Redux Toolkit(RTK)은 보일러플레이트를 줄이고, 일관된 패턴으로 상태를 구조화할 수 있게 도와줍니다. 이 글은 팀에서 바로 적용할 수 있는 디렉터리 구조, slice/thunk/selector 설계, RTK Query 도입 포인트까지 실무 중심으로 정리합니다.
1. 언제 Redux Toolkit을 쓰면 좋은가
다음 조건에 해당하면 RTK가 적합합니다.
- 클라이언트 전역 상태가 많고, 컴포넌트 간 공유가 잦습니다.
- 비동기 로직(요청/캐시/에러)이 반복됩니다.
- 상태 조회 성능을 위해 메모이즈된 selector가 필요합니다.
- 협업을 위해 일관된 액션/리듀서/파일 구조가 필요합니다.
2. 추천 폴더 구조
src/
app/
store.js
features/
todos/
todosSlice.js
todosSelectors.js
todosThunks.js
users/
usersSlice.js
shared/
apiClient.js
hooks.js
App.jsx
main.jsx
- feature 단위로 slice/selector/thunk를 모아 도메인 독립성 유지합니다.
- shared는 공용 훅, API 클라이언트 등 재사용 모듈을 둡니다.
3. 스토어 설정: 기본을 단단하게
// app/store.js
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from '../features/todos/todosSlice';
import usersReducer from '../features/users/usersSlice';
const isDev = process.env.NODE_ENV !== 'production';
export const store = configureStore({
reducer: {
todos: todosReducer,
users: usersReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
// API 응답에 File/Date 등 포함 시 유용
serializableCheck: false,
}),
devTools: isDev,
});
// shared/hooks.js - 컴포넌트에서 라이브러리 종속성 최소화
import { useDispatch, useSelector } from 'react-redux';
export const useAppDispatch = () => useDispatch();
export const useAppSelector = useSelector;
// main.jsx - Provider 연결 (JSX는 HTML 엔티티로 이스케이프)
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './app/store';
import App from './App';
createRoot(document.getElementById('root')).render(
<Provider store={store}>
<App />
</Provider>
);
4. Slice + Thunk: 상태/비동기 표준화
// features/todos/todosSlice.js
import { createSlice, createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit';
import api from '../../shared/apiClient';
const todosAdapter = createEntityAdapter({
selectId: (t) => t.id,
sortComparer: (a, b) => b.createdAt.localeCompare(a.createdAt),
});
export const fetchTodos = createAsyncThunk(
'todos/fetchTodos',
async (_, { rejectWithValue, signal }) => {
try {
const res = await api.get('/todos', { signal });
return res.data; // 배열
} catch (err) {
return rejectWithValue(err.response?.data || 'FETCH_TODOS_FAILED');
}
},
{
// 중복 로딩 방지
condition: (_, { getState }) => {
const { status } = getState().todos;
if (status === 'loading') return false;
return true;
},
}
);
const todosSlice = createSlice({
name: 'todos',
initialState: todosAdapter.getInitialState({
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
}),
reducers: {
todoAdded: todosAdapter.addOne,
todoRemoved: todosAdapter.removeOne,
todoToggled: (state, action) => {
const id = action.payload;
const todo = state.entities[id];
if (todo) todo.completed = !todo.completed;
},
reset: () => todosAdapter.getInitialState({ status: 'idle', error: null }),
},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(fetchTodos.fulfilled, (state, action) => {
state.status = 'succeeded';
todosAdapter.setAll(state, action.payload);
})
.addCase(fetchTodos.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload || action.error.message;
});
},
});
export const { todoAdded, todoRemoved, todoToggled, reset } = todosSlice.actions;
export default todosSlice.reducer;
export const todosSelectorsBase = todosAdapter.getSelectors((state) => state.todos);
5. EntityAdapter로 정규화
createEntityAdapter로 엔티티를 id-indexed 형태로 보관하면 대량 업데이트/조회 성능이 좋아집니다. 위 예제에서 setAll/addOne/removeOne 등을 사용합니다.
6. Selector 계층: 조회를 재사용 가능하게
// features/todos/todosSelectors.js
import { createSelector } from '@reduxjs/toolkit';
import { todosSelectorsBase } from './todosSlice';
export const selectTodoIds = todosSelectorsBase.selectIds;
export const selectTodoById = todosSelectorsBase.selectById;
export const selectAllTodos = todosSelectorsBase.selectAll;
export const selectActiveTodos = createSelector(
selectAllTodos,
(todos) => todos.filter((t) => !t.completed)
);
export const selectStatus = (state) => state.todos.status;
export const selectError = (state) => state.todos.error;
- useSelector에 바로 로직을 넣지 말고 selector로 분리해 재사용성과 메모이제이션을 확보합니다.
7. 컴포넌트 사용 패턴: 최소 리렌더
// features/todos/TodoList.jsx
import React, { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../../shared/hooks';
import { fetchTodos, todoToggled } from './todosSlice';
import { selectTodoIds, selectTodoById, selectStatus } from './todosSelectors';
export default function TodoList() {
const dispatch = useAppDispatch();
const status = useAppSelector(selectStatus);
const ids = useAppSelector(selectTodoIds);
useEffect(() => {
if (status === 'idle') dispatch(fetchTodos());
}, [status, dispatch]);
if (status === 'loading') return <p>로딩중...</p>;
return (
<ul>
{ids.map((id) => (
<TodoItem key={id} id={id} />
))}
</ul>
);
}
function TodoItem({ id }) {
const todo = useAppSelector((s) => selectTodoById(s, id));
const dispatch = useAppDispatch();
if (!todo) return null;
return (
<li>
<label>
<input
type="checkbox"
checked={!!todo.completed}
onChange={() => dispatch(todoToggled(id))}
/>
{todo.title}
</label>
</li>
);
}
- 목록은 id 배열을 구독하고, 각 아이템은 selectById로 개별 구독해 불필요한 리렌더를 줄입니다.
8. RTK Query로 서버 상태 분리
서버 캐시(페이징/무효화/리트라이)는 RTK Query로 분리하면 복잡도가 크게 낮아집니다. 전역 앱 상태와 서버 데이터 캐시는 다른 문제로 다룹니다.
// features/posts/postsApi.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Posts'],
endpoints: (builder) => ({
getPosts: builder.query({
query: () => '/posts',
providesTags: (result) =>
result
? [
...result.map(({ id }) => ({ type: 'Posts', id })),
{ type: 'Posts', id: 'LIST' },
]
: [{ type: 'Posts', id: 'LIST' }],
}),
addPost: builder.mutation({
query: (body) => ({ url: '/posts', method: 'POST', body }),
invalidatesTags: [{ type: 'Posts', id: 'LIST' }],
}),
}),
});
export const { useGetPostsQuery, useAddPostMutation } = postsApi;
// app/store.js - RTK Query 통합
import { postsApi } from '../features/posts/postsApi';
export const store = configureStore({
reducer: {
todos: todosReducer,
users: usersReducer,
[postsApi.reducerPath]: postsApi.reducer,
},
middleware: (gdm) => gdm().concat(postsApi.middleware),
});
- 서버 데이터는 useGetPostsQuery로 구독하고, 낙관적 업데이트/무효화 정책을 태그로 관리합니다.
9. 에러/로딩 정책 표준화
- slice state에 status(error 포함)를 표준 키로 두고 모든 thunk에서 동일하게 관리합니다.
- 컴포넌트는 status만 구독해 UI 상태를 일관되게 표현합니다.
- createAsyncThunk의 signal로 요청 취소를 전달해 언마운트/중복 요청에 안전하게 처리합니다.
// shared/apiClient.js - 예: axios 인스턴스
import axios from 'axios';
const api = axios.create({ baseURL: '/api', timeout: 10000 });
export default api;
10. 운영 팁과 점진적 도입
- 작은 feature부터 RTK로 전환하고, 기존 Context/Reducer는 경계 유지 후 점진적으로 통합합니다.
- 액션명은 '도메인/의도'로 네이밍합니다. 예: todos/fetchTodos, users/updateProfile 등
- 상태는 항상 정규화하고, 컴포넌트는 selector만 의존하도록 강제하면 테스트/교체가 쉬워집니다.
- devTools에서 액션/상태를 추적해 회귀를 빠르게 찾습니다.
마무리 체크리스트
- [ ] feature 폴더에 slice/thunk/selector 구성했는가?
- [ ] EntityAdapter로 컬렉션 상태를 정규화했는가?
- [ ] selector로 조회 계층을 분리했는가?
- [ ] 서버 상태는 RTK Query로 분리했는가?
- [ ] status/error 처리 규칙이 일관적인가?
위 패턴을 팀 컨벤션으로 정착하면, 상태 관리가 예측 가능해지고 코드 리뷰/온보딩 비용이 크게 줄어듭니다.
'React' 카테고리의 다른 글
| React 앱 성능 최적화를 위한 메모이제이션 기법 (0) | 2026.04.19 |
|---|---|
| React에서 WebSocket 연결 구현하기 (0) | 2026.04.18 |
| React Testing Library로 컴포넌트 단위 테스트 작성하기 (0) | 2026.04.17 |
| React에서 Error Boundary로 오류 처리하기 (1) | 2026.04.16 |
| React 앱에서 Lazy Loading 이미지 처리하기 (1) | 2026.04.16 |