notes
React Query Prefetch + HydrationBoundary
React Query Prefetch와 HydrationBoundary로 서버 데이터를 캐시하는 메모.
- React Query 캐시는 서버와 브라우저가 공유하지 않아서, 서버에서 받은 데이터를 직접 넘겨줘야 한다.
- 넘기는 방법이
dehydrate+HydrationBoundary고, queryKey 가 양쪽에서 완전히 같아야 캐시가 맞는다. - 서버의 QueryClient 는 요청마다 새로 만든다. 모듈 전역으로 두면 다른 사용자 데이터가 섞일 수 있다.
어디서 시작했나
상세 페이지에 메타태그(OG)로 썸네일 미리보기를 붙이려고 했다.
generateMetadata 에서 상세 정보를 API 로 가져와 title, description, image 를 넣었다. 그런데 같은 페이지의 클라이언트 컴포넌트가 상세 조회 쿼리로 똑같은 API 를 또 부르고 있었다.
서버에서 이미 가져온 데이터를 브라우저가 다시 부르는 건 낭비다. 그래서 찾은 게 Prefetch + HydrationBoundary 패턴이고, 적용해 보니 잘 동작해서 정리해 둔다.
문제 상황
서버에서 generateMetadata 가 한 번, 브라우저에서 useQuery 가 한 번. 같은 데이터를 두 번 받는다.
처음엔 서버에서 한 번 조회하면 클라이언트가 그 캐시를 쓰면 되지 않나 싶었다. 그런데 캐시는 공유되지 않는다.
서버에서 fetch 해도 클라이언트 캐시에는 자동으로 안 들어간다. 그래서 서버 데이터를 클라이언트로 넘겨주는 과정이 따로 필요하다.
해결: Prefetch + HydrationBoundary
핵심은 두 단계다.
- dehydrate(탈수): 서버 QueryClient 의 메모리 상태를 JSON 으로 직렬화한다.
{ queries: [{ queryKey: ['detail', 123], state: { data } }] }같은 모양이 HTML 과 함께 내려간다. - HydrationBoundary(수화): 받은 JSON 을 클라이언트 캐시에 복원한다. 이후
useQuery(['detail', 123])가 실행되면 캐시에 데이터가 있으니 queryFn 을 안 부른다.
기본 구조
서버 컴포넌트에서 prefetch 하고, 클라이언트 컴포넌트를 HydrationBoundary 로 감싼다. Next.js 15 부터 params 는 Promise 라 await 해서 꺼낸다.
// page.tsx (서버 컴포넌트)
import { HydrationBoundary, QueryClient, dehydrate } from '@tanstack/react-query'
import { getDetail } from '@/lib/server-api'
export default async function DetailPage({ params }: { params: Promise<{ id: string }> }) {
const id = Number((await params).id)
const queryClient = new QueryClient()
// 1. 서버에서 prefetch
await queryClient.prefetchQuery({
queryKey: ['detail', id],
queryFn: async () => (await getDetail(id)).data,
})
// 2. dehydrate 해서 HydrationBoundary 로 전달
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<DetailScreen id={id} />
</HydrationBoundary>
)
}// DetailScreen.tsx (클라이언트 컴포넌트)
'use client'
import { useDetailQuery } from '@/lib/client-api'
export default function DetailScreen({ id }: { id: number }) {
// 이미 캐시에 있어서 API 호출 없음
const { data } = useDetailQuery(id)
return <div>{data?.name}</div>
}cache() 로 서버 안 중복까지 줄이기
generateMetadata 와 Page 가 각자 같은 API 를 부르는 것도 아깝다. React.cache() 로 감싸면 한 요청 안에서는 한 번만 호출된다. 그 결과를 setQueryData 로 캐시에 넣으면 끝이다.
import { cache } from 'react'
const getDetailCached = cache(getDetail)
export async function generateMetadata({ params }: Props) {
const res = await getDetailCached(Number((await params).id))
return { title: res.data?.name }
}
export default async function Page({ params }: Props) {
const id = Number((await params).id)
const queryClient = new QueryClient()
// generateMetadata 에서 이미 불렀으면 여기선 cache hit
const res = await getDetailCached(id)
if (res.data) queryClient.setQueryData(['detail', id], res.data)
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<DetailScreen id={id} />
</HydrationBoundary>
)
}여러 쿼리를 미리 받아야 하면 Promise.all 로 병렬 prefetch 한다.
await Promise.all([
queryClient.prefetchQuery({ queryKey: ['detail', id], queryFn: () => getDetail(id).then((r) => r.data) }),
queryClient.prefetchQuery({ queryKey: ['detail/picture', id], queryFn: () => getPicture(id).then((r) => r.data) }),
])
놓치기 쉬운 것
첫째는 queryKey 다. 서버 prefetch 와 클라이언트 useQuery 의 queryKey 가 정확히 같아야 hydrate 된 데이터를 쓴다.
둘째는 QueryClient 를 만드는 시점이다. 서버에서는 요청마다 새 QueryClient 를 만든다. 모듈 레벨에 하나 두고 공유하면 다른 사용자 데이터가 섞일 수 있다.
// 요청마다 새 QueryClient
export default async function Page() {
const queryClient = new QueryClient()
}
// 모듈 레벨 공유는 금지 (다른 사용자 데이터 유출 위험)
const queryClient = new QueryClient()
셋째는 API 함수다. prefetch 의 queryFn 은 서버에서 돈다. 그래서 NEXT_PUBLIC_API_URL 을 쓰는 클라이언트용 함수 대신, 서버 환경변수와 cookies 를 쓰는 서버용 API 함수를 쓴다.
결과
적용 전에는 서버 1회, 클라이언트 1회로 총 2회였다. 적용 후에는 서버에서 cache() 로 1회만 부르고, 클라이언트는 hydrate 된 캐시만 쓴다.
Network 탭에서 호출이 하나 줄어든 걸 확인하면 뿌듯하다.