TanStack Query는 어떻게 데이터를 캐싱할까?

minzip·2025년 2월 9일
5

React

목록 보기
4/4
post-thumbnail

서론

프로젝트에서 서버 상태를 효율적으로 관리하기 위해 TanStack Query를 자주 활용한다. 특히, 네트워크 요청이 많은 환경에서 캐싱 기능은 성능 최적화와 사용자 경험 개선에 중요한 역할을 한다.

하지만 단순히 라이브러리를 사용하기만 할 뿐, 내부에서 어떻게 캐싱이 동작하는지 깊이 이해하지 못하고 있었다. 이번 기회를 통해 캐싱 관련 개념을 정리하고, 코드를 통해 TanStack Query의 내부 동작 방식을 분석해보고자 한다 🔥


캐싱이란?

캐싱(Cache)이란 자주 사용하는 데이터나 결과를 저장하여 빠르게 접근할 수 있도록 하는 기술이다.
서버에서 데이터를 매번 가져오는 대신, 한 번 가져온 데이터를 일정 시간 동안 보관하여 불필요한 네트워크 요청을 줄이고 응답 속도를 개선하는 데 활용된다.

SWR(Stale-While-Revalidate) 전략

SWR은 HTTP 캐싱 표준 중 하나로, 서버가 응답 헤더를 통해 브라우저 캐시 또는 프록시 서버가 먼저 캐시된 데이터를 제공하도록 한 후, 백그라운드에서 최신 데이터를 요청해 자동으로 갱신하는 방식이다.

SWR을 HTTP에서 설정하는 방법

SWR을 적용하려면 HTTP의 Cache-Control 헤더에 stale-while-revalidate 지시어를 추가하면 된다.

Cache-Control: max-age=60, stale-while-revalidate=120

이 설정은 다음과 같은 의미를 가진다.

  • max-age=60: 데이터를 캐시에 저장한 후 60초 동안 신선한 데이터로 간주한다.
  • stale-while-revalidate=120: 60초가 지나면 최대 120초 동안 기존 캐시 데이터를 그대로 사용하면서, 동시에 백그라운드에서 최신 데이터를 요청하여 갱신한다.

이 방식은 사용자가 오래된 데이터를 보지 않도록 하면서도 불필요한 네트워크 요청을 줄이고, 응답 속도를 높이는 효과를 가져온다.


🌴 TanStack Query의 캐싱

SWR 개념을 활용하는 대표적인 라이브러리 중 하나가 TanStack Query다. TanStack Query는 React 애플리케이션에서 서버 상태를 효율적으로 관리할 수 있도록 설계된 라이브러리로, 기본적으로 SWR과 유사한 캐싱 전략을 채택하고 있다.

참고로 이번 글은 TanStack Query v5를 기준으로 알아본다.

useQuery를 이용한 데이터 캐싱

useQuery 훅은 서버에서 데이터를 가져오고, 이를 캐싱하여 불필요한 네트워크 요청을 줄이는 역할을 한다.

const { data, isLoading } = useQuery({
  queryKey: ['todos'], // 요청을 식별하는 키
  queryFn: fetchTodos, // 데이터를 가져오는 함수
  staleTime: 1000 * 60, // 데이터가 신선한 상태로 유지되는 시간 (1분)
  gcTime: 1000 * 60 * 5, // 캐시가 유지되는 시간 (5분)
});
옵션설명
queryKey요청을 식별하는 키 (같은 키를 가진 요청은 같은 캐시에 저장됨)
queryFn데이터를 가져오는 비동기 함수
staleTime데이터를 신선한 상태로 유지하는 시간 (이 시간이 지나면 데이터가 "오래됨"으로 간주됨, 기본값 0분)
gcTime오래된 데이터를 캐시에서 제거하는 시간 (이 시간이 지나면 캐시에서 삭제됨, 기본값 5분)

useMutation을 이용한 데이터 업데이트

useMutation은 데이터를 변경(생성, 수정, 삭제)하는 API 요청을 처리하는 데 사용된다.

const mutation = useMutation({
  mutationFn: createTodo, // 데이터를 변경하는 함수
  onSuccess: () => {
    queryClient.invalidateQueries({queryKey: ['todos']}); // todos 캐시 무효화 후 자동 새로고침
  },
});
옵션설명
mutationFn데이터를 변경하는 비동기 함수 (예: POST, PUT, DELETE 요청)
onSuccess요청이 성공하면 실행할 콜백 함수
  • invalidateQueries(['todos'])를 사용하면 queryKey: ['todos'] 캐시를 무효화하고 데이터를 다시 불러옴.
  • 즉, useMutation으로 데이터를 변경한 후, 캐싱된 데이터를 최신 상태로 갱신하는 역할을 한다.

데이터 흐름 (A → B → C 시나리오)

컴포넌트 A - 최초 캐싱

const { data, isLoading } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  staleTime: 1000 * 60, // 1분 동안 신선한 데이터 유지
  gcTime: 1000 * 60 * 5, // 5분 동안 캐시에 유지
});
  1. useQuery(['todos'])가 실행
    캐시 컨텍스트에 queryKey가 "todos"인 데이터를 요청한다. 하지만 최초 상태이므로 캐시된 데이터가 없어 data는 undefined, isLoading: true가 반환된다.

  2. 네트워크 요청 실행
    비동기적으로 fetchTodos() 실행되고 API 요청 발생한다.
    요청이 끝나기 전까지 data: undefined 상태가 유지된다.

  3. 응답 도착 후 데이터 캐싱
    fetchTodos()가 응답을 받으면 데이터를 캐시에 저장한다.
    이제 data가 실제 데이터를 가지게 되고, isLoading: false로 변경된다.
    이때 쿼리 상태가 변경되었으므로 컴포넌트가 리렌더링 된다.

  4. useQuery 재실행 및 캐싱된 데이터 반환
    다시 queryKey가 "todos"인 데이터를 요청하고 캐싱된 데이터를 반환받는다.

컴포넌트 B

const { data, isFetching } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
});
  1. useQuery(['todos'])가 실행되어 캐시된 데이터를 반환 받는다.

  2. 이때

    • staleTime 이내면 캐시를 사용하고,
    • staleTime 초과 시에는 백그라운드 refetch,
    • gcTime 초과 시에는 네트워크 요청이 다시 발생한다.

📌 백그라운드 refetch VS 네트워크 요청

  • 백그라운드 refetch는 UI를 방해하지 않고 데이터를 갱신하며, 새로운 데이터가 준비되면 UI가 업데이트
  • 네트워크 요청은 캐시된 데이터가 없거나 만료되었을 때 발생하며, 로딩 상태를 보여주고 요청이 완료되면 UI가 업데이트

컴포넌트 C - 데이터 변경

const mutation = useMutation(updateTodo, {
  onSuccess: () => {
    queryClient.invalidateQueries({queryKey: ['todos']});
  },
});
  1. updateTodo()이 실행된다.
  2. API 요청이 성공하면 onSuccess() 실행된다.
  3. queryClient.invalidateQueries(['todos'])
    • 캐시된 ['todos'] 데이터가 무효화되고 A와 B에서 자동으로 최신 데이터 요청(refetch) 발생한다.
    • A와 B 컴포넌트가 최신 데이터를 반영하며 리렌더링된다.

TanStack Query 생명 주기

출처: 우아한테크세미나

A->B->C 시나리오의 흐름을 위 생명주기로 정리해 볼 수 있다.

상태별 개념

상태설명
fresh데이터가 최신 상태로, 새로 패칭할 필요가 없는 상태. staleTime 내에서 캐시된 데이터 사용 가능.
stale데이터가 최신 상태가 아니어서 새로 패칭해야 하는 상태. staleTime을 초과한 데이터.
active현재 컴포넌트에서 사용 중인 쿼리 상태. 컴포넌트가 마운트되어 쿼리를 사용하고 있을 때.
inactive더 이상 사용되지 않는 쿼리 상태. 컴포넌트가 언마운트되었거나 해당 쿼리를 더 이상 사용하지 않을 때.
deleted캐시에서 제거된 쿼리 상태. gcTime이 지나서 삭제된 데이터.
fetching데이터를 서버에서 가져오고 있는 상태. 백그라운드에서 데이터를 요청하는 중.

🔎 내부 코드 살펴보기

이제 실제 TanStack Query의 core 코드를 보며 어떻게 동작하는지 알아보자!

우리는 TanStack Query를 사용하기 위해 QueryClientProvider를 최상단에서 감싸주고 QueryClient 인스턴스를 client props로 넣어 애플리케이션에 연결해준다.
QueryClientProvider는 내부적으로 Context API를 사용하므로 모든 자식 컴포넌트들은 QueryClient에 접근이 가능해진다.

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

const queryClient = new QueryClient()

function App() {
  return <QueryClientProvider client={queryClient}>...</QueryClientProvider>
}

QueryClient

QueryClient 클래스 내부에서 QueryCache를 사용하는 것을 확인할 수 있다.

export class QueryClient {
  #queryCache: QueryCache
  ...

  constructor(config: QueryClientConfig = {}) {
    this.#queryCache = config.queryCache || new QueryCache()
    ...
  }
  ...
}

QueryCache

다음으로 QueryCache는 내부적으로 #queries라는 Map<string, Query> 형태로 쿼리 데이터를 저장한다.

export class QueryCache extends Subscribable {
  #queries: QueryStore

  constructor(public config: QueryCacheConfig = {}) {
    super()
    this.#queries = new Map<string, Query>()
  }
  ...
}

내부 메서드 build는 주어진 QueryKey와 옵션을 바탕으로 기존에 존재하는 쿼리를 찾아 반환한다. 만약 해당 쿼리가 존재하지 않으면, 새로운 Query 객체를 생성하여 캐시에 추가한다.

export class QueryCache extends Subscribable {
  ...
  
  build(
    client: QueryClient,
    options: WithRequired<
      QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
      'queryKey'
    >,
    state?: QueryState<TData, TError>,
  ): Query<TQueryFnData, TError, TData, TQueryKey> {
    const queryKey = options.queryKey
    const queryHash =
      options.queryHash ?? hashQueryKeyByOptions(queryKey, options)
    let query = this.get<TQueryFnData, TError, TData, TQueryKey>(queryHash)

    // 새로운 Query 객체 생성
    if (!query) {
      query = new Query({
        client,
        queryKey,
        queryHash,
        options: client.defaultQueryOptions(options),
        state,
        defaultOptions: client.getQueryDefaults(queryKey),
      })
      this.add(query)
    }

    return query
  }
}

get 메서드는 특정 쿼리 해시를 통해 캐시된 쿼리를 검색하는 역할을 한다. 만약 해당 쿼리가 캐시에 존재하지 않으면 undefined를 반환하여, 호출하는 쪽에서 쿼리가 없음을 처리할 수 있도록 한다.

export class QueryCache extends Subscribable<QueryCacheListener> {
  ...
  
  get(
    queryHash: string,
  ): Query<TQueryFnData, TError, TData, TQueryKey> | undefined {
    return this.#queries.get(queryHash) as
      | Query<TQueryFnData, TError, TData, TQueryKey>
      | undefined
  }
}

Query

다음으로 Query를 살펴보면 쿼리를 구별하는 고유키인 queryKey, staleTime 등의 옵션을 저장한 options, 현재 상태를 나타내는 state를 가지는 것을 확인할 수 있다.

export class Query extends Removable {
  queryKey: TQueryKey
  queryHash: string
  options!: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
  state: QueryState<TData, TError>

  #initialState: QueryState<TData, TError>
  #revertState?: QueryState<TData, TError>
  #cache: QueryCache
  #client: QueryClient
  #retryer?: Retryer<TData>
  observers: Array<QueryObserver<any, any, any, any, any>>
  #defaultOptions?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
  #abortSignalConsumed: boolean
  ...
}

이때 쿼리의 속성 중 observers가 중요한데, 바로 이 QueryObserver를 통해서 쿼리에 관심을 가지는 관찰자들에게 변경 사항을 알릴 수 있는 것이다!

QueryObserver

export class QueryObserver extends Subscribable {
  ...
  
  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()
    }
  }

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

QueryObserver에 구독 요청이 들어오게 되면 현재 쿼리(#currentQuery)의 #observers에 현재 옵저버를 추가하게 된다.

export class Query extends Removable {
  ...
  
  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의 state가 업데이트되는 경우

QueryQueryState가 업데이트 되는 경우에는 QueryObserver에 알리기 위해 Dispatch, Reducer, Action 개념을 활용한다. 액션에 타입에 따라 QueryState를 업데이트하고 Query 인스턴스를 구독하는 QueryObserver 인스턴스의 메서드를 실행한다 (onQueryUpdate).

export class Query extends Removable {
  constructor() {}

  #dispatch(action: Action<TData, TError>): void {
    const reducer = (
      state: QueryState<TData, TError>,
    ): QueryState<TData, TError> => {
      switch (action.type) {
        case 'failed': return {...}
        case 'pause': return {...}
        case 'continue': return {...}
        case 'fetch': return {...}
        case 'success': return {...}
        case 'error': return {...}
        case 'invalidate': return {...}
        case 'setState': return {...}
      }
    }

    this.state = reducer(this.state)

    notifyManager.batch(() => {
      this.observers.forEach((observer) => {
        observer.onQueryUpdate()
      })

      this.#cache.notify({ query: this, type: 'updated', action })
    })
  }
}

QueryObserveronQueryUpdate에서 호출하는 updateResult에서는 Query의 상태의 전과 후를 비교해 알림이 필요한 리스너에 대해서 최신화된 Query의 상태를 전달한다.

export class QueryObserver extends Subscribable {
  onQueryUpdate(): void {
    this.updateResult()

    if (this.hasListeners()) {
      this.#updateTimers()
    }
  }
  
  ...
  
  updateResult(notifyOptions?: NotifyOptions): void {
    const prevResult = this.#currentResult as
      | QueryObserverResult<TData, TError>
      | undefined

    const nextResult = this.createResult(this.#currentQuery, this.options)

    ...
               
    // 전후 상태 비교
    if (shallowEqualObjects(nextResult, prevResult)) {
      return
    }

    this.#currentResult = nextResult
    
    ...

    if (notifyOptions?.listeners !== false && shouldNotifyListeners()) {
      defaultNotifyOptions.listeners = true
    }

    this.#notify({ ...defaultNotifyOptions, ...notifyOptions })
  }
  
  ...
  
  // Query 상태 업데이트 시 필요한 리스너에 대해서 호출
  #notify(notifyOptions: NotifyOptions): void {
    notifyManager.batch(() => {
      // First, trigger the listeners
      if (notifyOptions.listeners) {
        this.listeners.forEach((listener) => {
          listener(this.#currentResult)
        })
      }
      ...
    })
  }
}

여기까지 알아본 흐름을 다이어그램으로 나타내면 아래와 같다.

출처: Inside React Query

그러면 ObserverQueryState 업데이트 시 호출하는 listner의 정체는 뭘까? 이를 위해서는 useQuery 훅의 실행 흐름을 살펴보아야 한다.

useQuery

export function useQuery(options: UseQueryOptions, queryClient?: QueryClient) {
  return useBaseQuery(options, QueryObserver, queryClient)
}

useQuery는 전달받은 인자와 QueryObserver 클래스로 useBaseQuery훅을 호출한다.

export function useBaseQuery(
  options: UseBaseQueryOptions,
  Observer: typeof QueryObserver,
  queryClient?: QueryClient,
): QueryObserverResult<TData, TError> {

  const client = useQueryClient(queryClient)
  const isRestoring = useIsRestoring()
  const errorResetBoundary = useQueryErrorResetBoundary()
  const defaultedOptions = client.defaultQueryOptions(options)

  // Observer 생성
  const [observer] = React.useState(
    () =>
      new Observer<TQueryFnData, TError, TData, TQueryData, TQueryKey>(
        client,
        defaultedOptions,
      ),
  )

  // 초기 결과 설정
  const result = observer.getOptimisticResult(defaultedOptions)

  const shouldSubscribe = !isRestoring && options.subscribed !== false
  React.useSyncExternalStore(
    React.useCallback(
      (onStoreChange) => {
        const unsubscribe = shouldSubscribe
          ? observer.subscribe(notifyManager.batchCalls(onStoreChange))
          : noop

        // 결과를 업데이트
        observer.updateResult()

        return unsubscribe
      },
      [observer, shouldSubscribe],
    ),
    () => observer.getCurrentResult(),
    () => observer.getCurrentResult(),
  )

  return !defaultedOptions.notifyOnChangeProps
    ? observer.trackResult(result)
    : result
}

useBaseQuery는 내부적으로 QueryObserver 객체를 생성하여 React 컴포넌트와 연결한다.

useSyncExternalStore는 observer의 상태 변화를 리액트 컴포넌트에 동기화하는데, 이때 첫 번째 인자인 콜백 파라미터에 리액트 컴포넌트를 렌더링 시키는 메서드 onStoreChange가 들어가게 된다.

export class Subscribable<TListener extends Function> {
  protected listeners = new Set<TListener>()

  constructor() {
    this.subscribe = this.subscribe.bind(this)
  }

  subscribe(listener: TListener): () => void {
    this.listeners.add(listener)
    ...
  }
}

observer.subscribe는 QueryObserver의 부모 클래스인 Subscribable에 구현된 것으로, Set 자료형으로 listner를 저장하고 있음을 알 수 있다!

다시 한번 QueryObserver의 코드를 살펴보자.

export class QueryObserver extends Subscribable {
  onQueryUpdate(): void {
    this.updateResult()

    if (this.hasListeners()) {
      this.#updateTimers()
    }
  }
  
  ...
  
  updateResult(notifyOptions?: NotifyOptions): void {
    const prevResult = this.#currentResult as
      | QueryObserverResult<TData, TError>
      | undefined

    const nextResult = this.createResult(this.#currentQuery, this.options)

    ...

    this.#notify({ ...defaultNotifyOptions, ...notifyOptions })
  }
  
  ...
  
  // Query 상태 업데이트 시 필요한 리스너에 대해서 호출
  #notify(notifyOptions: NotifyOptions): void {
    notifyManager.batch(() => {
      // First, trigger the listeners
      if (notifyOptions.listeners) {
        this.listeners.forEach((listener) => {
          listener(this.#currentResult)
        })
      }
      ...
    })
  }
}

observer.updateResult는 상태가 업데이트될 때 최신 데이터를 반영하도록 observer를 갱신하게 된다. 이때 내부에서 호출되는 #notify를 통해 등록되어있던 listner가 실행되는데 이것이 바로 연결된 리액트 컴포넌트들을 리렌더링하는 메서드인 것이다!

결과적으로, useQuery를 사용하는 컴포넌트는 하나의 QueryObserver와 연결되어 상태 변화에 따라 자동으로 리렌더링된다는 것을 알 수 있었다.

위 과정을 다이어그램으로 표현하면 아래와 같다.

✅ 동작 과정 정리

이제 전체 흐름을 정리해보자!

  1. 리액트 컴포넌트가 마운트되면서 useQuery 훅을 호출한다.
  2. useBaseOuery 훅으로 옵션과 QueryObserver 클래스가 전달된다.
  3. useBaseOuery 훅 실행 과정동안 리액트 컴포넌트와 QueryObserver 인스턴스가 연결된다.
  4. QueryObserverQueryCache에 있는 Query를 구독한다.
  5. Query의 상태가 업데이트되면 이를 관찰한 QueryObservernotify 메서드를 실행해 리액트 컴포넌트를 리렌더링한다.

마치며 💭

이번 글에서는 SWR의 개념을 바탕으로 TanStack Query가 어떻게 캐싱을 수행하는지 살펴보았다. 단순히 캐싱 기능을 활용하는 것을 넘어, 내부 코드 분석을 통해 동작 원리를 깊이 이해하는 데 초점을 맞췄다.

분량 상 더 알아보지는 못했지만 QueryObserver 내부에는 isStale,onSubscribe과 같이 stale 상태를 판단하고 리패칭하는 과정도 살펴볼 수 있어 흥미로웠다.

앞으로 이 내용을 바탕으로, 실제 프로젝트에서 TanStack Query를 어떻게 효율적으로 적용할지 고민해볼 수 있을 것이다. 🚀

참고

https://tanstack.com/query/latest/docs/framework/react/overview
https://www.timegambit.com/blog/digging/react-query/01
https://fe-developers.kakaoent.com/2023/230720-react-query/
https://velog.io/@hyunjine/Inside-React-Query
https://mingos-habitat.tistory.com/86

profile
내일은 더 성장하기

0개의 댓글

관련 채용 정보