TanStack Query v5 완전 가이드: 서버 상태 관리의 모든 것

프론트엔드

TanStack QueryReact Query서버 상태 관리캐싱React

이 글은 누구를 위한 것인가

  • 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를 만든다.