TanStack Query v5(구 React Query)는 useQuery 시그니처 단일 객체 통합, cacheTime→gcTime 변경, onSuccess/onError 제거 등 v4 대비 조용한 변경이 많다. 기본 설정부터 낙관적 업데이트·useInfiniteQuery·Next.js App Router SSR 연동까지 실전 코드로 정리하고, v4→v5 마이그레이션 체크리스트를 제공한다.
React Query(현재 TanStack Query)를 v4에서 v5로 올렸을 때 처음 마주친 건 에러가 아니라 조용한 변화였다. cacheTime이 gcTime이 됐고, useQuery 두 번째 인수로 넘기던 옵션이 이제 한 개의 객체로만 들어간다.
TanStack Query는 서버 데이터 상태 관리를 위한 라이브러리다. 서버에서 가져온 데이터를 컴포넌트가 직접 관리하는 대신, 캐시·리페치·로딩 상태를 한 곳에서 처리한다. useState + useEffect + fetch 조합으로 했던 일을 훨씬 적은 코드로 해결할 수 있다. 이 글은 v5에서 달라진 점, 실전 패턴, 그리고 v4에서 올라갈 때 걸리는 부분을 정리한다.
v5에서 달라진 것 | v4 대비 주요 변경점 6가지
v5는 2023년 10월 출시됐고 현재 최신 안정 버전이다. v4와 API가 완전히 다르진 않지만, 마이그레이션 중 조용히 빠지는 변경이 몇 가지 있다.
항목
v4
v5
useQuery 시그니처
useQuery(key, fn, options)
useQuery({ queryKey, queryFn, ...options })
cacheTime
cacheTime
gcTime(가비지 컬렉션 시간)
onSuccess/onError 콜백
useQuery 옵션에 포함
제거됨. effect 또는 useMutation 권장
isLoading vs isPending
isLoading
isPending으로 변경(isLoading은 fetchStatus 기반)
status 타입
'loading'·'error'·'success'·'idle'
'pending'·'error'·'success'
Suspense 지원
실험적
안정. useSuspenseQuery 별도 훅
가장 많이 막히는 것은 useQuery 시그니처 변경과 onSuccess/onError 제거다. 둘 다 v4 문서나 오래된 유튜브 영상을 참고하면 그냥 넘어가기 쉽다.
TanStack Query DevTools로 캐시 키·staleTime·gcTime 상태를 실시간으로 확인할 수 있다
기본 설정 | QueryClient와 QueryClientProvider
설치와 기본 설정은 v4와 거의 같다.
설치
npm install @tanstack/react-query
# 개발 도구 (선택 — 쿼리 상태 시각화)
npm install @tanstack/react-query-devtools
QueryClient 설정 — 기본값 조정 포인트
// app/providers.tsx (Next.js App Router 기준)
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useState } from 'react'
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// 5분 동안은 캐시를 신선하게 본다
staleTime: 5 * 60 * 1000,
// 가비지 컬렉션 시간 (v4의 cacheTime)
gcTime: 10 * 60 * 1000,
// 네트워크 재연결 시 자동 리페치 여부
refetchOnWindowFocus: false,
// 실패 시 재시도 횟수
retry: 1,
},
},
})
}
let browserQueryClient: QueryClient | undefined
function getQueryClient() {
if (typeof window === 'undefined') return makeQueryClient()
if (!browserQueryClient) browserQueryClient = makeQueryClient()
return browserQueryClient
}
export function Providers({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient()
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
staleTime vs gcTime:staleTime은 데이터를 신선하게 볼 기간(이 동안은 리페치 안 함). gcTime은 캐시를 메모리에 유지하는 기간(마운트된 컴포넌트가 없을 때부터 카운트). staleTime 0은 매번 리페치, gcTime 0은 언마운트 즉시 삭제다.
useQuery 실전 패턴 | 로딩·에러·성공 처리
v5의 useQuery는 단일 객체를 받는다. 타입 추론이 더 잘 되고, eslint 플러그인과 함께 쓰면 필수 필드 누락을 컴파일 타임에 잡는다.
useQuery 기본 패턴 (v5 시그니처)
import { useQuery } from '@tanstack/react-query'
// queryKey는 배열. 캐시 키로 쓰인다.
// 같은 key를 여러 컴포넌트에서 쓰면 캐시를 공유한다.
export function useUserProfile(userId: string) {
return useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`)
if (!res.ok) throw new Error('유저 정보를 가져오지 못했습니다')
return res.json() as Promise<User>
},
staleTime: 10 * 60 * 1000, // 10분
enabled: !!userId, // userId 없으면 실행 안 함
})
}
// 컴포넌트에서 사용
function UserProfile({ userId }: { userId: string }) {
const { data, isPending, isError, error } = useUserProfile(userId)
if (isPending) return <Skeleton />
if (isError) return <ErrorBanner message={error.message} />
return <div>{data.name}</div>
}
v4에서 많이 쓰던 패턴 중 v5에서 제거된 것이 onSuccess와 onError 콜백이다. 아래처럼 side effect가 필요하면 useEffect로 분리한다.
onSuccess 대체 — useEffect 분리
// v4 방식 (v5에서 동작 안 함)
useQuery({
queryKey: ['user'],
queryFn: fetchUser,
onSuccess: (data) => showToast(`반갑습니다, ${data.name}`), // ❌ v5 제거됨
})
// v5 방식 — useEffect로 분리
const { data, isSuccess } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
})
useEffect(() => {
if (isSuccess && data) {
showToast(`반갑습니다, ${data.name}`)
}
}, [isSuccess, data])
useMutation과 낙관적 업데이트 | 응답 전에 UI를 먼저 바꾸는 법
낙관적 업데이트(Optimistic Update)는 서버 응답을 기다리지 않고 UI를 먼저 바꾼 뒤, 실패하면 원래 상태로 되돌리는 패턴이다. 좋아요 버튼이나 체크리스트처럼 응답 대기가 UX를 해치는 경우에 쓴다.
낙관적 업데이트 — useMutation + onMutate
import { useMutation, useQueryClient } from '@tanstack/react-query'
function useLikeMutation(postId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (liked: boolean) =>
fetch(`/api/posts/${postId}/like`, {
method: 'POST',
body: JSON.stringify({ liked }),
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json()),
onMutate: async (liked) => {
// 진행 중인 리페치를 취소해 낙관적 업데이트가 덮이지 않게 한다
await queryClient.cancelQueries({ queryKey: ['post', postId] })
// 현재 캐시값을 저장 (실패 시 롤백용)
const previous = queryClient.getQueryData<Post>(['post', postId])
// 캐시를 먼저 업데이트 (UI가 즉시 반응)
queryClient.setQueryData<Post>(['post', postId], (old) =>
old ? { ...old, liked, likeCount: liked ? old.likeCount + 1 : old.likeCount - 1 } : old
)
return { previous }
},
onError: (err, _, context) => {
// 실패하면 이전 값으로 롤백
if (context?.previous) {
queryClient.setQueryData(['post', postId], context.previous)
}
},
onSettled: () => {
// 성공·실패와 무관하게 최종 서버 상태로 동기화
queryClient.invalidateQueries({ queryKey: ['post', postId] })
},
})
}
onMutate에서 캐시를 먼저 바꾸고 실패 시 롤백하는 낙관적 업데이트 흐름
무한 스크롤 | useInfiniteQuery 실전
페이지네이션이 "다음 페이지" 버튼 방식이라면, 무한 스크롤은 스크롤 끝에 도달할 때 자동으로 다음 데이터를 가져오는 방식이다. useInfiniteQuery가 이 패턴을 담당한다.
grep -r "useInfiniteQuery" src/에서 initialPageParam 누락 확인
getNextPageParam null 반환
null 대신 undefined 반환하도록 수정
공식 코드모드(npx @tanstack/react-query-codemods)를 먼저 돌리면 단순 시그니처 변환은 자동으로 된다. 코드모드 이후 타입 에러가 남은 부분을 위 체크리스트로 수동 확인하는 순서가 효율적이다.
자주 묻는 질문
TanStack Query를 Zustand·Redux와 같이 써야 하나요?
서버 상태(API 응답)는 TanStack Query가 담당하고, 클라이언트 UI 상태(모달 열림 여부, 선택된 탭 등)는 Zustand나 useState로 관리하는 구분이 일반적이다. Redux까지 같이 쓸 필요는 보통 없다. 많은 팀이 Redux를 걷어내고 TanStack Query + Zustand 조합으로 가고 있다.
queryKey 설계 원칙이 있나요?
계층형으로 설계하는 것이 좋다. ['posts']는 게시물 전체, ['posts', userId]는 특정 유저의 게시물, ['posts', userId, { status: 'draft' }]처럼 필터를 객체로 추가한다. queryClient.invalidateQueries({ queryKey: ['posts'] })를 호출하면 posts로 시작하는 모든 캐시가 무효화된다.
네트워크가 끊어졌을 때 동작이 어떻게 되나요?
기본적으로 온라인 상태를 감지해, 오프라인이 됐다가 복구되면 실패한 쿼리를 자동으로 재시도한다. networkMode: 'offlineFirst'로 설정하면 오프라인에서도 캐시 데이터를 즉시 반환한다. PWA나 캐시 우선 전략이 필요한 경우에 쓴다.
useSuspenseQuery와 useQuery 중 언제 무엇을 써야 하나요?
useSuspenseQuery는 부모 <Suspense>와 함께 쓸 때 사용한다. 로딩 중에는 Suspense 폴백이 렌더링되고, 성공하면 data가 항상 non-null이다. 에러 처리는 ErrorBoundary로 분리된다. 컴포넌트가 단순해지지만 Suspense 경계를 잘 설계해야 워터폴 문제가 생기지 않는다.
staleTime을 Infinity로 설정하면 리페치가 전혀 없나요?
staleTime이 Infinity면 수동으로 invalidate하기 전까지는 리페치하지 않는다. 변경이 거의 없는 설정값·카테고리 목록 같은 데이터에 쓴다. refetchOnWindowFocus, refetchOnMount 모두 staleTime 기간이 지나지 않으면 무시된다.
뮤테이션 후 특정 쿼리만 갱신하려면 어떻게 하나요?
queryClient.invalidateQueries({ queryKey: ['posts', postId] })로 특정 키만 무효화한다. 더 정밀하게 하려면 queryClient.setQueryData로 캐시를 직접 업데이트한다. 두 방법의 차이는 invalidate는 즉시 리페치를 트리거하고, setQueryData는 로컬 값만 바꾼다는 점이다.