본문 바로가기

React

React와 Redux Toolkit으로 상태 관리 구조화하기

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 처리 규칙이 일관적인가?

위 패턴을 팀 컨벤션으로 정착하면, 상태 관리가 예측 가능해지고 코드 리뷰/온보딩 비용이 크게 줄어듭니다.