tanstack query의 PrefetchBoundary를 프로젝트에 적용하며 prefetch의 동작 원리가 궁금해져
소심하게나마 다이브 해봤습니다.
프로젝트 구성 환경
Next.js 14(app router), React 18, TypeScript, Tanstack-Query 5, mantine.css, module.css
클라이언트에서의 네트워크 요청은 TTFB 개선이 어려웠다.
클라이언트 컴포넌트에서만 query fetch해도 되잖아?라고 물을 수도 있겠지만
클라이언트에서의 네트워크 요청에 따른 페이지 로드 시간 감소를 이번 프로젝트의 팀 목표로 설정했거든요.
목표를 충족시키기 위해선 prefetch로 진행하는 방법이 필요했습니다.
기본 fetch 대신 TanStack Query를 활용한 prefetch를 사용한 이유
초기에는 next.js 공식 문서를 참고해 RSC에서 ky(=fetch와 비슷한 라이브러리)를 사용해
prefetch를 진행하려고 했습니다. 그렇지만 ky를 사용하는 것보다 더 세밀한 캐싱 조정이 가능하고
서버 데이터를 상태로 관리할 수 있도록 돕는 tanstack-query에서도 prefetch를 위한
HydrationBoundary를 가이드로 제공하고 있어 next.js의 fetch나 ky로 RSC에서 fetch 할 이유가 없었습니다.
HydrationBoundary는 어떻게 동작하는걸까?
HydrationBoundary 컴포넌트가 prefetch를 통한 성능 최적화를 어떻게 이뤄내는지 코드를 보던 중,
컴포넌트를 구성하는 코어 로직이 hydrate와 dehydrate 함수라는 것을 알았는데요.
두 로직의 동작 원리를 파악한다면 HydrationBoundary를 더 쉽게 이해할 수 있을 거라는 생각이 들어
hydrate 함수와 dehydrate 함수를 먼저 살펴봤습니다.
두 함수에서 mutation 관련 코드도 존재하지만 query와 동일한 원리로 동작하여
query 기반으로 코드를 이해해보려 했습니다.
1. hydrate(= 역직렬화)
클라이언트에서 직렬화된 데이터를 다시 QueryClient의 캐시로 바꿔주는 역할을 수행하는데요.
기존에 쿼리가 존재한다면 기존 쿼리와 새롭게 전달받은 쿼리의 dataUpdatedAt(= 타임스탬프)를 비교해
새로운 쿼리로 유지하고, 그렇지 않다면 새로운 쿼리를 생성합니다.
RSC와의 호환을 위한 부분도 존재했는데요.
RSC에서 변환된 Promise가 thenable 하지 않을 수 있어 Promise.resolve를 사용해 준 후
쿼리의 데이터를 가져오기 위해 fetch 하는 것을 확인할 수 있었습니다.
// query/packages/query-core/src/hydration.ts
export function hydrate(
client: QueryClient,
dehydratedState: unknown,
options?: HydrateOptions,
): void {
if (typeof dehydratedState !== 'object' || dehydratedState === null) {
return
}
const queryCache = client.getQueryCache()
const deserializeData =
options?.defaultOptions?.deserializeData ??
client.getDefaultOptions().hydrate?.deserializeData ??
defaultTransformerFn
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const queries = (dehydratedState as DehydratedState).queries || []
...
queries.forEach(({ queryKey, state, queryHash, meta, promise }) => {
// 쿼리 캐시의 존재 유무
let query = queryCache.get(queryHash)
const data =
state.data === undefined ? state.data : deserializeData(state.data)
// 기존 쿼리 존재
if (query) {
if (query.state.dataUpdatedAt < state.dataUpdatedAt) {
const { fetchStatus: _ignored, ...serializedState } = state
query.setState({
...serializedState,
data,
})
}
} else {
// 기존에 쿼리가 존재하지 않는다면 새로운 쿼리를 생성
query = queryCache.build(
client,
{
...client.getDefaultOptions().hydrate?.queries,
...options?.defaultOptions?.queries,
queryKey,
queryHash,
meta,
},
{
...state,
data,
fetchStatus: 'idle',
},
)
}
if (promise) {
// Note: `Promise.resolve` required cause
// RSC transformed promises are not thenable
const initialPromise = Promise.resolve(promise).then(deserializeData)
// this doesn't actually fetch - it just creates a retryer
// which will re-use the passed `initialPromise`
void query.fetch(undefined, { initialPromise })
}
}
})
}
2. dehydrate(= 직렬화)
서버 사이드에서 QueryClient에 저장된 쿼리/뮤테이션 상태를 JSON 형태로 변환하여
HTML의 일부로 클라이언트에 전달해 주면 클라이언트에서 데이터 로드 시,
빠르게 사용할 수 있도록 처리해 주는 역할을 담당합니다.
아래의 코드를 보면 필터를 통과한 뮤테이션이나 쿼리만 dehydrate를 통한 직렬화를 수행하는 것을 볼 수 있는데요.
shouldDehydrateMutation, shouldDehydrateQuery을 통해 성공 상태의 query만 직렬화 처리합니다.
클라이언트 사이드에서는 전달받은 JSON 데이터를 QueryClient 상태로 다시 복원(hydrate)하여
초기 렌더링 시점부터 데이터를 즉시 사용할 수 있게 됩니다
// query/packages/query-core/src/hydration.ts
export function dehydrate(
client: QueryClient,
options: DehydrateOptions = {},
): DehydratedState {
const filterQuery =
// 유저에게 전달받은 쿼리
options.shouldDehydrateQuery ??
// 기존 QueryClient에 저장되어있는 쿼리
client.getDefaultOptions().dehydrate?.shouldDehydrateQuery ??
// TanStack Query에서 제공하는 기본 쿼리
defaultShouldDehydrateQuery
const serializeData =
// 유저에게 전달받은 직렬화 데이터
options.serializeData ??
// 기존 QueryClient에 저장된 직렬화 데이터
client.getDefaultOptions().dehydrate?.serializeData ??
// TanStack Query에서 제공하는 기본 직렬화 데이터
defaultTransformerFn
const queries = client
// 캐시된 모든 쿼리를 가져온다.
.getQueryCache()
.getAll()
// 걸러진 쿼리를 대상으로 dehydrate를 진행할지를 결정
.flatMap((query) =>
filterQuery(query) ? [dehydrateQuery(query, serializeData)] : [],
)
//최신 상태의 쿼리를 제공
return { queries }
}
hydrationBoundary
next.js 등의 SSR 환경에서 prefetch 되어 dehydrate 된 쿼리 상태를 클라이언트에서 효율적으로 hydrate 할 수
있도록 해주는 컴포넌트입니다.
성능 최적화를 위해 query client의 존재 여부로 분기 처리를 진행하는 것을 확인할 수 있었는데요.
QueryClient가 없는 경우, 서버에서 prefetch 된 데이터가 클라이언트에서 사용 가능한 상태로 hydrate 되어
유저에게 빠르게 데이터를 제공 가능하게끔 처리했더라고요.
QueryClient가 이미 존재하는 경우, hydrationQueue를 통해 비동기적으로 처리되며 렌더링 블로킹을 방지
고, 기존 클라이언트의 상태를 우선 사용하는 것을 알 수 있었습니다.
export const HydrationBoundary = ({
children,
options = {},
state,
queryClient,
}: HydrationBoundaryProps) => {
const client = useQueryClient(queryClient)
const [hydrationQueue, setHydrationQueue] = React.useState<
DehydratedState['queries'] | undefined
>()
const optionsRef = React.useRef(options)
optionsRef.current = options
...
//
React.useMemo(() => {
// 1. 새로운 쿼리를 전달받아서 분류.
if (state) {
...
// 2. 즉시 hydrate가 필요한 새 쿼리는 바로 처리.
if (newQueries.length > 0) {
hydrate(client, { queries: newQueries }, optionsRef.current)
}
// 3. 기존 쿼리는 큐에 넣어서 나중에 처리.
if (existingQueries.length > 0) {
setHydrationQueue(...)
}
}
}, [client, hydrationQueue, state])
// 렌더링 완료 후 Queue에 담긴 기존 쿼리에 대한 처리 진행.
React.useEffect(() => {
if (hydrationQueue) {
hydrate(client, { queries: hydrationQueue }, optionsRef.current)
setHydrationQueue(undefined)
}
}, [client, hydrationQueue])
return children as React.ReactElement
}
Reference
query/packages/react-query/src/HydrationBoundary.tsx at main · TanStack/query
Advanced Server Rendering | TanStack Query React Docs
Next.js app router에서 React Query 사용하면서 고민했던 것들
'TIL' 카테고리의 다른 글
react-markdown에서 next-mdx-remote/rsc로의 마이그레이션 (0) | 2025.02.28 |
---|---|
사내 코드에 openapi generator를 적용해보자. (0) | 2025.02.03 |
yarn classic에서 pnpm으로의 시작 (1) | 2024.12.27 |
<img/> vs <Image />, 유연하게 대처하기 (0) | 2024.12.07 |
응집도와 결합도 (0) | 2024.11.18 |