들어가는 말
TanStack Query에는 staleTime
, gcTime
(구 cacheTime
) 이라는 설정이 있습니다. 각각 특정한 시간이 지나면 가져온 자료를 stale하고 판단하거나, 아니면 아예 삭제해버리는 기능이죠. 재미있는 점은 이 설정에 Infinity
라는 값을 줄 수 있다는 겁니다. 특정 시간이 지나면 어떤 행동을 할 때는 setTimeout을 사용할 텐데, setTImeout에 Infinity는 줄 수 없기 때문이죠. 그래서 TanStack Query는 무한대의 시간을 어떻게 세고 있는지 궁금해서 찾아봤습니다.
TanStack Query v5 의 코드를 봤어요.
요약
무한의 시간을 세는 게 아니라, Infinity가 들어오면 setTimeout 자체를 하지 않는 방식으로 회피합니다.
gcTime, cacheTime
// packages/query-core/src/utils.ts
export function isValidTimeout(value: unknown): value is number {
return typeof value === 'number' && value >= 0 && value !== Infinity
}
일단 Infinity는 invalid timeout으로 판단한다는 사실을 알 수 있습니다. 함수의 리턴 타입을 왜 타입 가드 식으로 작성했는지는 모르겠네요. 음수도 number이고 Infinity도 number 인데 말이죠.
// packages/query-core/src/removable.ts
export abstract class Removable {
// ...
protected scheduleGc(): void {
this.clearGcTimeout()
if (isValidTimeout(this.gcTime)) {
this.#gcTimeout = setTimeout(() => {
this.optionalRemove()
}, this.gcTime)
}
}
// ...
}
조금 더 찾아보면 scheduleGc 라는 함수에서 gcTime이 올바르다고 판단했을 때만 timeout을 등록한다는 것을 확인할 수 있습니다.
// packages/query-core/src/query.ts
export class Query<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
> extends Removable {
// ...
constructor(config: QueryConfig<TQueryFnData, TError, TData, TQueryKey>) {
super()
this.#abortSignalConsumed = false
this.#defaultOptions = config.defaultOptions
this.setOptions(config.options)
this.#observers = []
this.#cache = config.cache
this.queryKey = config.queryKey
this.queryHash = config.queryHash
this.#initialState = config.state || getDefaultState(this.options)
this.state = this.#initialState
this.scheduleGc()
}
// ...
}
그리고 우리가 useQuery를 사용할 때 만들어지는 Query 클래스 생성 시, scheduleGc 를 한 번 실행합니다. 즉 gcTime: Infinity
설정을 넣으면 애초에 본인을 제거하는 행동 자체를 하지 않도록 만들어지기 때문에 마치 무한의 시간이 지난 뒤 제거되는 것처럼 표현할 수 있게 되는 겁니다.
구버전인 v3 에서는 원리는 같지만 용어만 cacheTime으로 다르고, Removable 클래스가 따로 없고 Query 클래스에 바로 달려 있다는 차이만 있습니다.
staleTime
이쯤되면 staleTIme도 마찬가지라는 느낌이 오실 겁니다.
// packages/query-core/src/queryObserver.ts
export class QueryObserver<
// ...
> extends Subscribable<QueryObserverListener<TData, TError>> {
// ...
constructor(
client: QueryClient,
public options: QueryObserverOptions<
// ...
>,
) {
super()
this.#client = client
this.#selectError = null
this.bindMethods()
this.setOptions(options)
}
// ...
setOptions(
options: QueryObserverOptions<
// ...
>,
notifyOptions?: NotifyOptions,
): void {
// ...
// Update stale interval if needed
if (
mounted &&
(this.#currentQuery !== prevQuery ||
this.options.enabled !== prevOptions.enabled ||
this.options.staleTime !== prevOptions.staleTime)
) {
this.#updateStaleTimeout()
}
// ...
}
// ...
#updateStaleTimeout(): void {
this.#clearStaleTimeout()
if (
isServer ||
this.#currentResult.isStale ||
!isValidTimeout(this.options.staleTime)
) {
return
}
// ...
}
// ...
}
처음에 만들어질 때 설정을 하면서 isValidTimeout으로 Infinity를 걸러낸 후, Infinity라면 무시합니다.
마무리
원하는 답을 찾는 건 생각보다 너무 쉬웠습니다. 답 자체는 위쪽 요약에 있으니 생략할게요.
특이하게 저는 이걸 찾아보면서 읽기 좋은 코드란 무엇인지 생각해볼 수 있었습니다. 아래 메서드 하나로 설명이 가능하겠네요.
isStaleByTime(staleTime = 0): boolean {
return (
this.state.isInvalidated ||
this.state.data === undefined ||
!timeUntilStale(this.state.dataUpdatedAt, staleTime)
)
}
함수가 반환하는 값이 정말 뻔한(IDE에서 마우스를 갖다 대면 알아서 추론해줄 만큼 쉬운) 부울 대수였지만 메서드의 첫 줄에 그걸 명확하게 적어놓았더라구요. 반환값이 아예 없는 경우에도 void를 적었습니다. 솔직히 저는 코드를 짤 때 반환 타입이 원시 타입이면 안 썼었는데요. 남의 코드를 보다 보니까 그거 유무가 좀 크다고 느꼈습니다.
다음으로는 !timeUntilStale()
부분이었습니다. !0
이 true라는 것은 모두가 알고 있지만 막상 number를 반환하는 함수를 쓰고 그 앞에 느낌표를 달아놓으니 한번에 알아보기가 힘들었습니다(어디까지나 제 생각입니다). 코드를 작성할 때는 저 방식이 깔끔해 보일지는 몰라도 바로 읽히지는 않는 느낌이었어요. 특히 숫자의 경우에는 0인지 아닌지 여부로만 판단하는 느낌이라 음수가 왔을 때는 어떻게 케어했는지도 생각해야 하니까요. 왜 뻔한 함수들을 굳이 lodash 같은 외부 라이브러리를 쓰면서까지 선언적으로 코드를 작성하는지 이해할 수 있는 경험이었습니다.
참고 자료
'호기심 천국' 카테고리의 다른 글
정규표현식으로 알파벳 자음을 표현하는 법에 대한 이야기 (0) | 2024.05.31 |
---|---|
MVC (0) | 2024.04.27 |
리액트와 대수적 효과는 무슨 관계일까? (1) | 2024.03.30 |
TypeScript) enum vs as const (2) | 2024.03.24 |
리액트 동시성이란 (4) | 2024.03.09 |