Redux를 쓰는 프로젝트에서 API 호출과 캐싱, 로딩/에러 상태 관리는 늘 골칫거리입니다. 직접 thunk를 만들고, 로딩 플래그를 관리하고, 캐시 무효화까지 처리하다 보면 코드가 금방 복잡해지고 일관성도 깨지기 쉽습니다.
이 문제를 구조적으로 해결하는 도구가 Redux Toolkit Query(RTK Query)입니다. RTK Query는 API 통신을 “Redux의 한 파트”로 보고, 데이터 패칭 + 캐싱 + 무효화 + 로딩/에러 상태를 표준화해 줍니다.
RTK Query를 쓰면 뭐가 달라지나?
- API 호출 로직이 slice/thunk에서 분리되고 표준화
- 캐싱, 자동 refetch, 중복 요청 방지
- 쿼리/뮤테이션 분리로 의도가 명확
- invalidateTags로 캐시 무효화 일관성 확보
- TypeScript 타입이 자연스럽게 전파
특히 Redux를 이미 사용 중인 프로젝트라면 React Query를 도입하는 것보다 RTK Query가 더 자연스럽게 맞는 경우가 많습니다.
설치
RTK Query는 Redux Toolkit에 포함되어 있습니다. 이미 RTK를 설치했다면 추가 설치가 필요 없는 경우가 많습니다.
npm install @reduxjs/toolkit react-redux
npm install -D @types/react-redux
기존 Redux/RTK 설정이 되어있다면 그대로 이어서 진행하시면 됩니다.
API 타입부터 정의하기 (실무 핵심)
RTK Query를 TypeScript로 쓸 때 가장 중요한 원칙은 “API 계약 타입부터 고정”하는 것입니다.
export type User = {
id: number;
name: string;
};
export type ApiResponse<T> =
| { ok: true; data: T }
| { ok: false; errorCode: string; message: string };
프로젝트 전체에서 응답 형식을 통일해두면 쿼리/뮤테이션 모두 예측 가능한 패턴으로 유지됩니다.
createApi로 API 모듈 만들기
RTK Query의 핵심은 createApi입니다.
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import type { ApiResponse, User } from "../types";
export const api = createApi({
reducerPath: "api",
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
tagTypes: ["User"],
endpoints: (builder) => ({
getUser: builder.query<ApiResponse<User>, number>({
query: (id) => `/users/${id}`,
providesTags: (result, error, id) => [{ type: "User", id }],
}),
}),
});
여기서 중요한 부분:
builder.query<응답타입, 파라미터타입>tagTypes와providesTags로 캐싱 단위를 정의
Store에 RTK Query 리듀서/미들웨어 등록
RTK Query는 store에 reducer와 middleware 등록이 필요합니다.
import { configureStore } from "@reduxjs/toolkit";
import { api } from "./api";
export const store = configureStore({
reducer: {
[api.reducerPath]: api.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(api.middleware),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
이 설정이 누락되면 RTK Query가 정상 동작하지 않습니다.
자동 생성 훅 사용하기
RTK Query는 endpoint를 정의하면 React 훅을 자동으로 생성합니다.
export const { useGetUserQuery } = api;
function Profile({ userId }: { userId: number }) {
const { data, isLoading, error } = useGetUserQuery(userId);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error</div>;
if (!data) return null;
if (!data.ok) {
return <div>{data.message}</div>;
}
return <div>{data.data.name}</div>;
}
응답 타입이 고정되어 있으니, 컴포넌트는 “무슨 데이터가 올지”를 추측할 필요가 없습니다.
Mutation(변경) 정의하기
조회는 query, 변경은 mutation으로 정의합니다.
type UpdateUserRequest = {
id: number;
name: string;
};
export const api = createApi({
reducerPath: "api",
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
tagTypes: ["User"],
endpoints: (builder) => ({
updateUser: builder.mutation<ApiResponse<User>, UpdateUserRequest>({
query: (body) => ({
url: `/users/${body.id}`,
method: "PUT",
body,
}),
invalidatesTags: (result, error, body) => [{ type: "User", id: body.id }],
}),
}),
});
핵심 포인트:
builder.mutation<응답타입, 요청타입>invalidatesTags로 관련 캐시 자동 무효화
Mutation 사용하기
export const { useUpdateUserMutation } = api;
function EditName({ userId }: { userId: number }) {
const [updateUser, { isLoading }] = useUpdateUserMutation();
const onClick = async () => {
const res = await updateUser({ id: userId, name: "New Name" }).unwrap();
if (!res.ok) {
alert(res.message);
return;
}
alert("Updated");
};
return (
<button onClick={onClick} disabled={isLoading}>
Save
</button>
);
}
unwrap()을 쓰면 Promise 결과로 응답을 직접 받을 수 있어 try/catch 흐름이 깔끔해집니다.
캐싱/무효화(tag) 설계 팁
RTK Query에서 성능과 일관성을 결정하는 것은 tag 설계입니다.
- 리소스 단위로 tagTypes 정의 (User, Post, Order 등)
- 조회 query는 providesTags
- 변경 mutation은 invalidatesTags
- id 기반으로 세분화하면 불필요한 refetch 감소
tag 설계가 잘 되면 API 상태 관리는 “거의 자동”이 됩니다.
실무에서 자주 하는 실수
- store에 api.middleware 등록을 빼먹음
- 응답 타입을 대충
any로 두고 시작 - tagTypes/invalidatesTags 설계를 안 해서 캐시가 꼬임
- query와 mutation을 thunk처럼 섞어 사용
RTK Query는 “규칙을 지킬수록 편해지는 도구”입니다.
실무 권장 체크리스트
- 응답 형식(ApiResponse<T>)을 프로젝트 표준으로 통일
- createApi는 도메인 단위로 분리 가능(사용자/주문/결제 등)
- tagTypes는 리소스 단위로 설계
- query는 providesTags, mutation은 invalidatesTags
- 컴포넌트는 자동 생성 훅만 사용
RTK Query + TypeScript 조합은 “API 상태 관리의 표준화”를 목표로 합니다. 설정을 한 번 제대로 해두면, 이후부터는 API 호출이 단순히 “훅을 호출하는 행위”가 되고 로딩/에러/캐싱/무효화는 프레임워크가 책임지게 됩니다.
- 응답 타입부터 고정
- query/mutation을 명확히 분리
- tag 기반 캐시 무효화로 일관성 유지
