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

TanStack Query 내부구조 (with 코드) + 가이드

by 김홍중 2025. 5. 6.


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

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

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

 

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

 

1. 주요 구성 요소

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

  • Query Core (프레임워크 독립적인 핵심 로직)
    • QueryClient: React Query의 중심 객체로, 기본 설정(defaultOptions)을 관리하고 캐시와 쿼리를 조정합니다.
    • QueryCache: 모든 쿼리(Query)를 저장하는 캐시입니다. 쿼리 데이터를 보관하고 관리합니다.
    • Query: 개별 쿼리를 나타내며, 데이터, 상태등을 포함합니다. 쿼리 실행, 재시도도 포함합니다.
    • Query Observer: 쿼리의 상태를 관찰하고, 변경 사항을 구독한 컴포넌트에 알립니다.
    • Persister: 캐시를 외부 저장소(ex. LocalStorage)와 동기화합니다. 

2. 데이터 흐름

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

3. 주요 특징

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

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

  • 컴포넌트 마운트 (React → useQuery):
    • React 컴포넌트가 처음 마운트될 때 useQuery 훅을 호출합니다.
    • useQuery는 특정 쿼리 키(ex. ['posts'])를 기반으로 쿼리를 생성합니다.
  • Query Observer 생성 (useQuery → Query Observer):
    • useQuery Query 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)
}

 

라이브러리 비교

 

https://tanstack.com/query/latest/docs/framework/react/comparison?from=reactQueryV3

https://swr.vercel.app/ko/docs/getting-started

https://tanstack.com/query/latest/docs/framework/react/community/tkdodos-blog

 

라이브러리 선택 결론

 

두 개의 라이브러리 모두 서비스에 적용해도 큰 문제는 없을것으로 보이나 비교한 기능을 React Query가 지원한다는점이 매력적이었습니다. 무엇보다도 공식 블로그가 상세하며 다른 기업의 블로그글도 React Query가 많아서 참고하기 좋았습니다. 이러한 이유로 처음 도입하기에 팀원 학습면에서 React Query가 더 나은 선택이라고 생각합니다.

React Query 가이드

useQuery 사용법

- 기본 사용법

 const {
    data: availableDataSourcesData,
    isLoading: isAvailableDataSourcesLoading,
    error: availableDataSourcesError,
    isError: isAvailableDataSourcesError,
  } = useQuery({
    queryKey: dataSourceKeys.available(),
    queryFn: getAvailableDataSources,
    enabled: isDataSourceConnectDrawerVisible,
  });

 

 

useMutation 사용법

- 기본 사용법

const updateMutation = useMutation({
    mutationFn: updateDataSource,
    onSuccess: () => {
      // 목록 갱신
      queryClient.invalidateQueries({ queryKey: dataSourceKeys.all }); // 아래 내용 참고

      // 업데이트된 데이터 소스를 선택된 상태로 유지
      setSelectedDataSources([
        { ...selectedDataSourceInfo, id: selectedDataSourceInfo.id },
      ]);

      // 성공 메시지 표시
      window.alert("데이터 소스 업데이트 성공");
    },
    onError: (error) => {
      console.error("데이터 소스 업데이트 실패:", error);
      window.alert(error);
    },
  });

 

 

캐시 무효화 전략

export const dataSourceKeys = {
  all: ["dataSources"] as const,
  selected: (id: string) => [...dataSourceKeys.all, "selected", id] as const,
  available: () => [...dataSourceKeys.all, "available"] as const,
};

 

queryClient.invalidateQueries({ queryKey: dataSourceKeys.all })

 

쿼리 키 팩토리 패턴

/**
 * 데이터 소스 쿼리 키 팩토리
 *
 * - 다른 key에 all도 포함한 이유
 * 1. 이 한 줄로 모든 데이터소스 관련 쿼리(selected, available 포함)를 무효화
 *    queryClient.invalidateQueries({ queryKey: dataSourceKeys.all });
 * 2. 명시적으로 dataSources 도메인에 속함을 나타내기 위함
 * 3. TanStack Query팀의 쿼리 키 팩토리 패턴 준수(아래 url 참고)
 *
 * 참고(각각 영어, 한글 버전)
 * https://tkdodo.eu/blog/effective-react-query-keys#effective-react-query-keys
 * https://highjoon-dev.vercel.app/blogs/8-effective-react-query-keys
 */
export const dataSourceKeys = {
  all: ["dataSources"] as const,
  selected: (id: string) => [...dataSourceKeys.all, "selected", id] as const,
  available: () => [...dataSourceKeys.all, "available"] as const,
};

 

알게된 팁

- mutate vs mutateAsync

mutateAsync는 Promise를 반환하고 muatate는 반환하지 않습니다. 그래서 mutateAsync를 호출하여 서버 응답 결과를 사용할 수 있습니다.

- isLoading

isLoading은 캐싱된 데이터가 없는 경우만 true입니다. 만약, 이미 캐시한 데이터가 있거나 initialData를 설정한 경우는 false를 반환합니다.

- dev tools

오른쪽 하단에 토글버튼을 말고 커스텀을 한다면 ReactQueryDevtoolsPanel을 사용하면 될것 같습니다.

 

참고

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

 

Inside React Query

Taking a look under the hood of React Query

tkdodo.eu

 

 

댓글