본문 바로가기
실무/데이터 캐싱

TanStack Query 내부구조 (with 코드) + 실무 적용

by 김홍중 2025. 3. 16.

React Query 가이드 문서에서 제공하는 내용을 통해 사용법만 익혀서 프로젝트에 적용할 수도 있습니다. 그런데 내부의 동작을 파악하면 라이브러리를 올바르게 사용하는 데에 도움이 되고 디버깅 시간도 줄일 수 있을것 같아서 파악해보았습니다. 

최대한 올바르게 파악하기 위하여 React Query 블로그에서 제공하는 자료와 github코드를 참고하였습니다.

이러한 원리를 학습하고 이를 사내 프로젝트에 적용한것을 아래에서 예시를 들어 설명하겠습니다.

 

1. 주요 구성 요소

React Query는 크게 Query CoreFramework Specific로 나뉩니다.

  • Query Core (프레임워크 독립적인 핵심 로직)
    • QueryClient: React Query의 중심 객체로, 기본 설정(defaultOptions)을 관리하고 캐시와 쿼리를 조정합니다.
    • QueryCache: 모든 쿼리(Query)를 저장하는 캐시입니다. 쿼리 데이터를 보관하고 관리합니다.
    • Query: 개별 쿼리를 나타내며, 데이터, 상태등을 포함합니다. 쿼리 실행, 재시도도 포함합니다.
    • Query Observer: 쿼리의 상태를 관찰하고, 변경 사항을 구독한 컴포넌트에 알립니다.
    • Persister: 캐시를 외부 저장소(ex. LocalStorage)와 동기화합니다.
  • Framework Specific (React와 같은 프레임워크에 특화된 부분)
    • QueryClient Provider: React 앱에 QueryClient를 제공하는 컨텍스트 Provider입니다. 모든 컴포넌트가 QueryClient에 접근할 수 있게 합니다.
    • useQuery Hook: 컴포넌트에서 쿼리를 생성하고 관리합니다. 

2. 데이터 흐름

  • QueryClientQueryCache를 관리하며, 캐시는 여러 Query를 보관합니다.
  • Query는 데이터를 가져오고, 상태를 관리하며, Query Observer를 통해 변경 사항을 구독 중인 Component에 알립니다.
  • ComponentuseQuery 훅으로 쿼리를 생성하고, Query Observer를 통해 데이터를 구독합니다.
  • QueryCachePersister를 통해 외부 저장소(예: LocalStorage)와 동기화됩니다.

3. 주요 특징

  • Query Observer는 쿼리 상태를 모니터링하고, 컴포넌트에 변경 사항을 알립니다(ex. 데이터 업데이트, 에러).
  • Query는 내부 상태 머신을 통해 중복 요청 방지, 취소, 재시도 로직을 처리합니다.
  • QueryClient Provider는 React 앱 전체에 쿼리 관리 환경을 제공합니다.

  • 컴포넌트 마운트 (React → useQuery):
    • React 컴포넌트가 처음 마운트될 때 useQuery 훅을 호출합니다.
    • useQuery는 특정 쿼리 키(ex. ['posts'])를 기반으로 쿼리를 생성합니다.
  • Query Observer 생성 (useQuery → Query Observer):
    • useQueryQuery Observer를 생성합니다.
    • Query Observer는 쿼리의 상태(데이터, 로딩, 에러 등)를 관찰하는 역할을 합니다.
  • Query 생성 및 구독 (Query Observer → Query):
    • Query Observer는 Query 객체를 생성하거나 기존 Query에 구독(subscribe)합니다.
    • Query는 데이터를 가져오는 로직(ex.API 호출)을 실행하고, 상태를 관리합니다.
  • 데이터 가져오기 (Query → Fetch):
    • Query는 데이터를 가져오기 위해 API 요청합니다.
    • 이 과정에서 캐시가 없거나 오래된 경우 데이터를 새로 가져옵니다.
  • 캐시 업데이트 및 알림 (Query → Query Observer):
    • 데이터 가져오기가 완료되면 Query는 캐시를 업데이트합니다.
    • Query는 변경 사항(새 데이터, 상태 변경 등)을 Query Observer에게 알립니다(notifies).
  • 컴포넌트 리렌더링 (Query Observer → React):
    • Query Observer는 새로운 데이터를 React 컴포넌트에 전달합니다.
    • React 컴포넌트는 이 데이터를 받아 리렌더링(re-render)됩니다.
  • 추가 리렌더링:
    • 이후에도 Query가 상태를 업데이트(ex.데이터 갱신, 에러 발생)하면 Query Observer를 통해 컴포넌트에 알리고, 컴포넌트는 다시 리렌더링됩니다.

코드를 통해 좀더 살펴보겠습니다.

QueryClient

https://github.com/TanStack/query/blob/main/packages/query-core/src/queryClient.ts#L338

export class QueryClient {
  #queryCache: QueryCache
  #mutationCache: MutationCache
  #defaultOptions: DefaultOptions
  #queryDefaults: Map<string, QueryDefaults>
  #mutationDefaults: Map<string, MutationDefaults>
  #mountCount: number
  #unsubscribeFocus?: () => void
  #unsubscribeOnline?: () => void
  
  constructor(config: QueryClientConfig = {}) {
    this.#queryCache = config.queryCache || new QueryCache()
    this.#mutationCache = config.mutationCache || new MutationCache()
    this.#defaultOptions = config.defaultOptions || {}
    this.#queryDefaults = new Map()
    this.#mutationDefaults = new Map()
    this.#mountCount = 0
  }
  
  mount() {}
  unMount() {}
  getQueryData() {}
  setQueryData() {}
  invalidateQueries() {}
  getQueryCache() {}
}

QueryClient는 queryCache 인스턴스를 가지고 queryCache가 제공하는 메소드를 사용합니다. 예를들어 onFocus, onOnline, findAll, get, build, remove, clear의 메소드를 사용합니다.

QueryCache

https://github.com/TanStack/query/blob/main/packages/query-core/src/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>()
  }
  
  build() {}
  add(){}
  remove() {}
  clear() {}
  get() {}
  getAll() {}
  find() {}
  findAll() {}
  notify() {}
  onFocus() {}
  onOnlie() {}
}

queries에서 제공하는 메서드를 이용하여 쿼리 인스턴스를 관리합니다. 메서드의 예시로 has, set, get, delete, values를 이용합니다.

QueryCache의 build 메서드

https://github.com/TanStack/query/blob/main/packages/query-core/src/queryCache.ts#L100-L131

QueryCache
build() {
	const queryKey = options.queryKey
	    const queryHash =
	      options.queryHash ?? hashQueryKeyByOptions(queryKey, options)
	    let query = this.get<TQueryFnData, TError, TData, TQueryKey>(queryHash)
	
	    if (!query) {
	      query = new Query({
	        client,
	        queryKey,
	        queryHash,
	        options: client.defaultQueryOptions(options),
	        state,
	        defaultOptions: client.getQueryDefaults(queryKey),
	      })
	      this.add(query)
	    }
	
	    return query
}
  • 옵션 객체의 쿼리키를 해시

https://github.com/TanStack/query/blob/main/packages/query-core/src/utils.ts#L205-L211

// utils
export function hashQueryKeyByOptions<TQueryKey extends QueryKey = QueryKey>(
  queryKey: TQueryKey,
  options?: Pick<QueryOptions<any, any, any, any>, 'queryKeyHashFn'>,
): string {
  const hashFn = options?.queryKeyHashFn || hashKey
  return hashFn(queryKey)
}

 

사내 프로젝트 적용

이를 사내 프로젝트에 적용해보았습니다.

React Query로 자산정보페이지 LCP 3.5초에서 2.4초로 개선

- 문제 정의

초기 페이지 진입 시 사용자가 필요한 자산정보에 접근하는데 약 3.5초가 소요되었습니다. 이는 컴포넌트가 마운트된 후에야 데이터 로딩이 시작되고, 필수 API 4개(그룹, 랙, 장비, 컬럼 목록)가 순차적으로 로드되는 워터폴 패턴 때문이었습니다. 렌더링 전에 미리 데이터를 로드하지 않았기 때문에 첫 API가 완료된 후에야 다음 API가 호출되어 불필요한 지연이 발생했습니다.

- 해결 방법

단순히 React Router loader만 사용해도 데이터를 미리 로드할 수 있지만, React Query를 함께 사용함으로써 캐싱을 활용하여 페이지 이동 시에도 데이터 재사용 가능하도록 하였습니다.

- 개선 결과

  • 네트워크 타임라인: 2초 → 1초 (50% 개선)
  • LCP(Largest Contentful Paint): 3.5초 → 2.4초 (31.4% 개선)

적용 이전

해당 페이지 접근  
→ 해당 페이지 마운트 & 렌더링  
→ 해당 페이지 내부에서 API 호출 시작  
→ 응답 받은 데이터를 기반으로 최종 렌더링

단점

  • 요청이 계층적으로 순차 발생하면서 시간이 오래 걸림 (Waterfall)
  • 렌더링 지연
[Fetch Waterfall]

/groups 
------------------------------------------------
          /racks
          --------------------------------------
                    /equipments
                    --------------------------------------
                              /columns
                              --------------------------------------

◆ mount
-------------------------------------------------------------> (시간)
Time

 

적용 이후

해당 페이지 접근 전 loader에서 prefetch  
→ 해당 페이지 접근  
→ prefetch한 데이터를 기반으로 즉시 렌더링

장점

  • Suspense로 로딩 경험 향상
  • 페이지 접근 전 미리 불러온 데이터를 캐싱하여 마운트 시점에 바로 렌더링
[Prefetching]

/groups 
------------------------------------------------
/racks 
------------------------------------------------
/equipments 
------------------------------------------------
/columns 
------------------------------------------------

◆ -------------  ◆ -------------  [Suspense]
prefetch            mount
-------------------------------------------------------------> (시간)
Time

 

적용 이전 LCP

 

적용 이후 LCP

 

참고

- https://tkdodo.eu/blog/inside-react-query

댓글