React Query 데이터 플로우: useQuery 호출부터 결과 반환까지

React Query 데이터 플로우: useQuery 호출부터 결과 반환까지

React Query를 쓰면서 항상 궁금했던 게 있었다. useQuery를 호출하면 마법처럼 데이터가 나타나는데, 내부에서는 도대체 뭔 일이 벌어지는 걸까? 오늘은 useQuery 호출부터 최종 결과가 컴포넌트에 반환되기까지의 전체 과정을 선형적으로 따라가보려고 한다.

1단계: useQuery 호출

사용자가 useQuery({queryKey, queryFn})를 호출하면, 내부적으로 useBaseQuery에 작업을 위임한다.

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

2단계: 준비 단계 - QueryClient와 옵션 정리

useBaseQuery에서는 먼저 필요한 걸 준비한다.

 const client = useQueryClient(queryClient)
 const defaultedOptions = client.defaultQueryOptions(options)

QueryClient를 가져오고, 내가 넘긴 옵션들을 정규화한다. 이 단계에서 기본값들이 적용된다.

3단계: QueryObserver 생성

이게 핵심이다. React의 useState를 써서 QueryObserver를 만든다.

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

이 Observer가 바로 쿼리 상태를 관리하는 주인공이다. 한 번 만들어지면 컴포넌트 생명주기 동안 계속 유지된다.

4단계: 초기 결과 생성

getOptimisticResult 호출 시점

Observer가 생성된 직후, useBaseQuery에서 getOptimisticResult를 호출한다.

const result = observer.getOptimisticResult(defaultedOptions)

초기 결과를 미리 준비해야하기 때문에 이 호출은 useSyncExternalStore 이전에 실행되어야 한다.

QueryCache에서 Query 찾기 / 만들기

getOptimisticResult 메서드 내부에서 가장 먼저 QueryCache.build()를 호출하여 Query 인스턴스를 가져오거나 생성한다.

const query = this.#client.getQueryCache().build(this.#client, options)

QueryCache.build()의 핵심 로직:

  • 쿼리 해시로 캐시에서 기존 Query 찾기
  • 없으면 새로 만들어서 캐시에 추가
  • 있으면 그냥 기존 걸 반환
build<
    TQueryFnData = unknown,
    TError = DefaultError,
    TData = TQueryFnData,
    TQueryKey extends QueryKey = QueryKey,
  >(
    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)

    if (!query) {
      query = new Query({
        client,
        queryKey,
        queryHash,
        options: client.defaultQueryOptions(options),
        state,
        defaultOptions: client.getQueryDefaults(queryKey),
      })
      this.add(query)
    }

    return query
  }

createResult() 로 결과 객체 생성

Query 인스턴스를 확보한 후, observer는 createResult(query, options)를 호출하여 실제 결과 객체를 생성한다.

const result = this.createResult(query, options)

createResult 메서드의 핵심 처리:

  • Query의 현재 상태 가져오기
  • select 함수가 있으면 데이터 변환 적용
  • placeholderData가 있고 pending 상태면 임시 데이터 사용
const { state } = query
let newState = { ...state }
let isPlaceholderData = false
let data: TData | undefined
...

Observer 현재 결과 업데이트

결과 객체가 생성된 후, shouldAssignObserverCurrentProperties 조건을 확인하여 Observer의 현재 결과를 업데이트할지 결정한다.

할당이 필요한 경우:

  • QueryKey가 바뀌어서 새로운 결과를 읽을 때
  • keepPreviousData 설정 때문에 결과가 안 바뀔 때

이 과정에서 currentResult, currentResultOptions, currentResultState가 모두 업데이트된다.

if (shouldAssignObserverCurrentProperties(this, result)) {
  this.#currentResult = result
  this.#currentResultOptions = this.options
  this.#currentResultState = this.#currentQuery.state
}

모든 처리가 완료된 후 초기 결과 객체가 반환된다. 이 객체에는 다음이 포함된다:

  • data: 실제 데이터 또는 placeholderData
  • status: 'pending', 'success', 'error' 중 하나
  • isLoading, isPending, isSuccess 등의 상태 플래그들
  • refetch, fetchNextPage 등의 액션 함수들

5단계: React와 연결하기

이제 React의 상태 시스템과 연결해야 한다. useSyncExternalStore가 등장한다:

useSyncExternalStore의 역할

React 18에서 도입된 이 훅은 외부 상태 저장소(QueryObserver)와 React 컴포넌트를 동기화하는 표준 방법이다:

  • 구독: Observer의 상태 변경을 감지
  • 스냅샷: 현재 결과 반환
  • 동기화: 상태 변경시 컴포넌트 리렌더링
React.useSyncExternalStore(
  React.useCallback(
    (onStoreChange) => {
      const unsubscribe = shouldSubscribe
        ? observer.subscribe(notifyManager.batchCalls(onStoreChange))
        : noop

      // Update result to make sure we did not miss any query updates
      // between creating the observer and subscribing to it.
      observer.updateResult()

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

6단계: Observer 구독과 데이터 페칭

구독 함수의 핵심 동작

구독 함수가 실행되면 다음과 같은 작업들이 수행된다:

  • 조건 확인: 구독이 필요한지 체크 (!isRestoring && options.subscribed !== false)
  • 배치 처리: batchCalls를 통해 여러 컴포넌트의 동시 업데이트를 효율적으로 처리
  • 누락 방지: observer.updateResult()를 호출하여 Observer 생성과 구독 사이에 발생할 수 있는 업데이트 누락을 방지

첫 번째 구독자의 특별한 역할

observer.subscribe()가 호출될 때, 첫 번째 컴포넌트가 구독하는 경우에만 특별한 초기화 작업이 수행된다:

이 onSubscribe() 메서드 내부에서 실제로 일어나는 일은:

  1. 첫 번째 리스너 확인: this.listeners.size === 1 조건으로 첫 번째 구독자인지 확인
  2. Query에 Observer 등록: this.#currentQuery.addObserver(this) 호출
  3. 페칭 조건 확인 및 실행: shouldFetchOnMount() 확인 후 필요시 this.#executeFetch() 호출
subscribe(listener: TListener): () => void {
  this.listeners.add(listener)

  this.onSubscribe()

  return () => {
    this.listeners.delete(listener)
    this.onUnsubscribe()
  }
  
//

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()
  }
}

왜 첫 번째 컴포넌트일 때만?: 여러 컴포넌트가 같은 쿼리를 사용할 수 있지만, Query에 Observer를 등록하고 데이터 페칭을 시작하는 초기화 작업은 한 번만 수행하면 되기 때문이다!

리스너 관리

매번 observer.subscribe()가 호출될 때마다 리스너가 추가 된다.

각 컴포넌트의 리렌더링 콜백이 Observer에 등록되는 거다. 그래서 상태가 바뀌면 관련된 모든 컴포넌트가 동시에 업데이트된다.

#notify(notifyOptions: { listeners: boolean }): void {
  notifyManager.batch(() => {
    // First, trigger the listeners
    if (notifyOptions.listeners) {
      this.listeners.forEach((listener) => {
        listener(this.#currentResult)
      })
    }

    // Then the cache listeners
    this.#client.getQueryCache().notify({
      query: this.#currentQuery,
      type: 'observerResultsUpdated',
    })
  })
}

Query에는 Observer 등록

this.#currentQuery.addObserver(this)를 통해 현재 Query 인스턴스에 이 Observer를 등록한다.

addObserver 메서드의 구현에서는 다음과 같은 작업들이 수행된다:

  1. 중복 등록 방지: 같은 Observer가 중복 등록되지 않도록 체크
  2. Observer 배열에 추가: Observer를 등록
  3. 가비지 컬렉션 방지: Query가 가비지 컬렉션되지 않도록 보호
  4. 캐시 알림: 캐시에 Observer가 추가되었음을 알림

페칭 조건 확인

shouldFetchOnMount()에서 진짜 데이터를 가져와야 하는지 확인한다:

  • enabled가 false가 아닌가?
  • 데이터가 undefined인가?
  • 에러 상태에서 재시도 해야 하나?
  • 기존 데이터가 있어도 다시 가져와야 하나? (refetchOnMount)

실제 데이터 페칭 실행

조건을 만족하면 this.#executeFetch()가 호출된다:

#executeFetch 메서드의 구현에서는 다음과 같은 단계를 거친다:

  1. 최신 Query 참조 확보: this.#updateQuery()를 호출하여 Query가 제거되었을 가능성에 대비
  2. 실제 fetch 호출: this.#currentQuery.fetch()를 호출하여 네트워크 요청을 시작
  3. 에러 처리: throwOnError 옵션이 false면 promise.catch(noop)으로 에러를 무시
#executeFetch(
  fetchOptions?: Omit<ObserverFetchOptions, 'initialPromise'>,
): Promise<TQueryData | undefined> {
  // Make sure we reference the latest query as the current one might have been removed
  this.#updateQuery()

  // Fetch
  let promise: Promise<TQueryData | undefined> = this.#currentQuery.fetch(
    this.options as QueryOptions<TQueryFnData, TError, TQueryData, TQueryKey>,
    fetchOptions,
  )

  if (!fetchOptions?.throwOnError) {
    promise = promise.catch(noop)
  }

  return promise
}

Query.fetch()에서는:

  • 중복 fetch 방지 (이미 fetching 중이면 기존 promise 반환)
  • 옵션 업데이트
  • queryFn 확보 (없으면 다른 observer에서 찾기)

7단계: 최종 결과 만들기

Observer의 createResult() 메서드가 최종 결과 객체를 만든다. 여기에 dataisLoadingerror 등 모든 상태가 들어간다.

8단계: 렌더링 최적화

마지막으로 useBaseQuery는 렌더링 최적화를 위해 trackResult()를 적용하거나 결과를 그대로 반환한다.

// Handle result property usage tracking
return !defaultedOptions.notifyOnChangeProps
  ? observer.trackResult(result)
  : result

trackResult()는 실제로 변경된 프로퍼티만 추적해서 불필요한 리렌더링을 방지한다. 똑똑하다!

마무리

이렇게 복잡한 과정을 거쳐서 우리가 쓰는 useQuery가 동작한다. 생각보다 훨씬 정교하게 설계되어 있고, 캐싱부터 렌더링 최적화까지 모든 게 다 계산되어 있다는 게 인상적이었다.

이런 내부 동작을 알고 나니 왜 이렇게 안정적이고 빠른지 이해를 할 수 있었다!

Reference

query/packages/react-query at main · TanStack/query

🤖 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

https://github.com/TanStack/query/tree/main/packages/react-query
query/packages/react-query at main · TanStack/query

TanStack/query | DeepWiki

This document provides a high-level introduction to TanStack Query, covering the monorepo structure, core architecture, and multi-framework data fetching capabilities. For detailed information about s

https://deepwiki.com/TanStack/query/1-overview