useQuery를 호출하면 내부에서 무슨 일이 일어날까

2025년 06월 14일

const { data } = useQuery({ queryKey: ['scores'], queryFn: fetchScores });

이 한 줄이 실행되면 내부에서 어떤 일이 일어날까요?

"캐싱이 된다", "useEffect로 fetch한다" 정도로 알고 있었는데, 실제 소스코드를 열어보니 생각보다 다른 구조였습니다. 이 글에서는 useQuery 한 줄이 fetch → 캐싱 → 리렌더로 이어지는 전체 과정을 소스코드 기반으로 따라가봅니다.


전체 구조

코드를 보기 전에, useQuery 내부에서 움직이는 객체들의 관계를 먼저 설명하겠습니다.

QueryClient (중앙 관리자 — 외부 API 제공)
  └─ QueryCache (저장소 — queryKey를 해시해서 Map으로 관리)
       └─ Query (개별 쿼리 객체 — queryKey마다 하나씩)
            ├─ state.data = ...                ← 실제 데이터는 여기
            ├─ state.status = 'success'
            ├─ fetch() 실행 가능
            └─ observers = [Observer A, ...]   ← 구독자 목록
                                ↑
                          QueryObserver (useQuery와 Query 사이의 다리)
                                │
                           컴포넌트 리렌더 트리거
객체역할
QueryClient전체를 관리. invalidateQueries 같은 외부 API 제공
QueryCacheQuery들을 queryKey별로 저장하고 찾아주는 저장소
Query하나의 queryKey에 대한 데이터 보관 + fetch 실행. queryFn이 반환한 값이 query.state.data에 저장됨
QueryObserverQuery를 구독하고, 데이터가 바뀌면 컴포넌트에 알림

QueryClientProvider로 앱 전체가 같은 QueryClient를 공유하기 때문에, 어디서 useQuery를 호출하든 같은 QueryCache에 접근합니다.

이 구조를 기억하면서 소스코드를 따라가 보겠습니다.


useQuery 내부로 들어가보면

// useQuery.ts
export function useQuery(options, queryClient) {
  return useBaseQuery(options, QueryObserver, queryClient)
}

useQueryuseBaseQuery에 위임만 합니다. 실제 로직은 전부 useBaseQuery 안에 있습니다.


Observer 생성

// useBaseQuery.ts 91번줄
const [observer] = React.useState(
  () => new Observer(client, defaultedOptions)
);

useState함수를 넣으면 컴포넌트 마운트 시 딱 1번만 실행됩니다. 이때 QueryObserver 객체가 만들어집니다.

new QueryObserver(client, options)가 호출되면 생성자 내부에서 이런 일이 벌어집니다:

// QueryObserver constructor
constructor(client, options) {
  this.#client = client
  this.setOptions(options)  // → 내부에서 #updateQuery() 호출
}
 
// #updateQuery
#updateQuery(): void {
  const query = this.#client.getQueryCache().build(this.#client, this.options)
  //            ↑ QueryCache에게 Query를 달라고 요청 (없으면 QueryCache가 새로 만듦)
  this.#currentQuery = query
  //                   ↑ 받아온 Query의 참조를 저장
}

Observer가 직접 데이터를 갖고 있는 게 아닙니다. getQueryCache().build()로 QueryCache에서 Query를 가져온 뒤, this.#currentQuery참조만 저장합니다. 실제 데이터는 query.state.data에 있습니다.

QueryCache (저장소)
  └─ Query
       ↑
       │ this.#currentQuery = query (참조)
       │
  QueryObserver ← 컴포넌트와 연결

useSyncExternalStore로 캐시 구독

// useBaseQuery.ts 103번줄
React.useSyncExternalStore(
  React.useCallback(
    (onStoreChange) => {
      const unsubscribe = observer.subscribe(
        notifyManager.batchCalls(onStoreChange)
      );
      observer.updateResult();
      return unsubscribe;
    },
    [observer],
  ),
  () => observer.getCurrentResult(),
  () => observer.getCurrentResult(),
);

useQueryuseEffect로 fetch를 한다고 생각하기 쉬운데, 실제로는 useSyncExternalStore를 사용합니다.

useSyncExternalStore는 React 18에서 추가된 Hook으로, React 외부에 있는 저장소를 구독할 때 사용합니다. QueryCache는 useState로 만든 값이 아니라 new QueryClient()로 생성된 일반 JavaScript 객체입니다. React는 이 객체의 변경을 감지할 수 없기 때문에, useSyncExternalStore로 연결해주는 겁니다.

첫 번째 인자 (subscribe) — React가 onStoreChange라는 콜백을 넘겨줍니다. Query의 데이터(fetch 결과)가 바뀌었을 때 이 함수를 호출하면 React가 리렌더를 시작합니다. 이 콜백을 observer.subscribe()에 전달해서 Observer가 변경 알림을 받을 수 있도록 등록합니다.

두 번째, 세 번째 인자 (getSnapshot) — React가 리렌더할 때 현재 값을 읽는 함수입니다. observer.getCurrentResult()가 data, isLoading, isError 등을 포함한 결과 객체를 반환하고, 이게 우리가 useQuery에서 받는 값입니다.


subscribe가 fetch를 트리거하는 과정

위에서 observer.subscribe(onStoreChange)를 호출한다고 했는데, 그 내부에서 뭐가 일어날까요? subscribe()는 QueryObserver의 부모 클래스 Subscribable에 정의되어 있습니다:

// Subscribable — 단순화
class Subscribable {
  listeners = new Set()
 
  subscribe(listener) {
    this.listeners.add(listener)   // ① onStoreChange를 저장
    this.onSubscribe()             // ② onSubscribe() 호출
    return () => {                 // ③ 언마운트 시 정리할 함수 리턴
      this.listeners.delete(listener)
      this.onUnsubscribe()
    }
  }
}

①에서 onStoreChange를 저장해두고, ②에서 onSubscribe()를 호출합니다. 여기서 fetch 여부가 결정됩니다:

// QueryObserver.onSubscribe()
protected onSubscribe(): void {
  if (this.listeners.size === 1) {
    this.#currentQuery.addObserver(this)    // Query에 나를 등록
 
    if (shouldFetchOnMount(this.#currentQuery, this.options)) {
      this.#executeFetch()                   // 캐시 없거나 stale → fetch!
    } else {
      this.updateResult()                    // 캐시 있고 fresh → fetch 안 함
    }
  }
}

캐시가 없으면 executeFetch()로 fetch를 시작하고, 캐시가 있고 fresh이면 updateResult()로 캐시 데이터를 바로 반환합니다. 처음 접속할 때 로딩이 보이고 두 번째부터 안 보이는 건 이 분기 때문입니다.


fetch 완료와 리렌더

fetch가 완료되면 Query가 업데이트되고, Query는 등록된 Observer에게 알립니다. Observer는 앞에서 ①에서 저장해뒀던 onStoreChange()를 호출하고, React가 리렌더를 시작합니다. 리렌더 시 getSnapshot(observer.getCurrentResult())으로 최신 값을 읽어 컴포넌트에 전달합니다.

마운트 시:
  useSyncExternalStore 실행
    → observer.subscribe(onStoreChange)
    → onStoreChange를 listeners에 저장
    → onSubscribe() → Query에 Observer 등록 + fetch 여부 판단
    → 캐시 없음 → fetch 시작

fetch 완료 시:
  Query 데이터 업데이트
    → Observer에게 알림 → 저장해뒀던 onStoreChange() 호출
    → React 리렌더 → getCurrentResult()로 새 데이터 읽음
    → 화면 갱신

같은 queryKey를 쓰는 컴포넌트가 2개라면?

function ScoreList() {
  const { data } = useQuery({ queryKey: ['scores'], queryFn: fetchScores });
}
 
function ScoreSummary() {
  const { data } = useQuery({ queryKey: ['scores'], queryFn: fetchScores });
}

이 경우 내부 구조는 이렇게 됩니다:

QueryCache
  └─ Query ['scores']     ← 1개 (데이터 보관)
       ↑           ↑
  Observer A    Observer B    ← 2개 (각 컴포넌트마다 하나)
       │              │
  ScoreList    ScoreSummary
  • Query는 1개 — 데이터를 실제로 갖고 있고, fetch를 실행하는 주체
  • Observer는 2개 — 각 컴포넌트가 Query를 구독하는 연결 지점

ScoreList가 먼저 마운트돼서 fetch를 시작하면, ScoreSummary가 마운트될 때는 이미 캐시에 데이터가 있으니 fetch를 건너뜁니다. fetch 1번으로 두 컴포넌트 모두 데이터를 받습니다.

두 컴포넌트가 같은 캐시를 공유할 수 있는 건 QueryClientProvider 덕분입니다:

<QueryClientProvider client={queryClient}>
  <App />
</QueryClientProvider>

React Context를 통해 앱 전체가 같은 QueryCache를 공유하기 때문에, 어디서 useQuery를 호출하든 같은 캐시에 접근합니다.


캐시 데이터의 상태 변화

앞에서 onSubscribe()shouldFetchOnMount()가 fetch 여부를 결정한다고 했습니다. 이 판단의 기준이 되는 게 캐시 데이터의 상태입니다.

fetch 완료 → fresh → stale → inactive → deleted

fresh → stalestaleTime이 결정합니다. onSubscribe()에서 shouldFetchOnMount()가 false를 반환하는 건 데이터가 fresh일 때입니다. staleTime 이내에는 어떤 컴포넌트가 마운트돼도 fetch하지 않고 캐시만 씁니다. staleTime이 지나 stale 상태가 되면, 캐시를 먼저 보여주되 백그라운드에서 refetch합니다. TanStack Query에서는 이를 stale-while-revalidate 전략이라고 합니다. 기본값이 0이라서 받자마자 stale이 되지만, 캐싱 자체는 동일하게 동작합니다.

stale → inactive — 앞에서 본 Subscribable의 unsubscribe가 여기서 쓰입니다. 컴포넌트가 언마운트되면 listeners.delete(listener)onUnsubscribe()destroy()가 실행되고, 구독자가 0명이 되면 Query는 inactive 상태가 됩니다.

inactive → deletedgcTime(기본값 5분)이 지나면 QueryCache에서 삭제됩니다. 다음에 같은 queryKey로 요청하면 onSubscribe()shouldFetchOnMount() → true → 처음부터 fetch합니다.


전체 흐름 정리

useQuery({ queryKey: ['scores'], queryFn: fetchScores }) 한 줄이 실행되면:

컴포넌트 마운트
  │
  ├─ useState → QueryObserver 생성
  │
  ├─ useSyncExternalStore
  │    └─ observer.subscribe() 호출
  │         └─ QueryCache에서 ['scores'] 검색
  │              ├─ 없음 → Query 생성 + fetch 실행
  │              ├─ 있고 fresh → 캐시 반환 (fetch 안 함)
  │              └─ 있고 stale → 캐시 반환 + 백그라운드 fetch
  │
  ├─ fetch 완료
  │    └─ QueryCache 업데이트
  │         └─ 모든 Observer에게 알림
  │              └─ onStoreChange() → React 리렌더
  │
  └─ 컴포넌트 언마운트
       └─ unsubscribe() → 구독 해제
            └─ 구독자 0명 → inactive → gcTime 후 삭제

useQuery 한 줄이 단순한 fetch가 아니라, Observer 패턴 + 외부 스토어 구독 + 캐시 계층으로 이루어진 시스템이라는 걸 소스코드를 통해 확인했습니다.