TanStack Query v5 실전 패턴: 서버 상태 관리 완전 가이드

웹 개발

TanStack QueryReact Query서버 상태 관리React데이터 패칭

이 글은 누구를 위한 것인가

  • React Query v4에서 v5로 마이그레이션하려는 팀
  • 낙관적 업데이트와 무한 스크롤을 제대로 구현하고 싶은 개발자
  • Suspense와 함께 TanStack Query를 사용하고 싶은 팀

들어가며

TanStack Query v5는 API를 단순화하고 TypeScript 지원을 강화했다. onSuccess, onError 콜백이 제거되고, useQuery의 옵션이 통합됐다. 처음엔 낯설지만 이해하면 훨씬 명확해진다.

이 글은 bluefoxdev.kr의 React 서버 상태 관리 가이드 를 참고하고, TanStack Query v5 실전 패턴 관점에서 확장하여 작성했습니다.


1. v4 → v5 주요 변경점

// v4 방식 (deprecated)
const { data } = useQuery(
  ['posts', userId],  // 배열 쿼리 키 (별도 인자)
  () => fetchPosts(userId),
  {
    onSuccess: (data) => console.log(data),  // 제거됨
    onError: (error) => console.error(error), // 제거됨
  }
);

// v5 방식 (권장)
const { data } = useQuery({
  queryKey: ['posts', userId],  // 객체 형태로 통합
  queryFn: () => fetchPosts(userId),
  // onSuccess, onError 없음 → useEffect 또는 useMutation 콜백 사용
});

// onSuccess 대체 패턴
useEffect(() => {
  if (data) {
    console.log('데이터 로드 완료:', data);
  }
}, [data]);

// v5 주요 변경 요약
// ✅ useQuery 옵션이 객체 하나로 통합
// ✅ isLoading → isPending (초기 로딩)
// ✅ remove() → invalidateQueries() 또는 clear()
// ✅ Suspense 모드 안정화 (useSuspenseQuery)
// ❌ onSuccess, onError, onSettled 콜백 제거
// ❌ keepPreviousData → placeholderData: keepPreviousData

2. 기본 패턴

import {
  useQuery,
  useMutation,
  useQueryClient,
  useSuspenseQuery,
} from '@tanstack/react-query';

// 기본 조회
function useProduct(productId: string) {
  return useQuery({
    queryKey: ['products', productId],
    queryFn: () => api.getProduct(productId),
    staleTime: 5 * 60 * 1000,  // 5분간 fresh
    gcTime: 10 * 60 * 1000,    // 10분간 캐시 유지 (v5: cacheTime → gcTime)
    enabled: !!productId,       // productId가 있을 때만 실행
    retry: 2,
    retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10000),
  });
}

// 페이지네이션 (이전 데이터 유지)
function useProductList(page: number) {
  return useQuery({
    queryKey: ['products', 'list', page],
    queryFn: () => api.getProducts({ page }),
    placeholderData: keepPreviousData,  // v5: 이전 페이지 데이터 유지
  });
}

// Suspense 모드 (ErrorBoundary 필요)
function useProductSuspense(productId: string) {
  return useSuspenseQuery({
    queryKey: ['products', productId],
    queryFn: () => api.getProduct(productId),
  });
}

3. 낙관적 업데이트

function useLikePost() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (postId: string) => api.toggleLike(postId),

    // 낙관적 업데이트 — 서버 응답 전에 UI 먼저 변경
    onMutate: async (postId) => {
      // 진행 중인 리패치 취소 (낙관적 업데이트를 덮어쓰지 않도록)
      await queryClient.cancelQueries({ queryKey: ['posts'] });

      // 현재 캐시 데이터 저장 (롤백용)
      const previousPosts = queryClient.getQueryData<Post[]>(['posts']);

      // 낙관적 업데이트 적용
      queryClient.setQueryData<Post[]>(['posts'], (old) =>
        old?.map((post) =>
          post.id === postId
            ? { ...post, isLiked: !post.isLiked, likeCount: post.likeCount + (post.isLiked ? -1 : 1) }
            : post
        )
      );

      return { previousPosts };  // context로 롤백 데이터 전달
    },

    // 오류 시 롤백
    onError: (error, postId, context) => {
      if (context?.previousPosts) {
        queryClient.setQueryData(['posts'], context.previousPosts);
      }
    },

    // 성공/실패 모두 → 서버 데이터로 동기화
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });
}

// 사용 예시
function PostCard({ post }: { post: Post }) {
  const { mutate: toggleLike, isPending } = useLikePost();

  return (
    <button
      onClick={() => toggleLike(post.id)}
      disabled={isPending}
    >
      {post.isLiked ? '❤️' : '🤍'} {post.likeCount}
    </button>
  );
}

4. 무한 스크롤

import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
import { useEffect } from 'react';

function useInfinitePosts() {
  return useInfiniteQuery({
    queryKey: ['posts', 'infinite'],
    queryFn: ({ pageParam }) => api.getPosts({ cursor: pageParam, limit: 20 }),

    initialPageParam: null as string | null,  // v5: initialPageParam 필수
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,

    staleTime: 60 * 1000,
  });
}

function InfinitePostList() {
  const { ref, inView } = useInView();
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfinitePosts();

  useEffect(() => {
    if (inView && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);

  const posts = data?.pages.flatMap((page) => page.items) ?? [];

  return (
    <div>
      {posts.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}

      {/* 감시 요소 — 화면에 들어오면 다음 페이지 로드 */}
      <div ref={ref}>
        {isFetchingNextPage ? (
          <div>로딩 중...</div>
        ) : hasNextPage ? (
          <div>스크롤하여 더 보기</div>
        ) : (
          <div>모든 게시물을 불러왔습니다</div>
        )}
      </div>
    </div>
  );
}

5. 의존 쿼리와 프리패칭

// 의존 쿼리 — 이전 쿼리 결과를 다음 쿼리에 사용
function useUserAndPosts() {
  const { data: user } = useQuery({
    queryKey: ['user'],
    queryFn: () => api.getCurrentUser(),
  });

  const { data: posts } = useQuery({
    queryKey: ['posts', user?.id],
    queryFn: () => api.getPostsByUser(user!.id),
    enabled: !!user?.id,  // user가 로드된 후에만 실행
  });

  return { user, posts };
}

// 프리패칭 — 호버 시 미리 데이터 로드
function ProductCard({ productId }: { productId: string }) {
  const queryClient = useQueryClient();

  const prefetchProduct = () => {
    queryClient.prefetchQuery({
      queryKey: ['products', productId],
      queryFn: () => api.getProduct(productId),
      staleTime: 5 * 60 * 1000,
    });
  };

  return (
    <div
      onMouseEnter={prefetchProduct}  // 호버 시 프리패치
      onFocus={prefetchProduct}       // 포커스 시 프리패치
    >
      <Link href={`/products/${productId}`}>상품 보기</Link>
    </div>
  );
}

마무리

TanStack Query v5는 v4보다 API가 더 명확해졌다. onSuccess/onError 제거는 처음엔 불편하지만, 부작용(side effect)을 더 명시적으로 다루게 해준다.

낙관적 업데이트는 onMutateonError(롤백) → onSettled(동기화) 3단계 패턴을 기억하면 된다. 무한 스크롤은 useInfiniteQuery + IntersectionObserver(혹은 react-intersection-observer)의 조합이 표준이다.