TanStack Query와 Next.js 캐시 — 같이 쓰지 말라가 아니라, 주인을 정하라
"TanStack Query로 서버 API를 prefetch하고 있는데, Next.js의 캐시 기능과는 같이 안 쓰는 게 낫겠지?" — 결론부터 말하면 "같이 쓰지 마라"가 아니라 "같은 데이터를 두 캐시에 동시에 태우지 마라" 가 정답입니다.
둘은 "경쟁"이 아니라 "다른 층"이다
두 캐시는 사는 곳도, 답하는 질문도 다릅니다.
┌─────────────────────────────────────────────┐
│ 브라우저 (사용자 개인) │
│ ┌───────────────────────────────────────┐ │
│ │ TanStack Query 캐시 │ │
│ │ · 사용자별 / 브라우저 메모리 │ │
│ │ · refetch, stale 관리, mutation, │ │
│ │ 낙관적 업데이트 │ │
│ │ · "이 사용자의 화면을 어떻게 최신으로 유지?" │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
▲ (dehydrate → hydrate로 넘어옴)
┌─────────────────────────────────────────────┐
│ 서버 (모든 요청/사용자가 공유) │
│ ┌───────────────────────────────────────┐ │
│ │ Next.js Data Cache / use cache / PPR │ │
│ │ · 요청 간 / 사용자 간 공유 │ │
│ │ · "이 무거운 계산을 한 번만 하고 재사용?" │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
TanStack Query는 "클라이언트에서 서버 상태를 관리" 하는 도구고, Next 캐시는 "서버에서 작업을 아껴 재사용" 하는 도구입니다. 층이 다르니 원래는 공존이 가능합니다. TanStack Query 공식 문서도 서버에서 prefetch → dehydrate → 클라이언트에서 hydrate 하는 패턴을 정식으로 지원하고, hydrate 이후 stale하면 클라이언트에서 다시 refetch까지 해줍니다. prefetch + hydrate를 이미 쓰고 있다면 그게 바로 정석 패턴입니다.
문제는 언제 생기나 — "이중 캐싱"
같은 데이터가 두 캐시를 동시에 통과할 때가 지옥입니다. 예를 들어 prefetchQuery의 queryFn 안에서 부르는 fetch를 Next의 Data Cache가 또 캐싱해 버리면:
- Next 캐시엔 A라는 낡은 값이, TanStack Query엔 B라는 값이 각각 다른 수명(TTL) 으로 남습니다.
- 무효화(invalidate)를 어느 쪽에 걸어야 할지 꼬입니다.
상상이 아니라 실제로 자주 겪는 버그입니다. Next 15 사용자가 "prefetchQuery가 첫 로드에서조차 stale 데이터를 반환한다"고 보고했는데, 알고 보니 Next의 fetch 캐싱 때문이었고 invalidateQueries를 강제로 넣어야 고쳐졌다는 사례가 대표적입니다.
Next 16에서는 오히려 안전해졌다
Next.js 16의 "동적이 기본" 변화가 이 문제를 상당 부분 해소해 줍니다.
- Next 13~15:
fetch가 기본 캐싱 → 나도 모르게 Next가 데이터를 붙잡고 있어서 위 버그가 잘 터짐 - Next 16: 캐싱이 완전히 opt-in →
queryFn의fetch는 기본적으로 Next가 안 건드림. TanStack Query가 온전히 주인이 됩니다
즉 Next 16에서는 "가만히 두면" 이중 캐싱이 안 생깁니다. 실수로 cache: 'force-cache'나 use cache를 그 데이터 경로에 붙이지만 않으면 됩니다.
실전 권장 3가지
TanStack Query가 주인인 데이터에는 Next 캐시를 끼우지 않는다는 원칙 아래:
prefetchQuery의queryFn에 있는fetch에cache/revalidate/use cache를 붙이지 않기 — 주인은 한 곳만.- QueryClient는 사용자/요청 간 데이터가 섞이지 않도록 서버에서는 요청마다 새 인스턴스로 생성하기 (브라우저는 싱글턴).
staleTime을 0보다 크게 설정하기 — 흔히 놓치는 부분인데, non-zero staleTime을 줘야 방금 서버에서 prefetch한 데이터를 클라이언트가 마운트하자마자 또 refetch하지 않습니다. 안 그러면 서버에서 애써 미리 받아온 게 무의미해집니다.
그럼 Next 캐시는 언제 쓰나
버리라는 게 아닙니다. 데이터마다 주인을 하나씩 정해주는 그림이 이상적입니다.
| 데이터 성격 | 주인 | 예시 |
|---|---|---|
| 사용자별, 상호작용·refetch·mutation 필요 | TanStack Query | 대시보드, 목록, 실시간 상태 |
| 사용자 무관하게 공유되고 잘 안 바뀜 | Next use cache / PPR |
설정값, 공용 참조 데이터, 정적 콘텐츠 |
딱 하나, 의도적 이중화가 이득인 예외가 있습니다. 비싼 외부 API를 여러 사용자가 동일하게 호출하는 비개인화 데이터라면, Next 서버 캐시로 외부 호출을 "전체에서 한 번만" 하도록 묶는 게 유리합니다. TanStack Query만으로는 각 브라우저가 따로 호출하니까요. 단, 이 경우엔 무효화를 태그 기반으로 서버 쪽에서 조율하겠다는 각오가 필요합니다.
더 깊이: staleTime은 데이터 성격으로 정한다
절대 정답은 없지만 출발점으로 쓸 만한 기준입니다.
| 데이터 | staleTime 출발점 | 이유 |
|---|---|---|
| 실시간 상태 (처리 진행률 등) | 0 ~ 수 초 | 항상 신선해야 함. 대신 polling/websocket과 조합 |
| 목록·대시보드 | 30초 ~ 5분 | 탭 전환마다 refetch하면 낭비 |
| 잘 안 바뀌는 참조 데이터 | 수십 분 이상 | 사실상 상수. 차라리 Next 캐시 후보 |
경험칙 하나 — "이 데이터가 N초 낡아도 사용자가 알아챌까?" 에서 N이 staleTime의 상한입니다.
더 깊이: 이중 캐싱이 걸려 있는지 확인하는 법
queryFn안의fetch호출부를 전부 검색해서cache,next: { revalidate }옵션이 붙어 있는지 확인- 데이터를 서버에서 바꾼 직후 새로고침 — 바뀐 값이 바로 안 보이고
invalidateQueries를 불러야만 보인다면 서버 쪽 캐시가 물고 있다는 신호 - dev에서 Server Function/fetch 로그로 실제 네트워크 호출이 나가는지 관찰 — 호출이 안 나가는데 데이터가 오면 어딘가의 캐시가 답한 것
한 줄 결론 — prefetch + hydrate는 정석이고, Next 16에선 그 데이터에 Next 캐시를 "일부러 안 붙이는 것"만 지키면 됩니다. 끄는 게 아니라, 데이터마다 주인을 하나씩 정해주는 감각으로 보면 됩니다.
스스로 점검할 질문
- staleTime을 실제로 얼마로 잡는 게 좋을까? 데이터 성격(실시간 상태 vs 잘 안 바뀌는 목록)에 따라 어떻게 다르게 줘야 할까?
- 지금 내 코드에서 실수로 이중 캐싱이 걸려 있는지 확인하려면 뭘 보면 되고, 어떻게 테스트하면 될까?
- 비개인화 + 비싼 외부 API 데이터를 Next 서버 캐시로 묶으면서 TanStack Query와 무효화를 충돌 없이 조율하려면 구체적으로 어떤 구조로 짜야 할까?
참고 링크