이 글은 누구를 위한 것인가
- 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)을 더 명시적으로 다루게 해준다.
낙관적 업데이트는 onMutate → onError(롤백) → onSettled(동기화) 3단계 패턴을 기억하면 된다. 무한 스크롤은 useInfiniteQuery + IntersectionObserver(혹은 react-intersection-observer)의 조합이 표준이다.