Featured image of post [Redux] 24. RTK Query - 데이터 페칭의 혁명

[Redux] 24. RTK Query - 데이터 페칭의 혁명

10편에서 언급한 서버 상태 전용 도구의 필요성을 RTK Query로 해결합니다. createApi로 캐싱·자동 재요청·뮤테이션을 다루고, keepUnusedDataFor·skip·useLazyQuery, React Query/SWR과의 관계까지 다룹니다.

24. RTK Query - 데이터 페칭의 혁명

10편에서 “서버 상태는 클라이언트 상태와 성격이 다르다"고 하며 전용 도구의 필요성을 언급했습니다. RTK Query가 그 공식 해답입니다. 이 편은 19편의 createAsyncThunk 방식과 RTK Query를 나란히 비교하며, 캐싱·자동 재요청·뮤테이션이 어떻게 선언적으로 처리되는지 다룹니다.

학습 목표

  • createApi로 API 엔드포인트를 정의하고, 자동 생성된 Hook으로 데이터를 조회할 수 있다.
  • RTK Query가 캐싱과 로딩 상태를 자동으로 관리하는 원리를 설명할 수 있다.
  • tagTypes를 이용한 캐시 무효화로, 뮤테이션 후 관련 데이터를 자동 재요청할 수 있다.

19편 복습: createAsyncThunk로 만든 수동 캐싱

19~20편에서 fetchTodoscreateAsyncThunk로 만들고, status 필드로 로딩 상태를 직접 관리했습니다.

1
2
3
4
// 19~20편: 직접 관리해야 하는 것들
// - status: 'idle' | 'loading' | 'succeeded' | 'failed'
// - 이미 불러온 데이터를 다시 요청하지 않으려면 22편의 getState 가드가 필요
// - 데이터를 추가/수정한 뒤 목록을 다시 불러오려면 fetchTodos()를 수동으로 재dispatch해야 함

이 모든 것이 틀린 방식은 아니지만, “서버 데이터를 가져오고, 캐시하고, 필요할 때 갱신한다"는 패턴은 프로젝트마다 반복해서 나타납니다. RTK Query는 이 반복을 표준화합니다.

createApi: 엔드포인트를 선언적으로 정의한다

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// features/api/todosApi.js
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";

export const todosApi = createApi({
  reducerPath: "todosApi",
  baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
  tagTypes: ["Todo"], // 캐시 무효화에 쓸 태그 종류 선언
  endpoints: (builder) => ({
    getTodos: builder.query({
      query: () => "/todos", // GET /api/todos
      providesTags: ["Todo"], // 이 쿼리 결과가 'Todo' 태그에 해당한다고 선언
    }),
    addTodo: builder.mutation({
      query: (text) => ({
        url: "/todos",
        method: "POST",
        body: { text },
      }),
      invalidatesTags: ["Todo"], // 이 뮤테이션 성공 시 'Todo' 태그를 가진 쿼리를 자동 무효화
    }),
  }),
});

export const { useGetTodosQuery, useAddTodoMutation } = todosApi; // Hook이 자동 생성됨

endpoints에 정의한 getTodos, addTodo로부터 useGetTodosQuery, useAddTodoMutation이라는 React Hook이 자동으로 생성됩니다. 이 명명 규칙(use + 엔드포인트 이름 + Query/Mutation)은 RTK Query가 항상 동일하게 따릅니다.

컴포넌트에서 사용하기: 19~20편과의 비교

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 24편: RTK Query 버전
import { useGetTodosQuery, useAddTodoMutation } from "./todosApi";

function TodoList() {
  const { data: todos, isLoading, error } = useGetTodosQuery(); // 요청·캐싱·로딩 상태 전부 자동
  const [addTodo] = useAddTodoMutation();

  if (isLoading) return <p>불러오는 ...</p>;
  if (error) return <p>에러가 발생했습니다</p>;

  return (
    <div>
      <button onClick={() => addTodo("새 할 일")}>추가</button>
      <ul>
        {todos.map((todo) => <li key={todo.id}>{todo.text}</li>)}
      </ul>
    </div>
  );
}

20편의 TodoList.jsx와 비교하면, useEffect로 마운트 시점에 fetchTodos()를 dispatch하는 코드, status를 직접 확인하는 조건문, 상태 구조(items/status/error)를 설계하는 과정이 모두 사라졌습니다. useGetTodosQuery() Hook 하나가 요청 시점 결정, 캐싱, 로딩/에러 상태를 전부 처리합니다.

캐싱이 자동으로 일어난다는 것의 의미

같은 쿼리를 여러 컴포넌트에서 호출해도, RTK Query는 네트워크 요청을 한 번만 보내고 결과를 공유합니다.

1
2
3
4
function TodoCount() {
  const { data: todos } = useGetTodosQuery(); // TodoList와 같은 쿼리 → 캐시 재사용, 추가 네트워크 요청 없음
  return <span>{todos?.length ?? 0}</span>;
}

TodoListTodoCount가 동시에 마운트되어도 /api/todos 요청은 한 번만 발생합니다. 22편에서 thunk로 이 최적화를 하려면 getState로 “이미 요청 중인지” 확인하는 가드를 직접 작성해야 했지만, RTK Query는 이를 기본 동작으로 제공합니다.

invalidatesTags/providesTags: 뮤테이션 후 자동 재요청

addTodo("새 할 일")가 성공하면, todosApiinvalidatesTags: ["Todo"] 설정에 따라 "Todo" 태그를 가진 모든 쿼리(getTodos)를 자동으로 무효화하고 다시 요청합니다.

sequenceDiagram
    participant UI as 컴포넌트
    participant RTK as RTK Query
    participant Server as 서버

    UI->>RTK: addTodo("새 할 일") 호출
    RTK->>Server: POST /api/todos
    Server-->>RTK: 201 Created
    RTK->>RTK: invalidatesTags: ["Todo"] 감지
    RTK->>Server: GET /api/todos (자동 재요청)
    Server-->>RTK: 최신 목록
    RTK-->>UI: useGetTodosQuery()가 새 데이터로 자동 리렌더

20편에서는 Todo를 추가한 뒤 화면을 갱신하려면, todoAdded 리듀서가 로컬 상태를 직접 업데이트하거나 fetchTodos()를 수동으로 재dispatch해야 했습니다. RTK Query는 **“이 데이터를 바꾸는 작업이 끝나면, 그 데이터에 의존하는 화면은 자동으로 최신 상태가 된다”**는 규칙을 태그 시스템으로 선언적으로 표현합니다.

폴링과 자동 재요청

RTK Query는 캐싱 외에도 실무에서 자주 필요한 기능을 옵션으로 제공합니다.

1
2
3
4
5
6
7
8
// 5초마다 자동으로 재요청 (실시간성이 필요한 대시보드 등에 사용)
const { data } = useGetTodosQuery(undefined, { pollingInterval: 5000 });

// 브라우저 탭이 다시 포커스될 때 자동 재요청
const todosApi = createApi({
  // ...
  refetchOnFocus: true,
});

캐시 수명과 조건부 요청: keepUnusedDataFor, skip, useLazyQuery

캐시가 언제까지 살아있는지, 그리고 항상 즉시 요청을 보내야 하는지도 상황에 따라 조정이 필요합니다.

1
2
3
4
5
// 이 쿼리를 구독하는 컴포넌트가 모두 사라진 뒤에도 60초간 캐시를 유지한다(기본값은 60초)
getTodos: builder.query({
  query: () => "/todos",
  keepUnusedDataFor: 60,
}),

컴포넌트가 언마운트되어도 keepUnusedDataFor에 지정한 시간 동안은 캐시가 남아있어서, 사용자가 화면을 잠깐 벗어났다 돌아와도 재요청 없이 즉시 이전 데이터를 보여줄 수 있습니다. 조건에 따라 요청 자체를 보내지 않아야 할 때는 skip 옵션을 씁니다.

1
2
3
4
5
function TodoDetail({ todoId }) {
  // todoId가 없으면(예: 아직 선택된 항목이 없음) 요청 자체를 보내지 않는다
  const { data: todo } = useGetTodoByIdQuery(todoId, { skip: !todoId });
  // ...
}

지금까지 본 useGetTodosQuery()는 컴포넌트가 마운트되자마자 자동으로 요청을 보냅니다. 반면 “버튼을 클릭해야만” 요청을 보내고 싶다면 useLazyQuery 버전을 씁니다.

1
2
3
4
5
6
import { useLazyGetTodosQuery } from "./todosApi"; // use + Lazy + 엔드포인트이름 + Query 규칙으로 자동 생성됨

function SearchButton() {
  const [trigger, { data, isFetching }] = useLazyGetTodosQuery();
  return <button onClick={() => trigger()}>{isFetching ? "불러오는 중..." : "검색"}</button>;
}

useLazyGetTodosQuery는 데이터를 담은 결과 대신 trigger 함수를 반환하고, 이 함수를 호출한 시점에만 실제로 요청이 나갑니다.

RTK Query와 React Query/SWR의 관계

RTK Query는 아이디어를 처음부터 새로 고안한 것이 아니라, 이미 널리 쓰이던 **React Query(TanStack Query)**와 SWR 같은 서버 상태 전용 라이브러리의 설계(캐싱, 자동 재요청, stale-while-revalidate 전략)에서 직접 영향을 받아 만들어졌습니다. “이미 React Query가 있는데 왜 RTK Query를 쓰는가"라는 질문에 대한 답은 생태계 통합에 있습니다.

구분RTK QueryReact Query / SWR
상태 저장 위치기존 Redux Store 안(같은 DevTools로 관찰 가능)별도의 자체 캐시 저장소
클라이언트 상태와의 통합같은 Store 안에서 useSelector로 함께 조합하기 쉬움별도 라이브러리라 조합에 추가 작업 필요
이미 Redux를 쓰는 프로젝트새 의존성 없이 바로 도입 가능상태 관리 계층이 두 개로 나뉨
Redux를 쓰지 않는 프로젝트Redux 전체를 들여와야 해 과함가볍게 바로 도입 가능

이미 Redux(Toolkit)를 쓰고 있고 클라이언트 상태와 서버 상태를 같은 DevTools·같은 Store 안에서 함께 다루고 싶다면 RTK Query가 자연스러운 선택이고, Redux를 쓰지 않는(또는 쓸 계획이 없는) 프로젝트라면 React Query나 SWR을 단독으로 쓰는 것이 더 가볍습니다.

RTK Query vs Thunk/Saga: 언제 무엇을 쓰는가

상황적합한 도구
서버 데이터를 조회·캐싱·자동 갱신해야 함(대부분의 CRUD)RTK Query(24편)
단순한 비동기 로직, 커스텀 흐름 제어가 필요함Thunk(22편)
여러 비동기 작업의 복잡한 순서 제어·요청 취소가 핵심임Saga(23편)

세 가지는 서로 배타적이지 않습니다. 실무 프로젝트에서는 **서버 데이터 페칭은 RTK Query, 그 외의 클라이언트 로직(폼 검증 흐름, 복잡한 UI 상태 전이)은 thunk나 일반 createSlice**로 나눠 쓰는 조합이 흔합니다. 21편에서 확인했듯 RTK Query도 내부적으로는 미들웨어 위에서 동작하므로, configureStoretodosApi.middleware를 추가해야 합니다.

1
2
3
4
5
6
7
export const store = configureStore({
  reducer: {
    [todosApi.reducerPath]: todosApi.reducer, // RTK Query 전용 리듀서 등록
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(todosApi.middleware), // 캐싱 로직을 처리하는 미들웨어
});

실무 체크리스트

  • 서버 데이터를 다루는 코드에서 createAsyncThunk + 수동 status 관리를 반복하고 있다면, RTK Query로 전환할 여지가 있는지 검토했는가?
  • 뮤테이션 후 관련 쿼리가 자동으로 최신화되도록 tagTypes/providesTags/invalidatesTags를 올바르게 연결했는가?
  • 실시간성이 필요한 데이터에 폴링(pollingInterval)이나 포커스 재요청(refetchOnFocus)을 고려했는가?
  • 조건에 따라 요청을 건너뛰어야 하는 곳에 skip을, 사용자 액션에만 반응해야 하는 곳에 useLazyQuery를 쓰고 있는가?
  • Redux를 쓰지 않는 프로젝트인데도 관성적으로 RTK Query를 도입하려 하지 않는가(그런 경우 React Query/SWR이 더 가벼울 수 있다)?

연습 과제

기초(★☆☆)

  • getTodos, addTodo 엔드포인트를 가진 todosApi를 만들고, useGetTodosQuery로 목록을 렌더링해보세요.

중급(★★☆)

  • deleteTodo 뮤테이션을 추가하고 invalidatesTags: ["Todo"]를 설정해, 삭제 후 목록이 자동으로 갱신되는지 확인해보세요.

고급(★★★)

  • getTodoById(id) 쿼리를 추가하고, 특정 Todo 하나만 무효화하는 세밀한 태그({ type: "Todo", id }) 구조로 리팩터링해보세요.
  • useLazyGetTodosQuery로 “검색” 버튼을 눌렀을 때만 요청이 나가는 컴포넌트를 만들고, skip 옵션으로 특정 조건에서 useGetTodoByIdQuery가 아예 요청을 보내지 않는지 확인해보세요.

요약

  • RTK Query는 서버 상태의 조회·캐싱·로딩 상태 관리·자동 갱신을 createApi 선언 하나로 표준화한다.
  • 같은 쿼리를 여러 컴포넌트가 호출해도 네트워크 요청은 한 번만 발생하고 결과가 공유된다.
  • tagTypes/providesTags/invalidatesTags로 뮤테이션 후 관련 쿼리의 자동 재요청을 선언적으로 연결하고, keepUnusedDataFor/skip/useLazyQuery로 캐시 수명과 요청 시점을 세밀하게 제어한다.
  • RTK Query는 React Query/SWR의 설계에서 영향을 받았으며, 이미 Redux를 쓰는 프로젝트에서는 같은 Store·DevTools로 통합된다는 점이 차별점이다.

참고 문헌 및 출처(추천)

  • Redux Toolkit 공식 문서, “RTK Query Overview”
  • Redux Toolkit 공식 문서, “RTK Query, Automated Re-fetching”
  • Redux 공식 문서, “Redux Essentials, Part 7: RTK Query Basics”
  • Redux Toolkit 공식 문서, “RTK Query vs React Query” — 비교 관점의 공식 안내

다음 글