
이중 캐시의 협업 - 서버 캐시와 TanStack Query 캐시의 역할 분담
Next.js의 App Router에서는 'use cache' 지시어를 통해 서버 측에서 데이터를 강력하게 캐싱하는 기능을 제공하나
이것만으로는 사용자가 버튼을 눌러 새로고침하거나, 다른 탭으로 이동했다가 돌아왔을 때
즉각적으로 최신 캐시를 보여주는 등의 섬세하고 동적인 브라우저 경험을 완벽하게 제어하기에 한계가 존재합니다.
우선 서버의 영역인 Next.js의 역할을 다음 예시에서 살펴보겠습니다.
import PostListInitial from '@/components/PostListInitial'
async function getPosts() {
'use cache'
const res = await fetch('https://api.example.com/posts')
return res.json()
}
export default async function PostsPage() {
const posts = await getPosts()
const serverTime = Date.now()
return (
<main>
<h1>서버에서 미리 준비한 게시글 목록</h1>
<PostListInitial initialData={posts} updatedAt={serverTime} />
</main>
)
}먼저 서버 컴포넌트 함수 자체에 async를 붙여 비동기 처리를 직관적으로 할 수 있습니다.
이것은 기존 리액트에서 useEffect 안에서 복잡하게 처리해야 했던 비동기 데이터 호출을
마치 동기적인 코드처럼 직관적으로 작성할 수 있음을 의미합니다.
특히 주목할 점은 Next.js 16의 최신 캐시 정책입니다.
과거 버전과 달리 fetch()의 기본값이 캐싱을 하지 않는 no-store로 변경되었기 때문에
성능 최적화를 위해 직접 'use cache' 지시어를 선언하거나 revalidate 옵션을 명시해주는 것이 첫걸음입니다.
이렇게 설정하면 사용자가 처음 사이트에 접속했을 때 텅빈 화면과 로딩 스피너를 보는 것이 아닌
서버에서 이미 조리가 완료된 풍성한 데이터를 즉시 마주하게 됩니다.
이는 검색 로봇이 사이트를 방문했을 때 텍스트를 완벽하게 긁어갈 수 있도록 돕는 검색엔진 최적화를 극대화하고
초기 로딩 속도인 LCP를 비약적으로 단축시킵니다.
하지만 서버는 브라우저의 내부 상태를 실시간으로 알지 못합니다.
사용자가 실시간으로 서버에 매번 페이지 전체를 다시 그려달라고 요청하는 건 너무나 무겁고 비효율적입니다.
바로 이 지점부터 클라이언트 매니저인 TanStack Query가 바통을 이어받아 화면의 생동감을 책임집니다.
다음 코드를 살펴봐주세요.
'use client'
import { useQuery } from '@tanstack/react-query'
interface Post {
id: number
title: string
}
export default function PostListInitial(
{ initialData, updatedAt } :
{ initialData: Post[], updatedAt: number }
) {
const { data: posts } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(res => res.json()),
initialData: initialData,
initialDataUpdatedAt: updatedAt,
staleTime: 1000 * 60 * 5
})
return (
<ul className="space-y-2">
{posts?.map((post) => (
<li key={post.id} className="p-4 bg-white shadow rounded-lg">
{post.title}
</li>
))}
</ul>
)
}가장 먼저 컴포넌트 상단에 적히는 'use client' 지시어는 이 컴포넌트가 서버에서 랜더링 되지 않는다는 것이 아닙니다.
이 컴포넌트 역시 서버에서 초기 HTML을 만드는데 참여하지만 이 컴포넌트는 상태를 가지거나 Event Listener가 필요하니
브라우저로 자바스크립트 번들을 함께 내려보내서 Hydration을 수행하라는 명확한 경계선을 그어줍니다.
그 다음으로, 서버에서 넘어온 데이터를 TanStack Query의 클라이언트 캐시 시스템에 등록하는 효율적인 방법은
바로 initialData 주입 패턴입니다.
서버에서 완성된 데이터를 props로 넘겨받아 클라이언트 캐시에 초기값으로 심어주는 것만으로도
화면 깜빡임 없는 완벽한 사용자 경험을 만들어낼 수 있습니다.
특히, TanStack Query v5 문법에서는 인자를 여러 개 넘기는 방식이 폐지되고, 반드시 단일 객체로 전달하도록 표준화되었습니다.
여기서 데이터가 언제 서버에서 캐싱되었는지 정확한 타임스탬프를 함께 넘겨서 initialDataUpdatedAt 옵션에 넣습니다.
이를 통해 클라이언트는 데이터를 단순히 받은 순간이 아니라 서버가 요리를 끊는 순간부터 유통기한을 계산하게 되어
데이터의 무결성을 칼 같이 관리할 수 있게 됩니다.
여기서 staleTime 옵션을 설정하여 브라우저에서 클릭할 때마다 백그라운드에서 불필요한 요청 낭비를 막습니다.
이 시간 동안은 서버와 철저히 독립되어 오직 브라우저 내부 메모리만을 사용하여 즉각적으로 반응하게 됩니다.
이제 위에서 배운 철학을 실제 코드로 살펴보겠습니다.
App Router 환경에 완벽하게 맞춘 v5 전용 Provider 전용 셋업과 캐시의 흐름을 보는 DevTools 연결 실습입니다.
Provider라는 용어의 역할은 예를 들어 식당 전체의 지휘 체계를 하나로 묶어주는 튼튼한 본부 역할입니다.
이 본부가 없다면 A 컴포넌트와 B 컴포넌트가 서로 다른 매니저를 고용하게 되어 데이터가 전혀 공유되지 않습니다.
하지만 Next.js의 최신 App Router 환경에서 이 본부를 세우는 일은 과거보다 조금 더 까다로워졌습니다.
왜냐하면, App Router의 최상단 Root 파일들은 기본적으로 서버 환경에서 실행되는 서버 컴포넌트인 반면
TanStack Query의 Provider는 내부적으로 React의 ContextAPI라는 브라우저 전용 기술을 사용하므로
반드시 클라이언트 환경에서 실행되어야 하기 때문입니다.
따라서 우리는 이 두 세계의 충돌을 막기 위해 서버 컴포넌트에 쏙 들어갈 수 있는 클라이언트 전용 껍데기를 만들어야 합니다.
먼저 총괄 매니저인 queryClient를 생성하는 기본 설정입니다.
// src/app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider, isServer } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
gcTime: 1000 * 60 * 60 * 24
}
}
})
}파일에 제일 상단에 적힌 'use client' 지시어 덕분에 서버 컴포넌트 내부에서도 안전하게 클라이언트 경계를 형성합니다.
여기서 정의하는 makeQueryClient() 함수는 전역 옵션을 담은 매니저를 찍어내는 공장 역할을 합니다.
특히 Hydration 시 즉각적인 Refetching을 방지하기 위해 staleTime을 1분으로 설정하여 불필요한 네트워크 요청을 차단합니다.
과거 cacheTime이라 불리던 옵션을 가비지 컬렉터가 작동하는 시간을 뜻하는 gcTime으로 변경했습니다.
여기서 주의할 점은 isServer 옵션을 반드시 TanStack Query 패키지에서 가져와야 한다는 것입니다.
다음 작업은 환경 분리 작업입니다.
let browserQueryClient: QueryClient | undefined = undefined
function getQueryClient() {
if (isServer) { return makeQueryClient() }
else {
if (!browserQueryClient) browserQueryClient = makeQueryClient()
return browserQueryClient
}
}과거에는 단순히 상태 관리 도구인 useState 훅을 사용해 매니저를 담아두기도 했지만
최신 Next.js의 Suspense 환경에서는 랜더링 도중 상태가 초기화되어 캐시가 날아가는 치명적 버그가 발생할 수 있습니다.
따라서, getQueryClient() 함수를 만들어
서버 환경일 때는 유저간 데이터 충돌을 막기 위해 매번 새로운 매니저를 고용하고
브라우저 환경일 때는 전역 변수를 활용해 단 하나의 매니저만 튼튼하게 유지하도록 분기 처리하는 것이
v5의 공식적인 안전망입니다.
안전망이 구축되었으니, 준비된 매니저를 실제 리액트 컴포넌트에 주입하고, DevTools를 달아줄 차례입니다.
export default function Providers({ children } : { children: ReactNode }) {
const queryClient = getQueryClient()
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevTools initialIsOpen={false} />
</QueryClientProvider>
)
}여기서 추가된 DevTools는 우리 눈에 보이지 않는 메모리 속의 캐시 데이터 상태를
브라우저 화면에서 실시간으로 보여주는 강력한 모니터링 도구입니다.
initialIsOpen 속성을 false로 설정하면 평소에는 우측하단에 작은 아이콘으로 숨어있다가
필요할 때만 패널을 열어 디버깅 효율을 비약적으로 상승시킬 수 있습니다.
그리고, 이 DevTools는 개발 환경에서만 작동하고 실제 프로덕션 빌드 시에는 자동으로 제거됩니다.
마지막으로 이렇게 만든 클라이언트 껍데기를 RootLayout에 씌어주어 모든 페이지가 혜택을 누릴 수 있도록 합니다.
// src/app/layout.tsx
import './globals.css'
import Providers from './providers'
export const metadata = {
title: 'Next.js 마스터 클래스 2026',
description: 'TanStack Query v5 완벽 연동 가이드'
}
export default function RootLayout({ children } : { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<Providers>
{children}
</Providers>
</body>
</html>
)
}RootLayout 파일은 우리 애플리케이션의 뼈대 역할을 하는 거대한 서버 컴포넌트입니다.
우리는 이 뼈대 안에 body 태그 내부에 만들어둔 Providers 컴포넌트를 배치하고
그 안에 실제 페이지들을 의미하는 자식 요소들을 집어넣었습니다.
이렇게 함으로써 서버 컴포넌트 환경에서도 클라이언트 영역이 필요한 TanStack Query Context를
아주 안전하고 우아하게 주입하는 패턴이 완성된 것입니다.
이제 애플리케이션 안의 어떤 깊은 곳에 있는 컴포넌트라도 자유롭게 queryClient를 호출하여
서버의 데이터를 캐싱하고 관리할 수 있는 완벽한 인프라가 구축된 것입니다.
prefetchQuery와 데이터 탈수(Data Dehydration)
Next.js의 서버 컴포넌트 영역에서 데이터를 미리 선점하여 가져오는 PrefetchQuery의 핵심 원리를 알아봅니다.
우리가 일반적으로 가져오는 fetch가 사용자가 페이지에 들어온 뒤에야 깨닫고 주방에 주문을 넣는 방식이라면
prefetch는 사용자가 문을 열고 들어오기도 전에 매니저가 미리 메뉴를 예측하여 요리를 시작하라고 지시하는
아주 공격적인 최적화 기법입니다.
특히, Next.js는 15버젼을 거쳐 16버젼에 이르기까지 기본 fetch 함수의 동작 방식이
데이터를 캐싱하지 않는 형태인 no-store 방식으로 완전히 변경되었습니다.
따라서 단순히 서버에서 데이터를 부르는 것을 넘어 TanStack Query의 prefetch 기능을 통해
서버와 클라이언트 간의 데이터 캐싱 상태를 직접적 그리고 안전하게 통제하는 것이 핵심 표준이 되었습니다.
이러한 미리 가져오기 기법을 활용하면 브라우저가 자바스크립트 파일을 다 다운로드 하고
리액트를 실행한 뒤에야 서버의 요청을 보내는 기나긴 대기 시간을 획기적으로 줄일 수 있습니다.
사용자가 화면을 마주하는 순간 이 서버에서 조리된 데이터가 캐시 저장소에 꽉 차있게 되어
완성된 화면을 즉시 보여줄 수 있게 되는 것입니다.
이 마법같은 과정을 구현하기 위해서는 먼저 서버 컴포넌트 안에서 임시 주방 매니저를 고용해야 합니다.
서버 컴포넌트 안에서는 서버 전용 queryClient를 새로 생성하여 데이터를 담을 빈 바구니를 준비해야 합니다.
코드를 통해 알아보겠습니다.
// src/app/posts/page.tsx
import { QueryClient } from '@tanstack/react-query'
import { cache } from 'react'
const getQueryClient = cache(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000
}
}
}))
export default async function PostsPage() {
const queryClient = getQueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: async () => {
const res = awiat fetch('https://api.example.com/posts')
return res.json()
}
})
}먼저 상단에 react의 cache() 함수를 불러와 getQueryClient를 만든 것을 볼 수 있습니다.
Next.js의 App Router에서는 여러 서버 컴포넌트가 동시에 랜더링 될 수 있는데
new QueryClient()를 무작정 호출하면 바구니가 여러 개 생겨 데이터가 흩어질 수 있습니다.
cache() 함수로 감싸주면, 한번의 서버 요청 사이클 동안에는 정확히 동일한 매니저 하나만 재사용하도록 보장해주는
Next.js 환경의 필수 안전장치입니다.
실무에서는 getQueryClient() 유틸리티 함수를 별도의 파일로 분리해서 여러 서버 컴포넌트에서 재사용합니다.
그리고, staleTime의 기본값은 0이므로 반드시 0보다 큰 값을 설정해주어야 prefetch 효과를 살릴 수 있습니다.
그 다음 이 코드에서 가장 중요한 구문은 바로 prefetchQuery() 입니다.
이 구문은 useQuery()와 구성요소가 거의 동일하지만, 화면에 데이터를 직접 그려주는 것이 아닌
오직 매니저가 들고 있는 바구니 즉 캐시 저장소에 데이터를 채워넣는 역할만 수행합니다.
그리고, prefetchQuery()는 서버에서 데이터를 가져오다 네트워크 오류 등으로 실패하더라도
에러를 던져 페이지를 고장내지 않습니다.
대신 그 에러 상태를 캐시에 담아 클라이언트로 조용히 넘겨줍니다.
덕분에 클라이언트단에서 부드럽게 에러 화면이나 재시도 버튼을 보여줄 수 있는 유연함을 제공합니다.
하지만 이 매니저는 서버 안에만 살고 있기 때문에 브라우저에 있는 진짜 매니저에게
이 따끈따끈한 데이터를 배달해줘야 하는 과정이 추가로 필요합니다.
배달을 위해서는 서버에 담긴 복잡한 데이터 객체를 아주 가볍고 전송한 형태로 압축해야 하는데
이 과정을 우리는 데이터 탈수(deHydration)이라고 부릅니다.
이렇게 바짝 말린 데이터를 브라우저로 보내면 브라우저에 있는 매니저가 다시 물을 부어
원래의 신선한 요리로 복구하는 수화(Hydration) 과정을 거치게 됩니다.
이 배달의 핵심인 Hydration Boundary와 deHydration() 함수 구문을 살펴보겠습니다.
import { deHydrate, HydrationBoundary } from '@tanstack/react-query'
import PostList from './PostList'
export default async function PostsPage() {
const queryClient = getQueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: async () => {
const res = awiat fetch('https://api.example.com/posts')
return res.json()
}
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostList />
</HydrationBoundary>
)
}이 코드의 return 문을 살펴보면
가장 바깥을 감싸고 있는 HydrationBoundary는 서버와 클라이언트를 이어주는 특별한 배달 창구 역할입니다.
이 컴포넌트의 state 속성을 보시면 dehydrate(queryClient)가 들어가 있는 것을 확인할 수 있습니다.
우리가 방금 prefetch된 서버 매니저가 들고 있는 데이터를 dehydrate() 함수로 탈수하여
state라는 상자에 담아 창고에 넘겨준 것입니다.
그러면 Next.js는 이를 브라우저에 안전하게 운반합니다.
브라우저에 도착한 데이터는 QueryClientProvider에 등록된 진짜 브라우저 매니저에게 전달됩니다.
매니저는 도착한 탈수된 데이터에 다시 물을 부어 신선한 캐시로 보관하고 자신의 캐시 공간에 올려둡니다.
그래서 HydrationBoundary 내부에 있는 PostList 컴포넌트가 브라우저에서 실행될 때
서버에 다시 요청할 필요 없이 캐시 공간에 놓인 신선한 데이터를 즉시 꺼내어 화면에 그려줍니다.
하이드레이션 바운더리의 내부 원리
하이드레이션 바운더리에서 어느 원리로 탈수된 데이터가 다시 촉촉하고 신선한 데이터로 살아나는지 과정을 파헤칩니다.
이 원리가 동작하려면 우리 앱의 최상단 RootLayout에 QueryClientProvider가 전체 앱을 감싸고 있어야 합니다.
먼저 서버와 클라이언트가 공통으로 사용할 옵션 객체를 먼저 정의해보겠습니다.
// src/app/posts/queries.ts
import { queryOptions } from '@tanstack/react-query
export const fetchPosts = async () => {
const res = await fetch('https://api.example.com/posts')
if (!res.ok) throw new Error("데이터 가져오는데에 실패했습니다.")
return res.json()
}
export const postQueries = {
all: () => queryOptions({
queryKey: ['posts'].
queryFn: fetchPosts,
staleTime: 60 * 1000
})
}이렇게 공통 파일에 queryOptions()를 만들어두면 클라이언트 컴포넌트에서는 아주 깔끔하게 사용할 수 있습니다.
다음 코드를 주목해주세요.
'use client'
import { useQuery } from '@tanstack/react-query'
import { postQueries } from './queries'
interface Post {
id: number
title: string
}
export default function PostList() {
const { data, isLoading, isError } = useQuery<Post[]>(postQueries.all())
if (isLoading) return <div>로딩 중...</div>
if (isError) return <div>Error....</div>
return (
<ul>
{data?.map((post: Post) => (
<li key={post.id}>{post.title}</li>
)}
</ul>
)
}하이드레이션이 성공적으로 이루어지기 위한 가장 핵심적인 조건은
서버에서 작성했던 prefetchQueries()와 queryKey가 동일해야 하는데요.
실무에서는 queryOptions()라는 기능을 사용해 이 설정을 한 곳에서 관리합니다.
그렇다면, 서버의 데이터 패칭 이후에 왜 추가적으로 클라이언트 패칭 로직이 필요한 걸까요?
결론부터 말하자면 서버에서 받은 데이터가 영원히 신선한 상태를 유지할 수는 없기 때문입니다.
만약 사용자가 웹 서핑을 하다가 잠시 다른 브라우저 탭으로 이동하거나
다른 행동을 한뒤 다시 우리 웹 사이트 창으로 돌아오게 되면
브라우저 매니저는 화면이 다시 포커스 된 것을 감지하고, 현재 있는 데이터가 상했다고 판단합니다.
이 때 매니저는 사용자 몰래 백그라운드에서 신선한 데이터를 다시 가져오려는 시도를 하게 되는데
브라우저가 스스로 서버에서 API를 요청해 데이터를 다시 가져오기 위해 반드시 참고해야 하는 레시피가 필요해집니다.
그 레시피가 우리가 queryOptions() 안에 미리 작성해둔 queryFn인 것입니다.
추가로, useSuspenseQuery() 훅을 활용하여 상위 컴포넌트와 하위 컴포넌트 간 어떻게 구성되는지 코드를 통해 살펴봅니다.
먼저 데이터를 직접 호출하고 화면에 그리는 하위 컴포넌트입니다.
// src/app/posts/PostList.tsx
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { postQueries } from './queries'
interface Post {
id: number
title: string
}
export default function PostList() {
const { data } = useSuspenseQuery<Post[]>(postQueries.all())
return (
<ul>
{data.map((post: Post) => <li key={post.id}>{post.title}</li>)}
</ul>
)
}하위 컴포넌트에서 useSuspenseQuery 훅을 도입하면 더 이상 로딩 상태나 에러 상태를 수동으로 체크하는
조건문을 작성할 필요가 없어집니다. 오직 데이터를 화면에 어떻게 보여줄지에만 집중합니다.
이제 여기서 생략된 로딩과 에러 처리는 상위 컴포넌트에서 담당하게 됩니다.
상위 컴포넌트의 코드를 보겠습니다.
// src/app/posts/PostContainer.tsx
'use client'
import { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
export default function PostContainer() {
return (
<ErrorBoundary fallback={<div>데이터 불러오는 중 문제 발생</div>}>
<Suspense fallback={<div>포스트 목록을 화려하게 스켈레톤 UI로 그리는 중</div>}>
<PostList />
</Suspense>
</ErrorBoundary>
)
}이 코드를 보시면 ErrorBoundary와 Suspense 컴포넌트가 하위 컴포넌트를 감싸고 있음이 확인됩니다.
데이터를 백그라운드에서 다시 불러오느라 로딩이 진행중일때는
React가 그 상태를 감지하여 상위 컴포넌트로 끌어올리고
가장 가까운 Suspense 컴포넌트의 fallback UI를 화면에 대신 그려줍니다.
마찬가지로, 네트워크 오류 등 에러가 발생하면 ErrorBoundary가 이를 낚아채어
화면이 완전히 망가지는 것을 방지하고 안전하게 Error UI를 처리하게 됩니다.
우리는 이미 서버에서 데이터를 prefetch 해왔기 때문에 첫번째 요청에는 로딩이 멈추는 현상이 발생하지 않습니다.
최근에 모던 프론트엔드 트렌드는 useQuery 대신 Suspense를 압도적으로 많이 채택합니다.