우리가 TanStack Query를 사용하는 이유 중 가장 많이 언급하는 것 중에 하나가 '캐싱'기능이다.
캐싱이란 데이터나 결과를 임시로 저장하여 동일한 요청에 대해 더 빠르게 응답할 수 있도록 하는 매커니즘입니다.
하지만 어떤 원리로 TanStack Query가 데이터를 캐싱하는지 모르고 있었기 때문에 정리할 겸 다른 사람들에게 공유도 할 겸 글을 작성한다.
우리는 TanStack Query를 사용하기 위해 프로젝트 최상단에 QueryClient 객체를 생성한다. 그리고 생성한 객체를 QueryClientProvider의 client props에 넘겨준다.
// 예시
const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
<Todos />
</QueryClientProvider>
)
}
TanStack Query의 코드를 살펴보면 QueryClient객체가 생성될 때 생성자를 호출하는데 config객체에 queryCache속성이 존재하지 않으면 새로운 QueryCache 객체를 생성해서 할당한다.
// queryClient.ts
export class QueryClient {
#queryCache: QueryCache
...
constructor(config: QueryClientConfig = {}) {
this.#queryCache = config.queryCache || new QueryCache()
...
}
}
아마 여기에서 캐싱 기능 동작에 대한 답을 얻을 수 있을 것 같다. 다시 TanStack Query의 코드를 까보자.
// queryCache.ts
export interface QueryStore {
has: (queryHash: string) => boolean
set: (queryHash: string, query: Query) => void
get: (queryHash: string) => Query | undefined
delete: (queryHash: string) => void
values: () => IterableIterator<Query>
}
export class QueryCache extends Subscribable<QueryCacheListener> {
#queries: QueryStore
constructor(public config: QueryCacheConfig = {}) {
super()
this.#queries = new Map<string, Query>()
}
우선 QueryStore라는 타입을 가진 #queries에 Map객체를 할당한다. Map객체의 타입을 보니 <string, Query> 이렇게 되어 있는데 string은 queryKey 그리고 Query는 우리가 useQuery hook을 사용해서 만드는 Query객체 같다.
즉, 여기서 보면 TanStack Query는 Map 객체를 이용해서 Query 객체들을 키를 통해 유니크하게 식별하고 관리하는것을 알 수 있다.
// queryCache.ts
build<TQueryFnData, TError, TData, TQueryKey extends QueryKey>(
client: QueryClient,
options: WithRequired<
QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
'queryKey'
>,
state?: QueryState<TData, TError>,
): Query<TQueryFnData, TError, TData, TQueryKey> {
// 1
const queryKey = options.queryKey
const queryHash =
options.queryHash ?? hashQueryKeyByOptions(queryKey, options)
// 2
let query = this.get<TQueryFnData, TError, TData, TQueryKey>(queryHash)
// 3
if (!query) {
query = new Query({
cache: this, // QueryCache
queryKey,
queryHash,
options: client.defaultQueryOptions(options),
state,
defaultOptions: client.getQueryDefaults(queryKey),
})
this.add(query)
}
return query
}
바로 아래에 위치한 빌드 메서드를 보니 Query객체를 생성하는 로직이 포함되어 있었다.
쿼리 키를 옵션에서 추출하고 쿼리 해시를 계산한다. options.queryHash가 제공되면 그 값을 사용하고 그렇지 않으면 hashQueryKeyByOptions 함수에 queryKey와 options를 매개변수로 전달해 queryHash를 새로 생성한다.
그 다음에 현재 QueryCache 객체에서 해당 해시를 가진 Query 객체를 찾는다. 아래 처럼 get method를 사용해서 this.#queries에 queryHash를 키로 가진 Query 객체를 찾고 없으면 undefined를 return 한다.
// queryCache.ts
get<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
queryHash: string,
): Query<TQueryFnData, TError, TData, TQueryKey> | undefined {
return this.#queries.get(queryHash) as
| Query<TQueryFnData, TError, TData, TQueryKey>
| undefined
}
TanStack Query에는 Query observer라는 개념이 있다. Query observer는 각 Query 객체들을 구독해 변경사항을 감지하고 적절한 동작을 수행한다.
// query.ts
constructor(config: QueryConfig<TQueryFnData, TError, TData, TQueryKey>) {
super()
this.#abortSignalConsumed = false
this.#defaultOptions = config.defaultOptions
this.setOptions(config.options)
this.#observers = []
...
}
위와같이 Query객체의 생성자 함수를 보면 this.#observers 배열이 있는데 구독요청이 들어오면 this.#observers에 옵저버들이 추가되고 객체 상태가 변경되면 옵저버들을 호출한다.
// queryObserver.ts
protected onSubscribe(): void {
if (this.listeners.size === 1) {
this.#currentQuery.addObserver(this)
...
}
}
// 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 })
}
}
Query observer에 onSubscribe 메서드가 호출되면 현재 Query에 addObserver메서드를 호출해서 현재 옵저버를 추가한다.
갑자기 Query observer의 개념을 설명한 이유는 우리가 보통 캐싱을 위해 설정하는 staleTime과 gcTime이 Query observer와 관련이 있기 때문이다.
stale이란 데이터가 신선하지 않은 상태를 의미하고 신선하지 않기 때문에 데이터를 새로 refetch해야한다. staleTime동안은 데이터가 fresh하고 그 이후부터는 데이터가 stale한 상태로 간주한다.
// queryObserver.ts
protected onSubscribe(): void {
if (this.listeners.size === 1) {
this.#currentQuery.addObserver(this)
// 1
if (shouldFetchOnMount(this.#currentQuery, this.options)) {
this.#executeFetch()
} else {
this.updateResult()
}
// 2
this.#updateTimers()
}
}
아까 봤던 Query observer의 onSubscribe 메서드를 다시보자. 새로운 query가 mount됐을 때 실행되는 메서드이다.
this.#updateTimers() 내부를 보면 this.#updateStaleTimeout() 메서드를 호출하고 있다.
// queryObserver.ts
#updateStaleTimeout(): void {
this.#clearStaleTimeout()
if (
isServer ||
this.#currentResult.isStale ||
!isValidTimeout(this.options.staleTime)
) {
return
}
const time = timeUntilStale(
this.#currentResult.dataUpdatedAt,
this.options.staleTime,
)
// 유효시간이 만료되기 1ms 이전에 타입아웃이 트리거되는 경우가 있습니다.
// 이 문제를 해결하기 위해 항상 타입아웃에 1ms를 추가합니다.
const timeout = time + 1
this.#staleTimeoutId = setTimeout(() => {
if (!this.#currentResult.isStale) {
this.updateResult()
}
}, timeout)
}
timeUntilStale함수를 호출해서 stale 될 때까지의 시간이 얼마나 남았는지 계산하고 setTimeOut을 이용해서 일정시간 이후 현재 상태가 stale하지 않다면 this.updateResult()를 호출해 fresh -> stale 상태로 변경하는 로직을 수행한다.
(V5부터 cacheTime이 gcTime으로 바뀌었다고 한다.)
Query 객체 로직을 보면
// query.ts
constructor(config: QueryConfig<TQueryFnData, TError, TData, TQueryKey>) {
super()
...
this.setOptions(config.options)
...
this.scheduleGc()
}
setOptions(
options?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
): void {
this.options = { ...this.#defaultOptions, ...options }
this.updateGcTime(this.options.gcTime)
}
this.updateGcTime으로 gcTime을 업데이트하고 옵저버가 제거되었을 때 데이터가 페칭되었을 때 등등 this.scheduleGc 메서드가 호출되고 있다.
// removable.ts
protected scheduleGc(): void {
this.clearGcTimeout()
if (isValidTimeout(this.gcTime)) {
this.#gcTimeout = setTimeout(() => {
this.optionalRemove()
}, this.gcTime)
}
}
지정해놓은 gcTime만큼 시간이 지나면 this.optionalRemove()가 호출되는데
// query.ts
protected optionalRemove() {
if (!this.#observers.length && this.state.fetchStatus === 'idle') {
this.#cache.remove(this)
}
}
optionalRemove라는 이름이 붙여진 것은 조건이 있기 때문이다. 조건은 총 두가지로
1. 현재 Query객체를 구독하는 옵저버가 없어야 한다.
2. fetchStaus가 idle이여야 한다. 즉, 현재 활동적인 데이터 페칭 작업이 없는 상태여야 한다.
이 모든 조건을 충족하면 this.#cache.remove 호출을 통해 QueryCache객체의 this.#queries에 있는 해당하는 query를 삭제한다.
결론은 TanStack Query는 QueryCache 객체 안의 this.#queries라는 Map객체를 사용해서 캐싱 기능을 구현하고 있다. 그리고 QueryObserver라는 객체를 활용해서 Query를 구독함과 동시에 staleTime, gcTime을 관리하고 있고 각각 시간은 목적에 따라서 적절히 호출되어 우리가 캐싱기능을 안정적으로 쓸 수 있게 해준다.
직접 TanStack Query 깃헙에 들어가서 코드 하나하나 보면서 분석하고 또 관련된 글을 읽으면서 더 명확하게 이해하려고 노력했다. 선언적인 프로그래밍을 가능하게 해준 노력 뒤에 이런 로직이 숨겨져 있는걸 알 수 있었던 소중한 시간이였다!
혹시 탠스택 쿼리 코드 까보실 때 https://github.com/TanStack/query 여기 사이트에서 까보시는건가요?
저도 캐싱이 왜 그렇게 되는지 궁금해서 코드 까보는데 폴더구조가 많으니까... 찾기가어렵네욤