Frontend

[Frontend] Redux 비동기 관리 정리

teddy bear 2026. 7. 14. 18:22
반응형

Redux 비동기 관리 정리(thunk부터 RTK Query까지)

Redux의 reducer는 순수 함수여야 합니다. 그런데 API 호출, 타이머, 웹소켓처럼 세상의 대부분은 비동기입니다. 이 근본적인 긴장을 Redux 생태계가 어떻게 풀어왔는지, 그리고 지금 가장 권장되는 방식은 무엇인지 정리합니다.


1. 왜 Redux에서 비동기가 특별한 문제인가

Redux의 핵심 원칙은 reducer가 순수 함수여야 한다는 것입니다.

(state, action) => newState

같은 입력에는 항상 같은 출력이 나와야 하고, 부수 효과(side effect)가 없어야 합니다. 하지만 fetch로 서버에 요청을 보내는 행위 자체가 이미 부수 효과입니다. 그래서 Redux는 "비동기 로직은 reducer 바깥, 미들웨어라는 계층에서 처리한다"는 구조를 택했습니다.

Action 생성 → dispatch → [미들웨어 체인] → reducer → 새 state
                              ↑
                    여기서 비동기 작업, 로깅, 사이드이펙트 처리

이 미들웨어 계층을 무엇으로 채우느냐에 따라 redux-thunk, redux-saga, redux-observable 같은 서로 다른 생태계가 생겨났습니다. 오늘은 현재 사실상 표준으로 자리 잡은 Redux Toolkit(RTK) 중심으로, thunk부터 RTK Query까지 단계적으로 살펴봅니다.


2. 가장 기본: redux-thunk

Thunk는 "액션 대신 함수를 dispatch할 수 있게 해주는" 가장 단순한 미들웨어입니다.

function fetchUser(userId) {
  return async (dispatch, getState) => {
    dispatch({ type: 'user/loading' });
    try {
      const response = await api.get(`/users/${userId}`);
      dispatch({ type: 'user/loaded', payload: response.data });
    } catch (error) {
      dispatch({ type: 'user/error', payload: error.message });
    }
  };
}

개념은 단순하지만, 이 패턴을 액션마다 손으로 반복하면 loading/loaded/error 액션 타입 정의, 리듀서 분기, 로딩 상태 관리가 프로젝트 전체에 계속 중복됩니다. 이 반복을 없애기 위해 등장한 것이 createAsyncThunk입니다.


3. createAsyncThunk: 생명주기를 자동으로

Redux Toolkit의 createAsyncThunk는 하나의 비동기 함수로부터 pending / fulfilled / rejected 세 가지 액션을 자동으로 생성합니다.

import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';

export const fetchUsers = createAsyncThunk(
  'users/fetch',
  async (_, { rejectWithValue }) => {
    try {
      const response = await api.get('/users');
      return response.data;
    } catch (error) {
      return rejectWithValue(error.response?.data ?? error.message);
    }
  }
);

리듀서 쪽에서는 extraReducers로 이 세 가지 생명주기를 처리합니다.

const usersSlice = createSlice({
  name: 'users',
  initialState: { list: [], status: 'idle', error: null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.pending, (state) => {
        state.status = 'loading';
        state.error = null;
      })
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.list = action.payload;
      })
      .addCase(fetchUsers.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.payload ?? action.error.message;
      });
  },
});

여기서 rejectWithValue가 중요한 이유가 있습니다. 그냥 throw를 하면 action.error에는 직렬화된 일반 에러 정보만 담기지만, rejectWithValue를 쓰면 서버가 준 원본 에러 응답 구조를 그대로 payload에 담아 리듀서와 컴포넌트에서 활용할 수 있습니다.

// throw만 했을 때: action.error.message 정도만 접근 가능
// rejectWithValue를 썼을 때: action.payload로 서버의 에러 응답 구조 전체 접근 가능
if (fetchUsers.rejected.match(action)) {
  console.log(action.payload); // { code: 'USER_NOT_FOUND', field: 'email' } 등
}

<cite index="46-1">검증 로직이나 복잡한 데이터 변환은 thunk 안에 직접 넣기보다 별도의 서비스나 도메인 계층에 두는 것이 권장됩니다.</cite> thunk는 "요청을 보내고 결과를 액션으로 변환하는" 얇은 어댑터 역할에 집중시키는 것이 유지보수에 유리합니다.


4. 요청 취소: thunkAPI.signal

createAsyncThunk는 내부적으로 AbortController를 자동 생성해서 thunkAPI.signal로 넘겨줍니다. 같은 thunk가 다시 dispatch되면 이전 요청을 취소하도록 직접 연결할 수 있습니다.

export const fetchUserDetail = createAsyncThunk(
  'user/fetchDetail',
  async (userId, { signal, rejectWithValue }) => {
    try {
      const response = await fetch(`/api/users/${userId}`, { signal });
      return await response.json();
    } catch (error) {
      if (error.name === 'AbortError') {
        throw error; // 취소는 rejectWithValue로 감싸지 않고 그대로 던져 무시되게 함
      }
      return rejectWithValue(error.message);
    }
  },
  {
    // 이미 진행 중인 요청이 있으면 새 dispatch를 아예 막는 옵션
    condition: (userId, { getState }) => {
      const { status } = getState().user;
      return status !== 'loading';
    },
  }
);

condition 옵션은 특히 유용합니다. thunk 실행 자체를 조건부로 막을 수 있어서, 이전 글에서 다룬 "중복 요청 방지"를 리듀서 상태 기반으로 선언적으로 처리할 수 있습니다. 요청이 취소되면 fetchUserDetail.rejected 액션의 meta.aborted가 true로 설정되므로, 리듀서에서 "취소된 요청은 에러 상태로 취급하지 않기" 같은 분기도 가능합니다.

.addCase(fetchUserDetail.rejected, (state, action) => {
  if (action.meta.aborted) return; // 취소는 무시
  state.error = action.payload;
})

5. RTK Query: 서버 상태 관리는 아예 다른 도구로

createAsyncThunk로 손수 만든 로딩/에러/캐시 로직은 결국 거의 모든 프로젝트에서 똑같은 모양으로 반복됩니다. 이 반복을 근본적으로 없애기 위해 Redux Toolkit은 RTK Query라는, TanStack Query(구 React Query)와 유사한 철학의 데이터 페칭 전용 API를 함께 제공합니다.

<cite index="53-1">RTK Query는 Apollo Client, React Query, Urql, SWR 같은 기존 데이터 페칭 도구들의 아이디어에서 영감을 받았으며, 캐싱 로직 자체는 Redux Toolkit의 createSlice와 createAsyncThunk 위에 구축되어 있습니다.</cite>

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const userApi = createApi({
  reducerPath: 'userApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['User'],
  endpoints: (builder) => ({
    getUsers: builder.query({
      query: () => 'users',
      providesTags: ['User'],
    }),
    updateUser: builder.mutation({
      query: ({ id, ...patch }) => ({
        url: `users/${id}`,
        method: 'PATCH',
        body: patch,
      }),
      invalidatesTags: ['User'], // 이 뮤테이션 후 관련 쿼리를 자동 재검증
    }),
  }),
});

export const { useGetUsersQuery, useUpdateUserMutation } = userApi;

컴포넌트에서는 이렇게 씁니다. loading, error, 캐싱, 재요청 로직을 전혀 손으로 짤 필요가 없습니다.

function UserList() {
  const { data: users, isLoading, error } = useGetUsersQuery();
  const [updateUser] = useUpdateUserMutation();

  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>
          {user.name}
          <button onClick={() => updateUser({ id: user.id, active: false })}>
            비활성화
          </button>
        </li>
      ))}
    </ul>
  );
}

tagTypes / invalidatesTags: 캐시 무효화의 핵심

RTK Query의 캐싱 전략은 태그 기반 무효화입니다. 쿼리는 providesTags로 "나는 이 태그에 해당하는 데이터를 제공한다"고 선언하고, 뮤테이션은 invalidatesTags로 "이 태그를 가진 캐시는 이제 오래됐다"고 선언합니다. 태그가 겹치면 RTK Query가 알아서 관련 쿼리를 다시 fetch합니다. 개발자가 직접 "이 화면 갱신해야지" 하고 dispatch를 챙길 필요가 없어지는 것이 핵심 이점입니다.


6. createAsyncThunk vs RTK Query, 언제 무엇을 쓸까

상황 권장 도구

서버 데이터를 가져와 화면에 보여주고 캐싱까지 필요 RTK Query
REST/GraphQL API 호출 후 로딩·에러·재요청을 관리 RTK Query
여러 화면에서 같은 데이터를 공유하며 자동 재검증이 필요 RTK Query
파일 업로드 진행률처럼 세밀한 커스텀 제어가 필요한 단발성 작업 createAsyncThunk
로그인, 로그아웃처럼 순수 서버 상태가 아닌 앱 흐름 제어 로직 createAsyncThunk (또는 일반 액션)
여러 액션을 순차/조건부로 orchestration 해야 함 createAsyncThunk 또는 redux-saga

<cite index="50-1">Redux 공식 튜토리얼도 데이터 페칭의 기본 접근법으로 RTK Query를 가르치고 있으며</cite>, <cite index="47-1">createAsyncThunk 문서 자체도 RTK Query를 시도해보고 데이터 페칭 코드를 단순화할 수 있는지 검토해볼 것을 권장하고 있습니다.</cite> 즉, "서버에서 데이터를 읽어오는 것"이 목적이라면 기본값은 RTK Query이고, createAsyncThunk는 그 범주에 딱 들어맞지 않는 나머지 비동기 로직을 위한 도구로 이해하면 됩니다.


7. saga, observable: 왜 굳이 다른 미들웨어를 쓸까

redux-saga(제너레이터 함수 기반)나 redux-observable(RxJS 기반)은 thunk보다 훨씬 복잡한 비동기 흐름 제어가 필요할 때 쓰입니다.

  • 여러 액션이 특정 순서로만 발생해야 하는 워크플로우
  • 디바운스, 쓰로틀, 취소 같은 스트림 연산을 선언적으로 조합
  • 폴링, 웹소켓 이벤트처럼 "끝나지 않는" 비동기 흐름 관리
// redux-saga 예시: 검색어 입력마다 디바운스 후 이전 요청 취소
function* watchSearch() {
  yield takeLatest('search/query', function* (action) {
    yield delay(300);
    const results = yield call(api.search, action.payload);
    yield put({ type: 'search/success', payload: results });
  });
}

takeLatest가 "새 액션이 오면 이전 제너레이터를 자동 취소"하는 것을 기본 제공한다는 점은, 우리가 createAsyncThunk에서 AbortController를 직접 연결해야 했던 것과 대조적입니다. 다만 이런 표현력은 제너레이터 문법이라는 학습 비용을 동반하므로, 대부분의 CRUD 위주 애플리케이션에는 RTK Query만으로 충분한 경우가 많습니다.


8. Redux Toolkit의 listenerMiddleware: 가벼운 사이드이펙트용

saga까지는 필요 없지만, "특정 액션이 dispatch되면 부수 효과를 실행하고 싶다"는 요구에는 RTK의 listenerMiddleware가 적합합니다.

import { createListenerMiddleware } from '@reduxjs/toolkit';

const listenerMiddleware = createListenerMiddleware();

listenerMiddleware.startListening({
  actionCreator: userApi.endpoints.updateUser.matchFulfilled,
  effect: async (action, listenerApi) => {
    analytics.track('user_updated', { userId: action.meta.arg.originalArgs.id });
  },
});

RTK Query의 자동 생성 액션 매처(matchFulfilled 등)와 결합하면, 별도의 미들웨어 라이브러리 없이도 "요청 성공 시 로깅/트래킹" 같은 가벼운 사이드이펙트를 깔끔하게 붙일 수 있습니다.


9. 정리: 선택 기준 요약

서버 데이터를 읽어오고 캐싱/재검증이 필요하다
        │
        ├─ Yes → RTK Query
        │
        └─ No, 앱 흐름 제어/일회성 비동기 로직이다
                │
                ├─ 단순한 요청-응답 → createAsyncThunk
                │
                └─ 복잡한 오케스트레이션(순서, 취소, 폴링) → redux-saga / redux-observable
                        │
                        └─ 단발성 사이드이펙트만 필요 → listenerMiddleware

Redux의 비동기 처리 생태계는 "reducer는 순수해야 한다"는 원칙을 지키면서도, 실제로는 비동기가 지배적인 프론트엔드 현실과 타협해온 역사이기도 합니다. createAsyncThunk가 반복되는 보일러플레이트를 줄여줬고, RTK Query는 아예 "서버 상태"라는 문제 자체를 별도로 떼어내 해결했습니다. 지금 새 프로젝트를 시작한다면, 서버 데이터는 RTK Query로, 그 외의 앱 로직은 얇은 createAsyncThunk나 일반 액션으로 처리하는 조합이 가장 무난한 출발점입니다.