deep-dive2025-06-08·12 min·41/348

Next.js에서 RTK Query 데이터 페칭 구현하기

Next.js에서 Redux Toolkit의 RTK Query를 활용하여 서버 상태를 효율적으로 관리하는 방법을 알아봅니다.

Next.js에서 RTK Query 데이터 페칭 구현하기

Introduction

RTK Query는 Redux Toolkit에 내장된 데이터 페칭 및 캐싱 라이브러리입니다. Next.js와 결합하면 서버 상태 관리를 자동화하고 캐싱 전략을 효과적으로 구현할 수 있습니다. 이 글에서는 RTK Query 설정부터 고급 캐싱 전략까지 다룹니다.

Environment

# 프로젝트 구조
my-app/
├── app/
│   ├── layout.tsx
│   └── page.tsx
├── store/
│   ├── index.ts
│   ├── api.ts
│   └── slices/
│       └── userSlice.ts
├── components/
│   └── UserList.tsx
└── package.json

# 기술 스택
Next.js: 14.2.5
Redux Toolkit: 2.0
RTK Query: 2.0
React Redux: 9.0
# 패키지 설치
npm install @reduxjs/toolkit react-redux

Problem

기존 Redux에서 데이터 페칭을 수동으로 처리하면 코드가 복잡해집니다:

// ❌ 수동 데이터 페칭
const fetchUsers = createAsyncThunk('users/fetch', async () => {
  const response = await fetch('/api/users');
  return response.json();
});

const userSlice = createSlice({
  name: 'users',
  initialState: {
    data: null,
    loading: false,
    error: null,
  },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.pending, (state) => {
        state.loading = true;
      })
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.loading = false;
        state.data = action.payload;
      })
      .addCase(fetchUsers.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message;
      });
  },
});

Analysis

RTK Query의 장점:

  1. 자동 캐싱: 중복 요청 방지
  2. 자동 리패칭: 캐시 만료 시 자동 업데이트
  3. Optimistic Update: UI 즉시 반영
  4. 코드 생성: TypeScript 타입 자동 생성

Solution

1. API 슬라이스 정의

// store/api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import type { RootState } from './index';

export interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

export interface Post {
  id: string;
  title: string;
  content: string;
  authorId: string;
}

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({
    baseUrl: '/api',
    prepareHeaders: (headers, { getState }) => {
      // 토큰 추가
      const token = (getState() as RootState).auth.token;
      if (token) {
        headers.set('authorization', `Bearer ${token}`);
      }
      return headers;
    },
  }),
  tagTypes: ['User', 'Post'],
  endpoints: (builder) => ({
    // 사용자 목록 조회
    getUsers: builder.query({
      query: () => '/users',
      providesTags: (result) =>
        result
          ? [
              ...result.map(({ id }) => ({ type: 'User' as const, id })),
              { type: 'User', id: 'LIST' },
            ]
          : [{ type: 'User', id: 'LIST' }],
    }),
    
    // 사용자 상세 조회
    getUserById: builder.query({
      query: (id) => `/users/${id}`,
      providesTags: (result, error, id) => [{ type: 'User', id }],
    }),
    
    // 사용자 생성
    createUser: builder.mutation>({
      query: (body) => ({
        url: '/users',
        method: 'POST',
        body,
      }),
      invalidatesTags: [{ type: 'User', id: 'LIST' }],
    }),
    
    // 사용자 수정
    updateUser: builder.mutation }>({
      query: ({ id, data }) => ({
        url: `/users/${id}`,
        method: 'PATCH',
        body: data,
      }),
      invalidatesTags: (result, error, { id }) => [{ type: 'User', id }],
    }),
    
    // 사용자 삭제
    deleteUser: builder.mutation({
      query: (id) => ({
        url: `/users/${id}`,
        method: 'DELETE',
      }),
      invalidatesTags: (result, error, id) => [
        { type: 'User', id },
        { type: 'User', id: 'LIST' },
      ],
    }),
  }),
});

export const {
  useGetUsersQuery,
  useGetUserByIdQuery,
  useCreateUserMutation,
  useUpdateUserMutation,
  useDeleteUserMutation,
} = api;

2. Redux 스토어 설정

// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import { setupListeners } from '@reduxjs/toolkit/query';
import { api } from './api';
import authReducer from './slices/authSlice';

export const makeStore = () => {
  const store = configureStore({
    reducer: {
      [api.reducerPath]: api.reducer,
      auth: authReducer,
    },
    middleware: (getDefaultMiddleware) =>
      getDefaultMiddleware().concat(api.middleware),
    devTools: process.env.NODE_ENV !== 'production',
  });

  setupListeners(store.dispatch);
  
  return store;
};

export type AppStore = ReturnType;
export type RootState = ReturnType;
export type AppDispatch = AppStore['dispatch'];

3. React Provider 설정

// app/layout.tsx
'use client';

import { Provider } from 'react-redux';
import { makeStore } from '@/store';
import { useRef } from 'react';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const storeRef = useRef(undefined);
  if (!storeRef.current) {
    storeRef.current = makeStore();
  }

  return (
    
      
        
          {children}
        
      
    
  );
}

4. 컴포넌트에서 사용

// components/UserList.tsx
'use client';

import { 
  useGetUsersQuery, 
  useDeleteUserMutation 
} from '@/store/api';

export function UserList() {
  const { 
    data: users, 
    isLoading, 
    isError, 
    error,
    refetch 
  } = useGetUsersQuery();
  
  const [deleteUser] = useDeleteUserMutation();
  
  if (isLoading) {
    return 
로딩 중...
; } if (isError) { return (

에러 발생: {JSON.stringify(error)}

); } return (
    {users?.map((user) => (
  • {user.name} {user.email}
  • ))}
); }

5. Optimistic Update 구현

// hooks/useOptimisticUpdate.ts
import { useDispatch } from 'react-redux';
import { api } from '@/store/api';
import type { User } from '@/store/api';

export function useOptimisticUserUpdate() {
  const dispatch = useDispatch();
  
  const updateUserOptimistic = async (
    userId: string, 
    updates: Partial
  ) => {
    // 1. 즉시 UI 업데이트
    dispatch(
      api.util.updateQueryData('getUsers', undefined, (draft) => {
        const user = draft.find((u) => u.id === userId);
        if (user) {
          Object.assign(user, updates);
        }
      })
    );
    
    try {
      // 2. 서버 요청
      await dispatch(
        api.endpoints.updateUser.initiate({ id: userId, data: updates })
      ).unwrap();
    } catch (error) {
      // 3. 실패 시 롤백
      dispatch(
        api.util.updateQueryData('getUsers', undefined, (draft) => {
          // 원래 데이터로 복원
          const index = draft.findIndex((u) => u.id === userId);
          if (index !== -1) {
            draft[index] = { ...draft[index], ...updates };
          }
        })
      );
      throw error;
    }
  };
  
  return { updateUserOptimistic };
}

6. 조건부 쿼리

// store/api.ts
export const api = createApi({
  // ... 기존 설정
  endpoints: (builder) => ({
    // 조건부 쿼리: 특정 조건에서만 실행
    getUserPosts: builder.query({
      query: ({ userId }) => `/users/${userId}/posts`,
      // enabled가 false면 쿼리 실행 안 함
      skip: (_, { enabled }) => enabled === false,
    }),
    
    // 폴링: 주기적 업데이트
    getNotifications: builder.query({
      query: () => '/notifications',
      pollingInterval: 30000, // 30초마다 폴링
    }),
  }),
});

7. Next.js SSR과 통합

// app/page.tsx
import { store } from '@/store';
import { api } from '@/store/api';
import { UserList } from '@/components/UserList';

export default async function HomePage() {
  // 서버 사이드에서 데이터 프리페치
  await store.dispatch(api.endpoints.getUsers.initiate());
  
  return (
    

사용자 목록

); }

Lessons Learned

  1. 자동 캐싱 활용: RTK Query의 태그 기반 캐싱 전략 활용
  2. Optimistic Update: 사용자 경험 향상을 위한 즉시 UI 반영
  3. 에러 처리: 쿼리 에러와 뮤테이션 에러를 구분하여 처리
  4. TypeScript 통합: API 타입 자동 생성으로 코드 안정성 확보

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.