React

Tanstack-Query 캐싱이 왜 필요하지? (gcTime, staleTime)

Jaymyong66 2024. 12. 12. 15:45

1. Tanstack-query에 캐싱 기능이 필요한 이유

Tanstack-query는 비동기 상태, 서버 상태를 관리해주는 라이브러리이다.

브라우저와 서버 자체적으로도 캐시 관리를 할텐데, 서버의 상태를 관리하는 라이브러리에서도 캐싱이 필요한 이유가 궁금했다.

클라이언트 측에서 서버의 상태를 알고 싶다면 항상 최신의 상태를 알도록 요청하고, 이에 대한 캐싱은 브라우저나 서버의 책임이지  tanstack-query의 책임이 아니지 않을까? 라는 생각이었다. 

 

하지만 tanstack-query는 갖고 있는 서버 상태를 유효성 검사를 하며 네트워크 요청 자체를 효율적으로 관리하고자 했던 것이다. 간단히 정리하자면,

  1. 네트워크 요청을 최소화한다. 동일한 데이터를 반복적으로 서버에 요청하지 않도록 한다. 이는 서버 부하를 줄이고, 클라이언트-서버 간의 네트워크 트래픽을 감소시킬 수 있다. 동일한 데이터임을 식별하는 값은 Query-key가 있고, staleTime, gcTime으로 설정이 가능하다.
  2. 사용자의 경험을 개선시킬 수 있다. 예를 들어, 서버에서 최신의 데이터를 가져오는 동안 캐싱 해놓은 데이터를 보여주고 있을 수 있다. 또 만약 사용자가 잠시 오프라인 상태이더라도 캐싱된 데이터를 활용해 잠시나마 해당 데이터로 서비스를 할 수 있다.
  3. 데이터를 일정 시간 간격으로 최신 데이터로 동기화할 수 있다. 또 최신의 서버 데이터와 비교해 캐싱된 데이터가 유효한지 여부를 관리하고 필요 시, 새로운 데이터를 가져오도록 하여 데이터의 일관성을 유지한다.

2. Tanstack-query에서 캐싱을 적용해보자

  • query key

먼저 각 쿼리는 Query key로 고유하게 식별된다. 아래에서는 ‘todos’라는 문자열로 query key를 생성했다.

만약 다른 컴포넌트에서 useTodosQuery 를 사용하더라도 query key에 의해 해당 쿼리가 식별되어 캐싱된 값이 유효하다면 해당 값을 사용하게 된다.

const useTodosQuery =  useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  staleTime: 0, // 바로 stale 상태로 변경
  gcTime: 1000 * 60 * 5, // 5분
});

const { data } = useTodosQuery();

 

  • staleTime

데이터가 신선하지 않은(stale) 상태로 간주되는 시간을 설정한다. 즉, 데이터의 유통기한을 설정한다.

`staleTime`을 설정하지 않는다면 기본값은 0으로 설정되며, 서버에서 받아온 데이터가 즉시 stale 상태로 전환된다.

만약 `staleTime`이 설정된 기간이 5분이라면, 5분동안 캐싱된 데이터가 그대로 사용되며, 새로 데이터를 가져오지 않는다.

 

소스 코드에서 staleTime이 쓰이는 곳을 살펴보자.

stale상태인지 검사하는 `isStaleByTime` 메서드는 다음과 같다. `timeUntilStale` 유틸 함수와 함께 쓰이며, `ensureQueryData`메서드에서 `prefetch` 하기 전에 사용되는 메서드이기도 하다

// query.ts
  isStaleByTime(staleTime = 0): boolean {
    return (
      this.state.isInvalidated ||
      this.state.data === undefined ||
      !timeUntilStale(this.state.dataUpdatedAt, staleTime)
    )
  }

// utils.ts  
export function timeUntilStale(updatedAt: number, staleTime?: number): number {
  return Math.max(updatedAt + (staleTime || 0) - Date.now(), 0)
}
// queryClient.ts
  ensureQueryData<
    TQueryFnData,
    TError = DefaultError,
    TData = TQueryFnData,
    TQueryKey extends QueryKey = QueryKey,
  >(
    options: EnsureQueryDataOptions<TQueryFnData, TError, TData, TQueryKey>,
  ): Promise<TData> {
    const defaultedOptions = this.defaultQueryOptions(options)
    const query = this.#queryCache.build(this, defaultedOptions)
    const cachedData = query.state.data

    if (cachedData === undefined) {
      return this.fetchQuery(options)
    }

    if (
      options.revalidateIfStale &&
      query.isStaleByTime(resolveStaleTime(defaultedOptions.staleTime, query))
    ) {
      void this.prefetchQuery(defaultedOptions)
    }

    return Promise.resolve(cachedData)
  }
  • gcTime

활성 되어있지 않은 캐시 데이터가 메모리에 남아있는 시간이다.

활성 되어있지 않다는 것은 쿼리 인스턴스가 inactive 되었다는 뜻이다. 간단히 이야기하면, 해당 query를 사용하는 컴포넌트(구독하는 observer라고 한다)가 없을 때, 설정한 gcTime 만큼 메모리 상에서 가지고 있는다. 이후에는 가비지 콜렉터가 해당 데이터를 처리한다.

기본값은 5분이다.

 

간단히 소스 코드를 살펴보자

gcTime은 `Removable.ts`에 구현되어있다.

기본값 5분으로 `updateGcTime`에서 설정하는 것으로 보인다.

이는 아래 `query.ts`에서 `setOptions`를 사용할때 사용되는 메서드로 gcTime을 업데이트하고,

`queriesObserver`에서 `onUnsubscribe` 메서드에서 `destroy` 메서드가 사용된다.

즉, 리스너가 아무도 없을 때, 해당 데이터 캐시를 삭제하는 것으로 보인다.

import { isServer, isValidTimeout } from './utils'

export abstract class Removable {
  gcTime!: number
  #gcTimeout?: ReturnType<typeof setTimeout>

  destroy(): void {
    this.clearGcTimeout()
  }

  protected scheduleGc(): void {
    this.clearGcTimeout()

    if (isValidTimeout(this.gcTime)) {
      this.#gcTimeout = setTimeout(() => {
        this.optionalRemove()
      }, this.gcTime)
    }
  }

  protected updateGcTime(newGcTime: number | undefined): void {
    // Default to 5 minutes (Infinity for server-side) if no gcTime is set
    this.gcTime = Math.max(
      this.gcTime || 0,
      newGcTime ?? (isServer ? Infinity : 5 * 60 * 1000),
    )
  }

  protected clearGcTimeout() {
    if (this.#gcTimeout) {
      clearTimeout(this.#gcTimeout)
      this.#gcTimeout = undefined
    }
  }

  protected abstract optionalRemove(): void
}
// query.ts

  setOptions(
    options?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
  ): void {
    this.options = { ...this.#defaultOptions, ...options }

    this.updateGcTime(this.options.gcTime)
  }
// queriesObserver.ts

  protected onUnsubscribe(): void {
    if (!this.listeners.size) {
      this.destroy()
    }
  }

3. 두 값은 왜 분리되어 있고, 기본값은 왜 각각 0분, 5분 일까?

얼핏 생각하면 staleTime과 gcTime 모두 데이터를 서버에 요청하지 않고 재사용하기 위한 값처럼 보였다.

왜 분리해서 괜히 헷갈리게 한건지 의문이 들었다.

  • staleTime

이는 ‘캐싱된 데이터’가 ‘신선한’ 상태로 간주되는 기간이다. 즉 데이터의 정확성과 신뢰성을 관리한다.

데이터가 얼마나 오랫동안 안전하게 재사용될 수 있는지 제어하는 값이다. 만약 ‘신선’하다고 판단되면 해당 데이터는 네트워크 요청 없이 캐싱된 데이터를 그대로 사용한다.

  • gcTime

이는 캐싱된 데이터가 ‘메모리에 유지되는 기간’을 정의한다.

애플리케이션의 ‘리소스 효율성’을 관리하는 값이다.

캐싱된 데이터는 메모리를 소비하며, 캐싱을 많이 하면 메모리 사용량 증가 및 성능 저하가 일어난다.

불필요한 데이터를 제거하도록 하며, 리소스 낭비를 방지한다.

 

정리하자면, staleTime은 캐싱된 데이터의 ‘유효성’, gcTime은 ‘저장기간’을 관리한다.

 

staleTime: 0, // 즉시 "stale" 상태
gcTime: 1000 * 60 * 10, // 10분간 캐시 유지

 

staleTime은 0분 동안 신선한 상태로 간주하지만 gcTime 10분으로 메모리에 유지할 수 있다.

데이터가 0분 뒤에 stale되지만, 새 요청을 보내더라도 동일한 queryKey에 대해선 캐싱된 데이터가 사용된다.

 

이는 정적인 사이트의 사용자 프로필 데이터를 생각해보자. 데이터를 항상 최신으로 유지하지만, 동일한 요청의 중복 처리를 방지하고자 한다. (사용자 프로필을 조회할 때마다 최신 데이터를 가져온다. 하지만 같은 요청은 너무 자주 발생시키지 않는다)

 

staleTime: 1000 * 60 * 5, // 5분 동안 "신선" 상태
gcTime: 1000 * 60 * 2, // 2분간 캐시 유지

 

staleTime이 gcTime보다 길때도 있다.

데이터는 5분동안 신선하지만, 2분 후 캐시에서 삭제된다.

 

자주 갱신되지 않는 데이터의 경우, 즉 신선한 데이터가 필요하지만 메모리 사용량을 줄이는 경우가 있다.

하지만 신선하게 유지하려하지만 캐시가 삭제되기에, 캐싱 효과가 2분 정도일 것이기에 잘 사용되지 않을 것 같다.

  • 왜 기본값이 staleTime은 0이고, gcTime은 5분일까?

staleTime을 무작정 늘리면, 새로고침을 하지 않는 이상 페이지 데이터가 갱신되지 않을 것이다.

즉시 최신 데이터를 요청할 수 있는 기회를 제공하고, 대부분의 앱에서는 기본적으로 사용자가 최신의 데이터를 보기를 기대하기 때문이다.

 

gcTime은 너무 오래 유지되면 메모리 사용량이 증가하고, 너무 짧으면 빈번하게 네트워크 요청을 한다.

아마도 5분이 라이브러리 개발자 간 결정된 대부분의 앱에서의 적절한 값이라고 판단한 것 같다.

 

주목한 것은 staleTime이 gcTime보다 작았다는 것이다.

최신데이터를 최대한 유지하고 메모리는 효율적으로 관리하고자 했던게 아닐까?

 

SWR(stale-while-revalidate) 방식을 이해해보자!

 

참고자료

https://github.com/TanStack/query

 

GitHub - TanStack/query: 🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/J

🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query. - TanStack/query

github.com

자료를 찾다가 발견한 공식문서 한글 번역 사이트

https://react-query.kro.kr/docs/community-resources/tkdodos-blog#18-inside-react-query

 

TkDodo's Blog – React Query 한글 문서

Nextra: the next docs builder

react-query.kro.kr