TechFeedTechFeed
Frontend

TanStack Query v5 실전 심층 분석 | 캐시·staleTime·무효화·Next.js

탠스택 쿼리(TanStack Query) v5로 서버 상태를 다루는 실전 심층 가이드. queryKey 설계, staleTime·gcTime·refetch 정책, useMutation 캐시 무효화, 무한 스크롤, 에러 재시도, Next.js 앱 라우터 경계, 주스탄트와의 역할 분리를 표·코드로 정리. 개발자·프론트엔드·React·TypeScript·API 캐시 실무용 FAQ 포함.

by

서버에서 받아 온 목록·상세·권한 상태를 화면에서 안정적으로 보여 주려면, 직접 useEffect로 fetch를 돌리기보다 탠스택 쿼리(TanStack Query) v5의 캐시와 재조회 규칙을 먼저 잡는 편이 낫다. 로딩·에러·재시도·백그라운드 갱신·탭 복귀 시 재요청까지 한 라이브러리 정책으로 묶이기 때문이다.




이 글은 설치 체크리스트가 아니라 queryKey 설계, staleTime·gcTime, 뮤테이션 후 무효화, 무한 스크롤, 넥스트 앱 라우터와 같이 쓸 때의 경계를 깊게 파 본다. 클라이언트 전역 상태(주스탄트·리덕스)와 역할이 어떻게 갈리는지, 어떤 옵션을 기본값으로 두면 운영 사고가 줄어드는지 표와 코드로 고정한다. 리액트 18+·타입스크립트 프로젝트를 기준으로 한다.


서버 상태와 클라이언트 상태를 가르는 기준

탠스택 쿼리가 다루는 것은 서버가 진실 공급원인 데이터다. 게시글 목록, 사용자 프로필, 결제 상태, 권한 플래그처럼 “다시 받아 오면 최신”인 값이 해당한다. 반대로 모달 열림, 사이드바 접힘, 폼 임시 입력, 테마 토글처럼 브라우저 안에서만 의미가 있는 값은 클라이언트 상태 라이브러리 영역이다.


둘을 한 스토어에 섞으면 보통 이런 일이 난다. 목록을 전역 상태에 넣고 화면마다 수동으로 다시 불러오거나, 수정 성공 후 목록 배열을 손으로 패치하다 필드가 어긋난다. 서버 상태는 캐시 키와 수명으로 관리하고, UI 상태만 가벼운 스토어에 두는 분리가 기본이다.


구분 예시 추천 도구
서버 상태 API 목록·상세·권한·검색 결과 탠스택 쿼리
클라이언트 UI 상태 모달, 탭 인덱스, 드래프트 입력 useState / 주스탄트 등
URL 상태 필터, 페이지, 정렬 searchParams + queryKey 반영
폼 검증 스키마 가입·설정 입력 규칙 Zod 등 스키마 + 뮤테이션

클라이언트 상태 쪽 비교는 리액트 상태 관리 비교 2026을 보면 되고, 스키마 검증은 Zod 스키마 검증 가이드와 연결하면 뮤테이션 입력을 단단히 잠글 수 있다. 서버 컴포넌트에서 이미 데이터를 내려주는 구조는 리액트 서버 컴포넌트 실전 가이드와 함께 읽으면 경계가 더 선명해진다.


설치와 QueryClient 기본 세팅

패키지 이름은 @tanstack/react-query다. v4 시절 react-query 패키지와 혼동하지 말 것. 앱 루트에 프로바이더를 한 번 감싸고, 그 안의 모든 훅이 같은 캐시를 공유한다.


QueryClient + Provider (app/providers.tsx 예시)
'use client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useState, type ReactNode } from 'react' export function Providers({ children }: { children: ReactNode }) { const [client] = useState( () => new QueryClient({ defaultOptions: { queries: { // 기본 0이면 마운트마다 즉시 stale → 과도한 재요청 staleTime: 60 * 1000, // v5: cacheTime → gcTime 이름 변경 gcTime: 5 * 60 * 1000, retry: 1, refetchOnWindowFocus: true, }, }, }) ) return <QueryClientProvider client={client}>{children}</QueryClientProvider> }

QueryClient를 컴포넌트 바깥 모듈 스코프 싱글톤으로 두면 넥스트 앱 라우터처럼 서버·클라이언트가 섞인 환경에서 요청 간 캐시가 섞일 위험이 있다. 클라이언트 전용 프로바이더 안에서 useState(() => new QueryClient(...))로 만드는 패턴이 공식 권장에 가깝다.


개발 중에는 @tanstack/react-query-devtools를 붙이면 queryKey별 상태·무효화 타이밍이 눈에 들어온다. 프로덕션 번들에는 넣지 않는다.


리액트 프론트엔드에서 서버 상태 캐시를 다루는 개발 화면
탠스택 쿼리 | 서버 상태 캐시와 클라이언트 UI 상태를 분리하는 흐름

useQuery 기본 | queryKey·queryFn·enabled

조회 한 건의 단위는 queryKey + queryFn이다. 키가 같으면 같은 캐시 슬롯을 공유하고, 키가 바뀌면 다른 조회로 취급한다. 키에는 “이 데이터를 유일하게 식별하는 입력”만 넣는다. 사용자 아이디, 필터, 페이지 번호, 로케일 등이 대표적이다.


useQuery 최소 예제
import { useQuery } from '@tanstack/react-query' type Post = { id: string; title: string } async function fetchPost(id: string): Promise<Post> { const res = await fetch(`/api/posts/${id}`) if (!res.ok) throw new Error('post fetch failed') return res.json() } export function PostDetail({ postId }: { postId: string }) { const { data, isPending, isError, error, isFetching } = useQuery({ queryKey: ['posts', postId], queryFn: () => fetchPost(postId), enabled: Boolean(postId), }) if (isPending) return <p>불러오는 중…</p> if (isError) return <p>실패: {error.message}</p> return ( <article> <h1>{data.title}</h1> {isFetching ? <span>백그라운드 갱신 중</span> : null} </article> ) }

v5에서 로딩 플래그 이름이 정리됐다. 첫 로딩은 isPending(또는 isLoading = pending이면서 fetch 중), 캐시가 있는 재요청은 isFetching이다. “스켈레톤을 띄울지”와 “작은 스피너만 돌릴지”를 이 둘로 나눈다.


  • enabled: false | 아이디가 아직 없을 때, 로그인 전에는 요청 자체를 막는다
  • queryKey 배열 | 문자열 하나보다 ['posts', id, filters]처럼 계층적으로 쪼개면 부분 무효화가 쉽다
  • select | 응답 전체 대신 화면이 쓸 필드만 골라 리렌더 범위를 줄인다
  • placeholderData / keepPreviousData 계열 | 페이지 전환 시 깜빡임을 줄인다(v5는 placeholderData: keepPreviousData 패턴)

키가 너무 길거나 객체 참조가 매번 새로 만들어지면 캐시 히트가 깨진다. 필터 객체는 안정된 직렬화(정렬된 키, 혹은 검색 파라미터 문자열)로 넣는 습관이 필요하다.


staleTime·gcTime·refetch 정책 심층

운영에서 가장 많이 손대는 손잡이가 staleTimegcTime이다. 둘을 섞어 쓰면 사고 난다. 의미를 짧게 고정한다.


옵션 하는 일 실무 기본 감각
staleTime 데이터를 “신선”으로 간주하는 시간. 이 안에서는 자동 재조회를 참는다 목록 30초~2분, 거의 안 바뀌는 설정은 5~30분
gcTime 구독자가 0이 된 뒤 캐시를 메모리에 남기는 시간(v4 cacheTime) 기본 5분대. 뒤로 가기 UX를 살리려면 넉넉히
refetchOnWindowFocus 탭 복귀 시 stale이면 다시 요청 관리자·실시간성 높은 화면 ON, 무거운 리포트는 OFF 검토
refetchOnReconnect 네트워크 복구 후 재조회 모바일·현장 앱에서 특히 유용
refetchInterval 폴링 주기 대시보드만 선택적으로. 전역 기본값으로 넣지 말 것

기본 staleTime: 0은 “마운트·포커스마다 서버와 맞춘다”는 보수적 설정이다. 트래픽이 큰 목록 API에 그대로 두면 포커스 한 번에 요청이 쏟아진다. 반대로 너무 길게 잡으면 수정 직후 다른 탭에서 옛 데이터가 보인다. 데이터 성격별로 쿼리 팩토리에서 staleTime을 다르게 두는 편이 안전하다.


결제 직후 잔액, 재고, 권한처럼 틀리면 비용이 큰 값은 staleTime을 짧게 하거나 뮤테이션 성공 시 즉시 무효화한다. 블로그 카테고리 목록처럼 거의 안 바뀌는 값은 길게 둬도 된다.


자주 하는 실수: staleTime과 gcTime을 같은 숫자로 맞추면 “신선 시간”과 “메모리 보관 시간”이 같아져 의도 파악이 어려워진다. 신선도는 짧게, 가비지 컬렉션은 그보다 길게 두는 조합이 일반적이다.

useMutation과 캐시 무효화 전략

쓰기 작업은 useMutation으로 보낸다. 성공 후 화면을 맞추는 방법은 크게 세 가지다. 무효화(invalidate), 직접 캐시 갱신(setQueryData), 낙관적 업데이트(onMutate + 롤백).


생성 후 목록 무효화
import { useMutation, useQueryClient } from '@tanstack/react-query' export function useCreatePost() { const qc = useQueryClient() return useMutation({ mutationFn: async (body: { title: string }) => { const res = await fetch('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) if (!res.ok) throw new Error('create failed') return res.json() }, onSuccess: async () => { // ['posts'] 로 시작하는 모든 목록 쿼리 재조회 예약 await qc.invalidateQueries({ queryKey: ['posts'] }) }, }) }

invalidateQueries는 “이 키는 이제 낡았다”고 표시하고, 현재 구독 중인 쿼리부터 다시 가져온다. 목록 키가 ['posts', { page, q }]처럼 잘려 있어도 프리픽스 ['posts']로 한 번에 걸 수 있다. 이게 queryKey를 배열로 설계하는 이유다.


상세만 바뀌었고 응답 본문을 이미 받았다면 setQueryData(['posts', id], data)로 즉시 반영한 뒤, 목록은 백그라운드 무효화만 걸어 체감을 살릴 수 있다. 낙관적 업데이트는 UX가 좋지만 롤백·레이스 조건 코드가 늘므로, 생성·삭제처럼 목록 구조가 바뀌는 작업은 무효화 우선, 좋아요 토글처럼 필드 하나가 바뀌는 작업에 낙관적 패턴을 쓰는 편이 유지보수에 유리하다.


서버 액션으로 쓰기를 처리하는 넥스트 앱이라면 액션 성공 후 클라이언트에서 같은 키를 무효화하거나, 라우터 캐시 무효화와 역할을 나눠야 한다. 서버 액션 쪽 흐름은 Next.js Server Actions 심층 분석을 참고하면 된다.


API 뮤테이션 이후 캐시 무효화 흐름을 설명하는 다이어그램 느낌의 개발 장면
뮤테이션 성공 → invalidateQueries → 구독 중 목록·상세 재조회

페이지네이션과 무한 스크롤 패턴

오프셋·커서 기반 목록은 useInfiniteQuery가 맞다. 페이지마다 다른 queryKey를 수동으로 관리하지 말고, pageParamgetNextPageParam으로 “다음 커서”만 서버 계약에 맞춘다.


커서 기반 useInfiniteQuery
import { useInfiniteQuery } from '@tanstack/react-query' type Page = { items: { id: string; title: string }[]; nextCursor: string | null } async function fetchFeed({ pageParam }: { pageParam: string | null }): Promise<Page> { const qs = pageParam ? `?cursor=${pageParam}` : '' const res = await fetch(`/api/feed${qs}`) if (!res.ok) throw new Error('feed failed') return res.json() } export function useFeed() { return useInfiniteQuery({ queryKey: ['feed'], queryFn: fetchFeed, initialPageParam: null as string | null, getNextPageParam: (last) => last.nextCursor, }) }

화면에서는 data.pages.flatMap(p => p.items)로 펼치고, 센티널 요소가 보이면 fetchNextPage()를 호출한다. hasNextPage·isFetchingNextPage로 버튼 비활성과 중복 호출을 막는다.


페이지 번호 UI(1·2·3 버튼)만 있는 관리자 화면은 굳이 무한 쿼리가 아니어도 된다. useQuery({ queryKey: ['posts', page] })placeholderData: keepPreviousData를 쓰면 페이지 전환 시 빈 화면 깜빡임이 줄어든다. 검색어가 바뀌면 키의 검색 부분을 바꿔 캐시를 분리한다. 이전 검색 결과와 새 결과가 섞이는 버그를 막기 위해서다.


에러·로딩·Suspense를 화면에서 나누는 법

한 화면에 쿼리가 여러 개면 로딩 처리가 지저분해진다. 패턴을 세 가지로만 기억해도 충분하다.


  1. 필수 데이터 1개 | isPending일 때 페이지 스켈레톤. 에러면 재시도 버튼(refetch).
  2. 본문 + 부가 위젯 | 본문 쿼리 실패는 전체 에러, 위젯 쿼리 실패는 위젯만 폴백. 위젯에 throwOnError: false 감각으로 독립시킨다.
  3. Suspense 경계 | useSuspenseQuery로 데이터를 반드시 있게 만들고, 상위 Suspense와 에러 바운더리에 맡긴다. 레이아웃 단위 로딩에 잘 맞는다.

재시도 기본값은 실패 시 최대 몇 번 다시 치는가다. 4xx(권한·잘못된 요청)에 재시도하면 서버만 아프다. retry를 함수로 두어 상태 코드별로 끊는 편이 낫다. 전역 API 레이트리밋이 있다면 프로덕션 API Rate Limiting 설계와 맞춰 클라이언트 폭주도 같이 줄인다.


상태 코드별 retry 예시
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: (failureCount, error) => { const status = (error as { status?: number })?.status if (status && status >= 400 && status < 500) return false return failureCount < 2 }, }, }, })

에러 객체에 상태 코드를 심으려면 queryFn에서 throw할 때 커스텀 에러 클래스를 쓰거나, fetch 래퍼에서 한 번 정규화한다. 화면 메시지에는 내부 스택을 그대로 노출하지 말고, 사용자용 문구와 로그용 상세를 분리한다.


Next.js 앱 라우터와 같이 쓸 때 경계

서버 컴포넌트는 이미 서버에서 await fetch를 할 수 있다. 그럼에도 탠스택 쿼리를 쓰는 이유는 대체로 이렇다. 클라이언트 상호작용 이후의 재조회, 탭 포커스 갱신, 뮤테이션 후 캐시 공유, 무한 스크롤이다. “첫 페인트용 데이터”와 “이후 클라이언트 서버 상태”를 나누면 설계가 단순해진다.


  • 초기 HTML에 꼭 넣어야 하는 SEO·핵심 본문 → 서버 컴포넌트 fetch 또는 서버 액션 읽기
  • 로그인 후 개인화 위젯, 알림 뱃지, 무한 피드 → 클라이언트 쿼리
  • 서버에서 받은 초기값을 클라이언트 캐시에 심기 → initialData / hydration 유틸(dehydrate·HydrationBoundary)

하이드레이션 경로를 쓰면 서버에서 미리 채운 캐시를 클라이언트가 그대로 이어받는다. 키가 서버·클라이언트에서 동일해야 한다. 날짜 객체·맵처럼 직렬화 안 되는 값을 키에 넣으면 깨진다. 앱 라우터 프로덕션 점검은 App Router 프로덕션 체크리스트, 전환 큰 그림은 앱 라우터 마이그레이션 가이드를 보면 된다.


쿠키 인증 fetch는 서버와 클라이언트에서 credentials·헤더 처리가 다르다. 서버 전용 시크릿을 queryFn에 넣지 말고, 브라우저가 부를 BFF 라우트나 서버 액션 뒤로 숨긴다. 시크릿 운영 체크는 시크릿·환경변수 프로덕션 체크리스트와 맞춰 두면 실수가 줄어든다.


Next.js 앱 라우터와 클라이언트 데이터 페칭 경계를 나누는 개념 이미지
서버 컴포넌트 초기 데이터 + 클라이언트 탠스택 쿼리 재조회 역할 분리

실무에서 자주 막히는 실수 다섯 가지

  1. queryKey에 매번 새 객체 리터럴 | queryKey: ['x', { ...filters }]에서 filters가 렌더마다 새 참조여도 직렬화 동등이면 보통 괜찮지만, 키 생성 함수를 한곳에 모아 문자열·원시값 위주로 통일하는 편이 디버깅이 쉽다.
  2. queryFn 안에서 컴포넌트 클로저 남용 | 최신 props가 안 먹는 버그가 난다. 필요한 입력은 전부 queryKey와 queryFn 인자로 명시한다.
  3. 무효화 키를 너무 좁게 잡음 | 상세만 갱신하고 목록 프리픽스를 안 지우면 목록이 옛 제목을 보여 준다. 쓰기 성공 시 영향 범위를 표로 적어 두고 리뷰한다.
  4. 전역 staleTime 0 + refetchOnWindowFocus true | 개발 중 탭 이동만 해도 API 비용이 뛴다. 기본값을 팀 합의로 올리고, 실시간성이 필요한 쿼리만 예외로 짧게 잡는다.
  5. 에러를 삼키고 빈 배열로 성공 처리 | UI는 조용히 비고, 재시도도 안 돈다. 실패는 throw로 올려 상태 머신이 에러를 알게 한다.

팀 규칙으로 남기기 좋은 한 줄은 이렇다. “서버에서 온 데이터는 쿼리 키로만 읽고, 쓰기가 끝나면 관련 키를 무효화한다.” 예외(낙관적 업데이트)는 주석과 테스트가 있을 때만 허용한다.


쿼리 팩토리 추천: postKeys.all, postKeys.detail(id), postKeys.list(filters)처럼 키 생성 함수를 모듈로 모으면 무효화 누락이 줄어든다. 문자열 하드코딩 분산이 장애의 흔한 원인이다.

탠스택 쿼리를 쓰지 않는 편이 나은 경우

모든 fetch를 쿼리로 감쌀 필요는 없다. 아래면 훅 없이 가는 편이 단순한 경우가 많다.


  • 빌드 타임·서버 컴포넌트에서 한 번만 읽어 HTML에 박는 정적 마케팅 문구
  • 웹훅 수신, 파일 다운로드처럼 화면 캐시와 무관한 일회성 요청
  • 완전 로컬만의 UI 상태(이미 위에서 구분)
  • WebSocket으로 푸시가 주가 되고, HTTP 스냅샷이 보조인 실시간 보드 | 별도 연결 계층 + 필요 시 쿼리 보조

반대로 “같은 데이터를 여러 컴포넌트가 구독하고, 수정 후 일관된 재조회가 필요”하면 도입 이득이 바로 나온다. 중소 규모 앱에서도 목록·상세·마이페이지 세 화면만 공유해도 중복 fetch와 레이스 조건이 눈에 띄게 줄어든다.


참고 자료


옵션 이름·기본값은 메이저 버전에 따라 달라질 수 있다. 도입 전에 프로젝트에 설치된 @tanstack/react-query 메이저 버전과 위 문서의 v5 기준으로 한 번 더 대조한다.


자주 묻는 질문

탠스택 쿼리 v5에서 cacheTime이 안 보여요. 뭐로 바뀌었나요?

v5에서 cacheTime 이름이 gcTime으로 바뀌었다. “구독자가 없을 때 가비지 컬렉션까지 남는 시간”이라는 의미가 이름에 더 잘 맞는다. 동작 감각은 v4 cacheTime과 같고, 설정만 새 키로 옮기면 된다.


주스탄트(Zustand)와 같이 쓰나요, 하나만 고르나요?

같이 쓰는 경우가 많다. 서버 데이터는 탠스택 쿼리, 모달·위자드 단계·UI 플래그는 주스탄트(또는 useState)로 둔다. 서버 목록을 주스탄트에 복사해 두면 동기화 책임이 두 곳으로 갈라져 버그가 는다. 역할을 나누는 쪽이 유지보수에 유리하다.


staleTime을 팀 기본으로 얼마로 둘까요?

만능 숫자는 없다. 팀 기본으로 30초~60초를 두고, 실시간성이 필요한 쿼리만 0~10초, 설정·코드성 데이터는 5분 이상으로 올리는 식이 흔하다. 포커스 재조회로 서버가 아프면 기본을 먼저 올리고 예외만 짧게 가져가라.


Next.js 서버 컴포넌트만으로 충분하지 않나요?

첫 렌더와 SEO에는 서버 fetch가 충분할 수 있다. 다만 클라이언트에서 필터를 바꾸고, 뮤테이션 후 여러 위젯을 동시에 갱신하고, 탭 복귀 시 최신을 맞추는 요구가 생기면 서버 컴포넌트만으로는 상태가 분산된다. 초기 데이터는 서버, 이후 서버 상태 동기화는 쿼리로 이어 붙이는 하이브리드가 실무에서 자주 쓰인다.


invalidateQueries와 refetchQueries 차이는 뭔가요?

invalidateQueries는 캐시를 stale로 표시하고, 활성 구독이 있으면 재조회를 예약한다. refetchQueries는 조건에 맞는 쿼리를 지금 다시 가져오도록 더 직접적으로 친다. 대부분의 뮤테이션 성공 핸들러는 invalidate로 충분하다. “지금 당장 이 화면 숫자만 강제 갱신”이 필요할 때 refetch를 검토한다.


queryKey에 사용자 토큰을 넣어도 되나요?

토큰 문자열 전체를 키에 넣는 것은 피한다. 로그·devtools에 남을 수 있고, 갱신마다 키가 바뀌어 캐시가 무의미해진다. 사용자 아이디·테넌트 아이디처럼 안정적인 식별자만 넣고, 인증 헤더는 fetch 래퍼에서 붙인다. 로그아웃 시에는 queryClient.clear()로 캐시를 비우는 절차를 둔다.


TanStack QueryReact Query서버 상태캐시staleTime프론트엔드ReactTypeScriptNext.js개발자

관련 도구

함께 보면 좋은 문제 해결

EXPLORE / Frontend

이어서 읽어보기

전체 토픽 둘러보기