개요
사내 프로젝트를 진행하는 과정에서 Nextjs 14의 Page Router와 Tanstack Query v4를 함께 사용하고 있었다. 그러던 와중에 특정 쿼리를 추가했고 쿼리를 Server Side에서도 활용하기 위해 queryClient의 fetchQuery 메서드를 활용했고, 개발 과정에서 코드를 추가한 뒤 해당 지면에서 refresh를 수행하며 디버깅을 했을때에는 문제없이 Hydration이 잘 수행되었다. 하지만 추가적인 테스트를 진행하면서 다른 지면에서 해당 지면으로 Navigate할때의 Hydration이 원활하게 동작하지 않아 이에 대해 알아보게 되어 그 과정에 대해 정리하고자 한다.
본론으로 들어가기 전 먼저 이러한 상황이 발생한 시점 queryClient의 defaultOptions를 확인해보았다.
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: 0,
staleTime: 0,
cacheTime: 0,
networkMode: 'always',
refetchOnWindowFocus: false,
},
mutations: {
networkMode: 'always',
},
},
}),
);
현재 프로젝트는 staleTime와 cacheTime이 전부 0으로 되어있었는데, 이는 기존에 타 레포에서 진행하던 CSR기반의 Vite 프로젝트를 주로 다루는 과정에서 기본적으로는 데이터의 최신성을 보장하기 위해 staleTime을 0으로 지정하고, 사용처마다 니즈에 맞게 staleTime을 부여하여 default option을 override하는 방식을 사용했다.
이런 상황에서 위의 문제를 다시 정리해보면 사실 이미 staleTime과 cacheTime이 0이기때문에 이론상 Hydration이 수행된 이후에 다시 refetch되었어야한다. 공식 문서에서도 강조하는 내용중 하나이지만 staleTime은 SSR을 수행하기 위해서는 애초에 staleTime이 0보다 커야했다. 추가적으로 cacheTime은 gc가 일어나는 기준시점이기때문에 0이었다면 당연히 정상적으로 Hydration 수행이 안되었어야했다. 하지만 refresh시에는 정상적으로(어쩌면 비정상적으로) Hydration이 되었고, Navigate시 Hydration 이후 초기화가 일어나 다시 API 호출이 일어나 의문이 생겼다.
위와 같이 Refresh 시의 Hydration에서는 정상적으로 동작하는 것처럼 보이지만 Navigate 시 일어나는 Hydration을 보면 정상적으로 동작하지 않는 것을 볼 수 있다.
그렇다면 이 두가지 케이스에서 왜 차이가 나는지 알아보기 위해 tanstack query의 소스코드를 확인하게 되었다.
본문
tanstack query는 query-core를 두고, 이를 가지고 각 라이브러리(React, Vue, Svelte 등등 ..) 에 맞게 라이브러리를 만들어두었기때문에 코어로직을 확인하기 위해 query-core의 hydration 파일을 확인했다.
해당 소스코드는 아래와 같다.
// 불필요한 내용과 hydrate로직 내 mutation 관련 로직은 생략
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)
// Do not hydrate if an existing query exists with newer data
if (query) {
if (query.state.dataUpdatedAt < state.dataUpdatedAt) {
// omit fetchStatus from dehydrated state
// so that query stays in its current fetchStatus
const { fetchStatus: _ignored, ...serializedState } = state
query.setState({
...serializedState,
data,
})
}
} else {
// Restore query
query = queryCache.build(
client,
{
...client.getDefaultOptions().hydrate?.queries,
...options?.defaultOptions?.queries,
queryKey,
queryHash,
meta,
},
// Reset fetch status to idle to avoid
// query being stuck in fetching state upon hydration
{
...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 })
}
})
}
여기서 주목할 부분은 queries를 순회하는 부분인데, 이 내용을 하나씩 확인해보자.
let query = queryCache.get(queryHash)
// Do not hydrate if an existing query exists with newer data
if (query) {
if (query.state.dataUpdatedAt < state.dataUpdatedAt) {
// omit fetchStatus from dehydrated state
// so that query stays in its current fetchStatus
const { fetchStatus: _ignored, ...serializedState } = state
query.setState({
...serializedState,
data,
})
}
먼저 query의 Hydration 과정에서 기존에 query가 활성화된 이력이 있어 queryCache에 존재한다면, 이를 해당 query가 cache에 존재하는 것으로 인지하고 cache에 있는 query의 state에 담긴 업데이트 된 시점인 dataUpdatedAt을 확인하고, 이와 현재 hydration을 수행하고자 하는 state가 업데이트 된 시점을 비교한다.
이후 특정 케이스로 인해 cache에 담겨있는 dataUpdatedAt이 새로 들어온 state의 dataUpdatedAt보다 더 최신이라면 Hydration하지 않고, 새로 들어온 state의 dataUpdatedAt이 더 최신이라면 기존의 query의 모든 속성을 그대로 둔 채로 data만 이식해준다.
이제 요청받은 query가 cache에 없는 케이스를 확인해보자.
// Restore query
query = queryCache.build(
client,
{
...client.getDefaultOptions().hydrate?.queries,
...options?.defaultOptions?.queries,
queryKey,
queryHash,
meta,
},
// Reset fetch status to idle to avoid
// query being stuck in fetching state upon hydration
{
...state,
data,
fetchStatus: 'idle',
},
)
요청받은 query가 없는 경우에는 queryCache의 build 메서드를 통해 새로 query를 생성해준다. 이때 queryCache의 build 메서드의 내부구현이 궁금할 수 있는데 이는 아래와 같다.
// 불필요한 부분 생략
if (!query) {
query = new Query({
cache: this,
queryKey,
queryHash,
options: client.defaultQueryOptions(options),
state,
defaultOptions: client.getQueryDefaults(queryKey),
});
this.add(query);
}
내용은 기본적으로 이름과 동일하게 해당 query를 만들어주는데, option은 queryClient에서 가지고있는 defaultOptions을 기반으로 받아온 options가 있다면 이를 활용해서 options를 세팅해주는 로직이다.
이제 위에서 Hydration 시 cache내의 query가 없는 케이스를 다시 확인해보면 defaultOptions에 있는 옵션과 queryKey, queryHash를 가지고 새로 만들어주며 data는 새로 받아온 data를 주입해주고, query가 있던 케이스와 다르게 fetchStatus를 idle로 만들어서 반환해준다.
그렇다면 queryCache에 해당 query가 존재하는지에 따라 달라지는 Hydration 플로우를 정리해보자.
- query 가 존재하는 경우 : 다른 옵션은 그대로 두고 data만 주입해준다.
- query 가 존재하지 않는 경우 : defaultOptions를 기본으로 설정하고 data의 주입과 함께 fetchStatus를 idle로 초기화해준다.
하지만 기존에 알고있던 내용으로는 fetchStatus가 idle인 상태로 새로운 호출이 일어나는 경우, 해당 query에 대한 observer가 존재하지 않는다면 다시 호출하는 것으로 알고있었다.
// 위의 근거와 관련된 실제 query-core의 query.ts 에 존재하는 코드이다.
protected optionalRemove() {
if (!this.observers.length && this.state.fetchStatus === 'idle') {
this.#cache.remove(this)
}
}
그렇다면 refresh시에는 query가 존재하지 않는데 fetchStatus가 idle 인 상황에서도 왜 Refresh가 일어나지 않았을까 ?
이를 이해하기 위해서는 정확히 gcTime이 0인 경우 어떻게 gc가 일어나고, 이것이 어떻게 Hydration에서 영향을 주는지 확인해봐야했다.
먼저 Observer 부분과 query의 observer에 대해 gc가 수행되는 과정에 대해 확인해보았다.
// query-core/src/queryObserver.ts
protected onSubscribe(): void {
if (this.listeners.size === 1) {
this.#currentQuery.addObserver(this)
if (shouldFetchOnMount(this.#currentQuery, this.options)) {
this.#executeFetch()
} else {
this.updateResult()
}
this.#updateTimers()
}
}
// query-core/src/query.ts
addObserver(observer: QueryObserver<any, any, any, any, any>): void {
if (!this.observers.includes(observer)) {
this.observers.push(observer)
// Stop the query from being garbage collected
this.clearGcTimeout()
this.#cache.notify({ type: 'observerAdded', query: this, observer })
}
}
removeObserver(observer: QueryObserver<any, any, any, any, any>): void {
if (this.observers.includes(observer)) {
this.observers = this.observers.filter((x) => x !== observer)
if (!this.observers.length) {
// If the transport layer does not support cancellation
// we'll let the query continue so the result can be cached
if (this.#retryer) {
if (this.#abortSignalConsumed) {
this.#retryer.cancel({ revert: true })
} else {
this.#retryer.cancelRetry()
}
}
this.scheduleGc()
}
this.#cache.notify({ type: 'observerRemoved', query: this, observer })
}
}
observer를 등록하는 onSubsribe동작을 보면 내부에 addObserver로 해당 query에 대한 observer를 등록하는 과정이 있다.
이 과정을 보면 내부적으로 observer가 push된 직후에는 gcTimeout를 clear하는 로직을 볼 수 있다. 또한 observer가 remove되는 removeObserver 로직을 보면 Gc에 대해 schedule하는 로직을 볼 수 있다.
이 두 메서드에 대해 확인해보자.
// query-core/src/removable.ts
protected scheduleGc(): void {
this.clearGcTimeout()
if (isValidTimeout(this.gcTime)) {
this.#gcTimeout = setTimeout(() => {
this.optionalRemove()
}, this.gcTime)
}
}
protected clearGcTimeout() {
if (this.#gcTimeout) {
clearTimeout(this.#gcTimeout)
this.#gcTimeout = undefined
}
}
query core에 있는 removable 파일에 두가지 메서드가 정의되어있었는데 이를 자세히보면 scheduleGc와 clearGcTimeout 모두 setTimeout과 clearTimeout을 통해 event loop 를 수행하는 방식이다.
그렇기 때문에 macroTask Queue에 쌓이는 task인 scheduleGc는 unmount 시에 queue에 들어가지만, 이후 바로 나타나는 React의 mount는 microTask Queue에 쌓이기때문에 hydration으로 인한 최초 렌더 이후 gc가 수행되어 초기화된 다음 다시 해당 api를 요청하고 response를 받아오는 과정이 발생했던 것이다.
하지만 위의 동작은 전제조건이 분명해야한다. Navigation 이전 지면에서 동일한 쿼리키가 존재하지 않았어야한다.
그 이유를 위에서 본 로직과 함께 확인해보자
Navigation Hydration 과정
A 쿼리키가 활성화된 이력이 있는 상태로 A 쿼리키를 가진 B지면으로 이동했을 경우
- staleTime, cacheTime이 유효한 경우에는 당연히 정상 Hydration 된다.
- staleTime과 cacheTime이 모두 0인 경우에서는 지면이 이동함에 따라 unmount가 수행되며 observer가 제거되고 scheduleGc가 수행되지만, 지면 이동 후 mount(microTask Queue)가 더 빠르게 수행되기때문에 해당 로직 수행 이후 gc가 수행된다. 하지만 mount 이후에는 다시 observer가 등록되기때문에 위에서와 같이 예약되어있던 gc를 clear한다.(clearGcTimeout) 따라서 staleTime, cacheTime이 유효하지 않더라도 전 지면에서 동일 쿼리키를 사용했던 경우에는 정상적으로 Hydration이 된다.
A 쿼리키가 활성화된 이력이 없는 상태로 A 쿼리키를 가진 B지면으로 이동했을 경우
- staleTime, cacheTime이 유효한 경우 : 해당 값이 유효한 경우에는 Hydration의 로직에 따라 기존에 동일한 쿼리키가 존재하지 않았던 경우는 fetchStatus를 idle로 하고 data를 주입해주면서 기존의 options을 그대로 가지고온다. 이후 유효한 staleTime, cacheTime을 가진 상태로 Hydration이 완료된 해당 쿼리는 렌더 이후 stale한 상태가 아니기때문에 그대로 유지된다.
- staleTime과 cacheTime이 모두 0인 경우에서는 mount가 수행되며 observer가 추가된다. Hydration의 로직에 따라 기존에 동일한 쿼리키가 존재하지 않았던 경우는 fetchStatus를 idle로 하고 data를 주입해주면서 기존의 options을 그대로 가지고온다. 따라서 staleTime, cacheTime이 유효하지 않은 상태로 Hydration이 완료되고, 해당 쿼리는 cacheTime이 0이기때문에 이후 gc가 이루어진다. gc가 완료된 후에 해당 query에 대한 observer가 존재하지 않고 fetchStatus가 idle이기때문에 다시 refetch를 수행한다.
따라서 지금까지 본 동작들을 정리하면 다음과 같다.
Refresh Hydration 과정
- 새로 mount되면서 QueryObserver가 새로 생성된다.
- 새로운 observer가 추가된다. (addObserver)
- 새 observer 추가 시 예약된 GC가 취소된다. (clearGcTimeout)
- queryCache.build()에서 query의 defaultOption을 가지고 새로 쿼리를 만들고 data만 이식해준다.
- 이후 Hydration이 완료된 후에도 clearGcTimeout에 따라 GC는 취소되고 상태가 유지된다.
Navigate Hydration 과정
- staleTime, cacheTime이 유효한 경우에는 이전 지면에 동일 쿼리키가 있었는지 여부와 상관없이 Hydration이 유지된다.
- staleTime, cacheTime이 유효하지 않은 경우에는 이전 지면에 동일 쿼리키가 있었는지 여부에 따라 Hydration이 유지되는지, 아니면 이후 다시 refetch가 일어나는지 달라진다.
일단 두 케이스 모두 중요하게 알아야하는 점은 GC는 timeout기반이기때문에 macroTask Queue에 속해 microTask Queue에 속하는 mount보다 늦게 수행된다.
- 동일 쿼리키가 있었다면, 전 지면이 unmount되고 staleTime, cacheTime이 유효하지 않기때문에 Observer가 remove되며 발생한 GC 스케줄링이 이동한 지면에서 새로 Observer가 add됨에 따라 수행된 GC timeout clear의 영향을 받아 예약되어있던 GC가 취소된다. 따라서 새로운 지면에서 발생한 Hydration은 정상적으로 유지가 된다.
- 동일 쿼리키가 없었다면, 새로운 지면이 mount되고 staleTime, cacheTime이 유효하지 않기때문에 새로 생성된 Observer는 Hydration 시에 data만 이식될 뿐 defaultOptions의 옵션은 그대로 가지고온다. 따라서 위의 케이스처럼 Observer가 add되어 GC timeout clear는 일어나지만 그 순간에 등록된 GC는 존재하지 않고, mount 이후 해당 쿼리가 가진 옵션에 따라 cacheTime이 0이기때문에 GC가 스케줄링되고, 해당 쿼리 데이터가 제거된 후 refetch하는 것이다.
정리
돌아보면 SSR을 사용하는 상황에서는 당연히 staleTime을 0보다 크게주어야하고, 일반적인 케이스에서는 cacheTime을 default값인 5분으로 두고 별도로 커스텀하지 않아도 해당 시간 내에서 stale하게된 경우 새로운 데이터를 받아오기 전까지 이전 데이터를 유지해주는 역할을 하기때문에 수정하지 않았어도 된다.
아무래도 이런 이유때문에 문제가 발생했을 때 검색해보더라도 동일한 케이스를 마주하기 어려웠던 것 같다.
하지만 CSR환경에서의 버릇으로 의도치않게 잘못된 코드를 만들었던 상황에서 이해하고있지 못했던 상황에 대해 소스코드를 보고 파악한 후 제대로 정리하게 되어 Tanstack Query의 Hydration이 어떻게 동작하는지 자세하게 알게되어 실수로부터 의미있는 시간을 만들었다.
'개발 > React' 카테고리의 다른 글
(짧) Tree Shaking과 이를 사용하는 곳에서 해야할 일 (1) | 2024.12.03 |
---|---|
Nextjs와 Vanilla Extract 사용 시 css가 로드되지 않는 문제 Trouble Shooting (0) | 2024.11.24 |
더 좋은 유저 경험을 위한 Streaming SSR (1) | 2024.10.13 |
PWA환경에서 유저 친화적으로 앱 다운로드 안내하기 (0) | 2024.06.17 |
jotai atom을 모킹하는 다양한 방법 (0) | 2024.05.13 |