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 제공 |
| QueryCache | Query들을 queryKey별로 저장하고 찾아주는 저장소 |
| Query | 하나의 queryKey에 대한 데이터 보관 + fetch 실행. queryFn이 반환한 값이 query.state.data에 저장됨 |
| QueryObserver | Query를 구독하고, 데이터가 바뀌면 컴포넌트에 알림 |
QueryClientProvider로 앱 전체가 같은 QueryClient를 공유하기 때문에, 어디서 useQuery를 호출하든 같은 QueryCache에 접근합니다.
이 구조를 기억하면서 소스코드를 따라가 보겠습니다.
useQuery 내부로 들어가보면
// useQuery.ts
export function useQuery(options, queryClient) {
return useBaseQuery(options, QueryObserver, queryClient)
}useQuery는 useBaseQuery에 위임만 합니다. 실제 로직은 전부 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(),
);useQuery가 useEffect로 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 → stale — staleTime이 결정합니다. 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 → deleted — gcTime(기본값 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 패턴 + 외부 스토어 구독 + 캐시 계층으로 이루어진 시스템이라는 걸 소스코드를 통해 확인했습니다.