이 글은 누구를 위한 것인가
- Redux/Zustand로 서버 데이터를 관리하다 TanStack Query로 전환하려는 팀
- Optimistic Update와 Infinite Scroll을 구현하려는 개발자
- Next.js App Router에서 TanStack Query와 RSC를 함께 사용하려는 팀
들어가며
서버에서 오는 데이터(서버 상태)는 클라이언트 상태와 다르다. 언제나 최신이 아니고, 공유되며, 캐시가 필요하고, 비동기다. TanStack Query는 서버 상태의 이런 특성을 인식하고 캐싱·동기화·갱신을 자동화한다.
이 글은 bluefoxdev.kr의 TanStack Query v5 가이드 를 참고하여 작성했습니다.
1. TanStack Query 핵심 개념
[서버 상태 vs 클라이언트 상태]
서버 상태:
- 원본은 서버에 있음 (캐시는 로컬)
- 여러 사용자가 공유
- 언제든 stale(오래됨) 해질 수 있음
- 비동기 API로만 접근
→ TanStack Query로 관리
클라이언트 상태:
- 앱 내에서만 존재
- 동기적으로 접근
- 단일 사용자
→ useState, Zustand, Jotai로 관리
[캐시 생명주기]
fresh → stale → inactive → deleted
fresh: staleTime 동안 재요청 없음
stale: 포커스/마운트 시 refetch
inactive: 화면에서 unmount된 상태
deleted: gcTime(기본 5분) 후 캐시 삭제
[queryKey 설계]
['todos'] → 전체 목록
['todos', todoId] → 단건
['todos', { status: 'done'}] → 필터링
계층적 무효화:
queryClient.invalidateQueries({ queryKey: ['todos'] })
→ ['todos'], ['todos', id], ['todos', filter] 모두 무효화
2. TanStack Query v5 구현 패턴
// 기본 설정
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5분간 fresh
gcTime: 1000 * 60 * 10, // 10분 후 캐시 삭제 (v5: gcTime, 이전: cacheTime)
retry: 1,
refetchOnWindowFocus: true,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<MyApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
// useQuery - 데이터 조회
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
interface Todo {
id: number;
title: string;
completed: boolean;
}
// 일반 useQuery
function TodoList() {
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ['todos'],
queryFn: async (): Promise<Todo[]> => {
const res = await fetch('/api/todos');
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
},
staleTime: 30_000, // 30초 fresh (전역 설정 오버라이드)
select: (data) => data.filter(t => !t.completed), // 완료되지 않은 것만
});
if (isLoading) return <Spinner />;
if (isError) return <Error message={error.message} />;
return <ul>{data?.map(todo => <TodoItem key={todo.id} todo={todo} />)}</ul>;
}
// Suspense 통합 (React 18+)
function TodoListSuspense() {
const { data } = useSuspenseQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
// isLoading 처리 불필요 - Suspense가 처리
return <ul>{data.map(todo => <TodoItem key={todo.id} todo={todo} />)}</ul>;
}
// 사용: <Suspense fallback={<Spinner />}><TodoListSuspense /></Suspense>
// useMutation + Optimistic Update
import { useMutation, useQueryClient } from '@tanstack/react-query';
function TodoItem({ todo }: { todo: Todo }) {
const queryClient = useQueryClient();
const toggleMutation = useMutation({
mutationFn: (todoId: number) =>
fetch(`/api/todos/${todoId}/toggle`, { method: 'PATCH' }).then(r => r.json()),
// Optimistic Update
onMutate: async (todoId) => {
// 진행 중인 refetch 취소 (충돌 방지)
await queryClient.cancelQueries({ queryKey: ['todos'] });
// 현재 캐시 스냅샷 저장
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// 즉시 UI 업데이트
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map(t =>
t.id === todoId ? { ...t, completed: !t.completed } : t
) ?? []
);
return { previousTodos }; // context로 onError에 전달
},
onError: (err, todoId, context) => {
// 실패 시 롤백
queryClient.setQueryData(['todos'], context?.previousTodos);
},
onSettled: () => {
// 성공/실패 무관하게 서버와 동기화
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleMutation.mutate(todo.id)}
disabled={toggleMutation.isPending}
/>
{todo.title}
</li>
);
}
// Infinite Query - 무한 스크롤
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
interface PageData {
todos: Todo[];
nextCursor: string | null;
}
function InfiniteTodoList() {
const { ref, inView } = useInView();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['todos', 'infinite'],
queryFn: async ({ pageParam }) => {
const url = pageParam
? `/api/todos?cursor=${pageParam}`
: '/api/todos';
const res = await fetch(url);
return res.json() as Promise<PageData>;
},
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
// 화면 끝 감지 시 자동 로드
useEffect(() => {
if (inView && hasNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage]);
const todos = data?.pages.flatMap(page => page.todos) ?? [];
return (
<>
<ul>{todos.map(todo => <TodoItem key={todo.id} todo={todo} />)}</ul>
<div ref={ref}>
{isFetchingNextPage ? <Spinner /> : hasNextPage ? '더 보기' : '모두 로드됨'}
</div>
</>
);
}
// Next.js App Router SSR prefetch
// app/todos/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
export default async function TodosPage() {
const queryClient = new QueryClient();
// 서버에서 prefetch
await queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
return (
// dehydrate된 캐시를 클라이언트로 전송
<HydrationBoundary state={dehydrate(queryClient)}>
<TodoList /> {/* 클라이언트에서 캐시 히트 → 로딩 없이 즉시 렌더 */}
</HydrationBoundary>
);
}
마무리
TanStack Query의 핵심은 서버 상태를 클라이언트 상태처럼 다루지 않는 것이다. staleTime으로 불필요한 네트워크 요청을 줄이고, invalidateQueries로 관련 캐시를 한 번에 무효화한다. Optimistic Update는 onMutate에서 즉시 UI를 업데이트하고 onError에서 롤백해 응답성을 높인다. Next.js App Router에서 서버 prefetch + HydrationBoundary로 초기 로딩 없는 UX를 만든다.