
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() 메서드 내부에서 실제로 일어나는 일은:
- 첫 번째 리스너 확인: this.listeners.size === 1 조건으로 첫 번째 구독자인지 확인
- Query에 Observer 등록: this.#currentQuery.addObserver(this) 호출
- 페칭 조건 확인 및 실행: 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 메서드의 구현에서는 다음과 같은 작업들이 수행된다:
- 중복 등록 방지: 같은 Observer가 중복 등록되지 않도록 체크
- Observer 배열에 추가: Observer를 등록
- 가비지 컬렉션 방지: Query가 가비지 컬렉션되지 않도록 보호
- 캐시 알림: 캐시에 Observer가 추가되었음을 알림
페칭 조건 확인
shouldFetchOnMount()에서 진짜 데이터를 가져와야 하는지 확인한다:
- enabled가 false가 아닌가?
- 데이터가 undefined인가?
- 에러 상태에서 재시도 해야 하나?
- 기존 데이터가 있어도 다시 가져와야 하나? (refetchOnMount)
실제 데이터 페칭 실행
조건을 만족하면 this.#executeFetch()가 호출된다:
#executeFetch 메서드의 구현에서는 다음과 같은 단계를 거친다:
- 최신 Query 참조 확보: this.#updateQuery()를 호출하여 Query가 제거되었을 가능성에 대비
- 실제 fetch 호출: this.#currentQuery.fetch()를 호출하여 네트워크 요청을 시작
- 에러 처리: 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() 메서드가 최종 결과 객체를 만든다. 여기에 data, isLoading, error 등 모든 상태가 들어간다.
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
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