[React] React로 할 일 관리 앱 만들기: 8장 - 비동기 작업과 미들웨어
안녕하세요! 이번 포스트에서는 우리의 할 일 관리 앱에 비동기 작업 처리 기능을 추가하고, Redux Thunk 미들웨어를 사용하는 방법을 알아보겠습니다. 이를 통해 서버와의 통신을 시뮬레이션하고, 실제 애플리케이션에서 API 호출을 어떻게 처리할 수 있는지 배워보겠습니다.
1. Redux Thunk 설치하기
먼저 Redux Thunk를 설치합니다. 터미널에서 다음 명령어를 실행하세요:
npm install redux-thunk
2. 가짜 API 서비스 만들기
실제 서버 없이 비동기 작업을 시뮬레이션하기 위해 가짜 API 서비스를 만들어 보겠습니다. src
폴더에 api
폴더를 만들고, 그 안에 todoApi.js
파일을 생성합니다:
// src/api/todoApi.js
// 가짜 지연 함수
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// 가짜 데이터베이스
let todos = [];
export const fetchTodos = async () => {
await delay(500); // 0.5초 지연
return [...todos];
};
export const addTodoApi = async (text) => {
await delay(500);
const newTodo = { id: Date.now(), text, completed: false };
todos.push(newTodo);
return newTodo;
};
export const toggleTodoApi = async (id) => {
await delay(500);
const todo = todos.find(t => t.id === id);
if (todo) {
todo.completed = !todo.completed;
return { ...todo };
}
throw new Error('Todo not found');
};
export const deleteTodoApi = async (id) => {
await delay(500);
const index = todos.findIndex(t => t.id === id);
if (index !== -1) {
todos = todos.filter(t => t.id !== id);
return id;
}
throw new Error('Todo not found');
};
export const editTodoApi = async (id, text) => {
await delay(500);
const todo = todos.find(t => t.id === id);
if (todo) {
todo.text = text;
return { ...todo };
}
throw new Error('Todo not found');
};
3. Redux Thunk 액션 생성하기
이제 todoSlice.js
파일을 수정하여 비동기 액션을 추가합니다:
// src/store/todoSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { fetchTodos, addTodoApi, toggleTodoApi, deleteTodoApi, editTodoApi } from '../api/todoApi';
export const fetchTodosAsync = createAsyncThunk(
'todos/fetchTodos',
async () => {
const response = await fetchTodos();
return response;
}
);
export const addTodoAsync = createAsyncThunk(
'todos/addTodo',
async (text) => {
const response = await addTodoApi(text);
return response;
}
);
export const toggleTodoAsync = createAsyncThunk(
'todos/toggleTodo',
async (id) => {
const response = await toggleTodoApi(id);
return response;
}
);
export const deleteTodoAsync = createAsyncThunk(
'todos/deleteTodo',
async (id) => {
await deleteTodoApi(id);
return id;
}
);
export const editTodoAsync = createAsyncThunk(
'todos/editTodo',
async ({ id, text }) => {
const response = await editTodoApi(id, text);
return response;
}
);
const todoSlice = createSlice({
name: 'todos',
initialState: {
items: [],
status: 'idle',
error: null
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchTodosAsync.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchTodosAsync.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
.addCase(fetchTodosAsync.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
.addCase(addTodoAsync.fulfilled, (state, action) => {
state.items.push(action.payload);
})
.addCase(toggleTodoAsync.fulfilled, (state, action) => {
const todo = state.items.find(todo => todo.id === action.payload.id);
if (todo) {
todo.completed = action.payload.completed;
}
})
.addCase(deleteTodoAsync.fulfilled, (state, action) => {
state.items = state.items.filter(todo => todo.id !== action.payload);
})
.addCase(editTodoAsync.fulfilled, (state, action) => {
const todo = state.items.find(todo => todo.id === action.payload.id);
if (todo) {
todo.text = action.payload.text;
}
});
},
});
export default todoSlice.reducer;
4. 컴포넌트 수정하기
이제 각 컴포넌트를 수정하여 새로운 비동기 액션을 사용하도록 합니다.
TodoList 컴포넌트
// src/components/TodoList.js
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchTodosAsync } from '../store/todoSlice';
import TodoForm from './TodoForm';
import TodoItem from './TodoItem';
import TodoFilters from './TodoFilters';
import TodoStats from './TodoStats';
import './TodoList.css';
function TodoList() {
const dispatch = useDispatch();
const { items: todos, status, error } = useSelector(state => state.todos);
const [filter, setFilter] = React.useState('all');
useEffect(() => {
if (status === 'idle') {
dispatch(fetchTodosAsync());
}
}, [status, dispatch]);
const filteredTodos = todos.filter((todo) => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});
if (status === 'loading') {
return <div>로딩 중...</div>;
}
if (status === 'failed') {
return <div>에러: {error}</div>;
}
return (
<div className="todo-app">
<h2>할 일 관리 앱</h2>
<TodoForm />
<TodoFilters filter={filter} setFilter={setFilter} />
<ul className="todo-list">
{filteredTodos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
<TodoStats />
</div>
);
}
export default TodoList;
TodoForm 컴포넌트
// src/components/TodoForm.js
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { addTodoAsync } from '../store/todoSlice';
function TodoForm() {
const [inputValue, setInputValue] = useState('');
const dispatch = useDispatch();
const handleSubmit = (e) => {
e.preventDefault();
if (inputValue.trim() !== '') {
dispatch(addTodoAsync(inputValue));
setInputValue('');
}
};
return (
<form className="todo-form" onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="새로운 할 일을 입력하세요"
/>
<button type="submit">추가</button>
</form>
);
}
export default TodoForm;
TodoItem 컴포넌트
// src/components/TodoItem.js
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { toggleTodoAsync, deleteTodoAsync, editTodoAsync } from '../store/todoSlice';
function TodoItem({ todo }) {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(todo.text);
const dispatch = useDispatch();
const handleEdit = () => {
dispatch(editTodoAsync({ id: todo.id, text: editValue }));
setIsEditing(false);
};
return (
<li className="todo-item">
<input
type="checkbox"
checked={todo.completed}
onChange={() => dispatch(toggleTodoAsync(todo.id))}
/>
{isEditing ? (
<input
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleEdit}
autoFocus
/>
) : (
<span
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
onDoubleClick={() => setIsEditing(true)}
>
{todo.text}
</span>
)}
<button onClick={() => dispatch(deleteTodoAsync(todo.id))}>삭제</button>
</li>
);
}
export default TodoItem;
TodoStats 컴포넌트
TodoStats 컴포넌트는 변경할 필요가 없습니다.
5. 스토어 설정 수정하기
src/store/store.js
파일을 수정하여 Redux Thunk 미들웨어를 추가합니다:
// src/store/store.js
import { configureStore } from '@reduxjs/toolkit';
import todoReducer from './todoSlice';
const store = configureStore({
reducer: {
todos: todoReducer,
},
// Redux Toolkit의 configureStore는 기본적으로 Redux Thunk를 포함합니다.
});
export default store;
실행 결과
이제 npm start
명령어로 앱을 실행하면, 비동기 작업을 처리하는 할 일 관리 앱을 볼 수 있습니다. 각 작업(추가, 삭제, 수정, 완료 상태 변경)에 약간의 지연이 있을 것입니다. 이는 실제 서버 통신을 시뮬레이션한 것입니다.
마무리
이번 챕터에서는 Redux Thunk를 사용하여 비동기 작업을 처리하는 방법을 배웠습니다. 이를 통해 얻을 수 있는 장점은 다음과 같습니다:
- 비동기 로직 분리: 컴포넌트에서 비동기 로직을 분리하여 관리할 수 있습니다.
- 상태 업데이트 일관성: 서버의 응답을 기반으로 상태를 업데이트하여 일관성을 유지할 수 있습니다.
- 에러 처리: 비동기 작업의 실패를 쉽게 처리하고 사용자에게 알릴 수 있습니다.
- 로딩 상태 관리: 데이터를 불러오는 동안 로딩 상태를 쉽게 관리할 수 있습니다.
Redux Thunk를 사용하면서 느낀 점이 있나요? 비동기 작업 처리가 더 쉬워졌나요, 아니면 더 복잡해졌나요? 여러분의 경험을 댓글로 공유해 주세요!
다음 챕터에서는 단위 테스트 작성 방법에 대해 알아보겠습니다. React, Redux, 그리고 비동기 작업을 포함한 애플리케이션을 어떻게 테스트할 수 있는지 살펴볼 예정입니다. React와 Redux로 앱을 개발하면서 궁금한 점이 있다면 언제든 질문해 주세요!