← 글 목록

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 와 클라이언트 컴포넌트
// 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 로 캐시에 넣으면 끝이다.

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 탭에서 호출이 하나 줄어든 걸 확인하면 뿌듯하다.