useQuery 는 어떻게 동작할까
useQuery가 캐시와 리렌더를 어떻게 다루는지 내부부터 뜯어보기
알고쓰자 useQuery
최근 프론트엔드 개발을 하다 보면 React와 함께 거의 필수적으로 사용하는 라이브러리, TanStack Query가 있다. 그중에서도 useQuery는 GET 요청을 처리할 때 자주 사용되며, 서버 상태를 매우 편리하게 관리할 수 있도록 도와준다.
이처럼 편리하게 쓸 수 있다 보니 그동안은 단순히 가져다 쓰기만 했을 뿐, 내부적으로 어떻게 동작하는지는 깊이 이해하지 못하고 있었다. 그래서 이번에는 useQuery가 어떤 방식으로 동작하는지 오픈소스 코드를 직접 살펴보며 정리해 보았다.
글은 세 부분으로 나뉜다.
- QueryClientProvider 가 선언되었을때 내부적으로 발생하는 일
- useQuery가 선언되었을때 내부적으로 발생하는 일
- 학습하면서 알게된 점
QueryClientProvider 가 선언되었을때
먼저 리액트쿼리를 사용하기 위해서는 프로바이더를 최상단에 선언해야한다.
const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
<Todos />
</QueryClientProvider>
)
}const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
<Todos />
</QueryClientProvider>
)
}실제로 선언된 프로바이더 안에는 context api가 숨어져 있다.
export const QueryClientProvider = ({
client,
children,
}: QueryClientProviderProps): React.JSX.Element => {
React.useEffect(() => {
client.mount()
return () => {
client.unmount()
}
}, [client])
return (
<QueryClientContext.Provider value={client}>
{children}
</QueryClientContext.Provider>
)
}export const QueryClientProvider = ({
client,
children,
}: QueryClientProviderProps): React.JSX.Element => {
React.useEffect(() => {
client.mount()
return () => {
client.unmount()
}
}, [client])
return (
<QueryClientContext.Provider value={client}>
{children}
</QueryClientContext.Provider>
)
}export const useQueryClient = (queryClient?: QueryClient) => {
const client = React.useContext(QueryClientContext)
if (queryClient) {
return queryClient
}
if (!client) {
throw new Error('No QueryClient set, use QueryClientProvider to set one')
}
return client
}export const useQueryClient = (queryClient?: QueryClient) => {
const client = React.useContext(QueryClientContext)
if (queryClient) {
return queryClient
}
if (!client) {
throw new Error('No QueryClient set, use QueryClientProvider to set one')
}
return client
}그리고 context api로 공유하게 되는 QueryClientContext 는 내부적으로 600줄에 이르는 거대한 클래스로 이루어져 있다.
선언 후에는, 마운트 메서드가 실행된다.
mount(): void {
this.#mountCount++
if (this.#mountCount !== 1) return
this.#unsubscribeFocus = focusManager.subscribe(async (focused) => {
if (focused) {
await this.resumePausedMutations()
this.#queryCache.onFocus()
}
})
this.#unsubscribeOnline = onlineManager.subscribe(async (online) => {
if (online) {
await this.resumePausedMutations()
this.#queryCache.onOnline()
}
})
} mount(): void {
this.#mountCount++
if (this.#mountCount !== 1) return
this.#unsubscribeFocus = focusManager.subscribe(async (focused) => {
if (focused) {
await this.resumePausedMutations()
this.#queryCache.onFocus()
}
})
this.#unsubscribeOnline = onlineManager.subscribe(async (online) => {
if (online) {
await this.resumePausedMutations()
this.#queryCache.onOnline()
}
})
}- 마운트 메서드에서는 몇개가 마운트 되었는지 카운트를 진행한다.
- 따라서 만약 중첩되게 선언되었다고 하더라도 2개이상부터는 내부 로직에 따라 동작을 무시하게 되고 가장 먼저 만나는 프로바이더만 정상적으로 구독하게 된다.
- 중간에 보이는 focusManager 는 브라우저가 다시 포커스 될때를 감지하기 위한것으로 내부적으로는 윈도우의 visibilitychange 이벤트를 구독하는 로직을 가지고 있다.
window.addEventListener('visibilitychange', listener, false)window.addEventListener('visibilitychange', listener, false)- onlineManager는 오프라인에서 온라인이 되는것을 감지하기 위한것이다.
window.addEventListener('online', onlineListener, false)
window.addEventListener('offline', offlineListener, false)window.addEventListener('online', onlineListener, false)
window.addEventListener('offline', offlineListener, false)- 그리고 함께 실행되는 resumePausedMutations는 isPaused: true 상태로 대기중인 모든 mutation을 재실행하는 역할을 한다. (mutate() 로 요청하다가 네트워크가 끊겼다가 연결된 경우 등에서 이어서 요청하는 로직이다.)
resumePausedMutations(): Promise<unknown> {
const pausedMutations = this.getAll().filter((x) => x.state.isPaused)
return notifyManager.batch(() =>
Promise.all(
pausedMutations.map((mutation) => mutation.continue().catch(noop)),
),
)
} resumePausedMutations(): Promise<unknown> {
const pausedMutations = this.getAll().filter((x) => x.state.isPaused)
return notifyManager.batch(() =>
Promise.all(
pausedMutations.map((mutation) => mutation.continue().catch(noop)),
),
)
}Provider 동작 정리
![]()
- mount() — 포커스/온라인 이벤트 구독 시작
- focusManager/onlineManager — window 이벤트를 중간에서 받아 QueryClient에 전달
- resumePausedMutations — 네트워크 끊김 중 대기 중이던 mutation 재실행
- mountCount — 같은 client 공유 시 중복 구독 방지
useQuery가 선언되었을 때
const query = useQuery({ queryKey: ['todos'], queryFn: getTodos }) const query = useQuery({ queryKey: ['todos'], queryFn: getTodos })내부적으로는 useBaseQuery가 실행된다.
export function useQuery(options: UseQueryOptions, queryClient?: QueryClient) {
return useBaseQuery(options, QueryObserver, queryClient)
}export function useQuery(options: UseQueryOptions, queryClient?: QueryClient) {
return useBaseQuery(options, QueryObserver, queryClient)
}useBaseQuery에서는 크게 6가지의 동작이 이루어진다.
1. 준비
const isRestoring = useIsRestoring()
const client = useQueryClient(queryClient)
const defaultedOptions = client.defaultQueryOptions(options)const isRestoring = useIsRestoring()
const client = useQueryClient(queryClient)
const defaultedOptions = client.defaultQueryOptions(options)- isRestoring: 로컬스토리지 같은 외부 저장소에서 이전 캐시를 불러오는 중이면 true. 이 경우 불필요한 fetch를 막는다.
- client: QueryClientProvider의 Context에서 QueryClient를 꺼내온다.
- defaultedOptions: 사용자가 넘긴 옵션에
staleTime: 0, gcTime: 5분같은 기본값을 병합한다.- 그리고 options에 있는 queryKey → queryHash 생성
2. Observer 생성
const [observer] = React.useState(
() => new Observer(client, defaultedOptions)
)const [observer] = React.useState(
() => new Observer(client, defaultedOptions)
)- useState로 최초 1번만 생성된다.
- Observer 생성자 내부에서 캐시를 조회한다.
- queryHash 로 캐시 조회 및 생성
- 같은 queryKey를 가진 useQuery가 여러 곳에 선언되어도 Query는 하나를 공유하고, Observer만 각각 생성된다.
- 옵저버가 각각 생성되는 이유는 자기 컴포넌트 기준으로 리렌더 여부를 판단해야하기 때문이다.
- select와 관련이 있기도 하고, 같은 쿼리라고해도 staleTime, refetchOnMount 등 옵션이 다르기 때문이다.
const result = observer.getOptimisticResult(defaultedOptions)const result = observer.getOptimisticResult(defaultedOptions)- 데이터가 없으면(초기값)
data: undefined, status: 'pending'으로 계산된다. - 현재 캐시 상태를 읽어
{ data, status, isFetching, ... }스냅샷 객체를 만든다.- 다음에 나올 useSyncExternalStore에 의해서 observer.getCurrentResult() 의 값이 변할때, 리렌더링이 발생하고 그때마다 최신 값을 스냅샷으로 남긴다.
3. React 렌더링 연결
const shouldSubscribe = !isRestoring && options.subscribed !== false
React.useSyncExternalStore(
React.useCallback(
(onStoreChange) => {
const unsubscribe = shouldSubscribe
? observer.subscribe(notifyManager.batchCalls(onStoreChange))
: () => {}
observer.updateResult()
return unsubscribe
},
[observer, shouldSubscribe],
),
() => observer.getCurrentResult(),
() => observer.getCurrentResult(),
)const shouldSubscribe = !isRestoring && options.subscribed !== false
React.useSyncExternalStore(
React.useCallback(
(onStoreChange) => {
const unsubscribe = shouldSubscribe
? observer.subscribe(notifyManager.batchCalls(onStoreChange))
: () => {}
observer.updateResult()
return unsubscribe
},
[observer, shouldSubscribe],
),
() => observer.getCurrentResult(),
() => observer.getCurrentResult(),
)React의 useState나 useReducer는 React 내부 상태가 바뀔 때만 리렌더를 발생시킨다.
QueryCache는 React 외부에 있기 때문에, useSyncExternalStore를 통해서 React에 연결해야 한다.
- 첫 번째 인자의 함수는 DOM 커밋 직후 실행된다.
- onStoreChange
- onStoreChange를 옵저버에 구독한다.
- 옵저버는 쿼리의 변화를 감지 (() => observer.getCurrentResult() 실행해 이전 결과와 비교)
- 옵저버는 onStoreChange 를 실행시켜 리액트에게 변화되었다는 것을 알린다.
notifyManager.batchCalls: 여러 쿼리가 동시에 완료돼도onStoreChange를 한 번에 묶어 호출해 리렌더를 1번으로 줄인다.observer.updateResult(): subscribe 등록 전에 이미 데이터가 바뀌었을 경우를 대비해 최신 상태를 반영한다.- updateResult 동작 요약 : 이 로직 내에서 옵저버에 저장된 값(컴포넌트가 구독하고있는 값)과 쿼리(서버원본데이터)에 저장된 값을 비교하여 리렌더링 여부를 결정.
4. fetch 여부 결정
protected onSubscribe(): void {
if (this.listeners.size === 1) {
this.#currentQuery.addObserver(this)
if (shouldFetchOnMount(query, options)) {
this.#executeFetch() // 네트워크 요청
} else {
this.updateResult() // 캐시 반환
}
}
}protected onSubscribe(): void {
if (this.listeners.size === 1) {
this.#currentQuery.addObserver(this)
if (shouldFetchOnMount(query, options)) {
this.#executeFetch() // 네트워크 요청
} else {
this.updateResult() // 캐시 반환
}
}
}observer.subscribe() 호출 시 내부적으로 onSubscribe()가 실행되고, 여기서 fetch를 할지 캐시를 반환할지 결정
- listeners.size === 1: 같은 Observer에 중복 구독이 발생해도 fetch는 한 번만 실행
- 언마운트 되기전에 리렌더링되는 경우를 방지 - React 내부 concurrent 렌더링
- addObserver: Query에 이 Observer를 등록한다. 이후 데이터 변경 시 Query → Observer → onStoreChange → React 리렌더 순
function shouldFetchOnMount(query, options) {
const enabled = resolveEnabled(options.enabled, query) !== false
const staleTime = resolveStaleTime(options.staleTime, query)
// 1. 데이터 자체가 없는 경우
if (
enabled &&
query.state.data === undefined &&
!(query.state.status === 'error' && options.retryOnMount === false)
// retryOnMount: false 이고 에러 상태면 재시도 차단
) {
return true
}
// 2. 데이터는 있지만 refetch 필요한지 확인
if (
enabled &&
query.state.data !== undefined &&
staleTime !== 'static'
) {
const refetchOnMount = options.refetchOnMount
const value = typeof refetchOnMount === 'function'
? refetchOnMount(query)
: refetchOnMount
if (value === 'always') {
return true
}
if (value !== false) {
// stale 여부 판단
if (query.state.isInvalidated) { // invalidateQueries 호출된 경우
return true
}
const remainingTime = timeUntilStale(query.state.dataUpdatedAt, staleTime)
return remainingTime <= 0 // staleTime 초과 여부
}
}
return false // fetch 안 함
}function shouldFetchOnMount(query, options) {
const enabled = resolveEnabled(options.enabled, query) !== false
const staleTime = resolveStaleTime(options.staleTime, query)
// 1. 데이터 자체가 없는 경우
if (
enabled &&
query.state.data === undefined &&
!(query.state.status === 'error' && options.retryOnMount === false)
// retryOnMount: false 이고 에러 상태면 재시도 차단
) {
return true
}
// 2. 데이터는 있지만 refetch 필요한지 확인
if (
enabled &&
query.state.data !== undefined &&
staleTime !== 'static'
) {
const refetchOnMount = options.refetchOnMount
const value = typeof refetchOnMount === 'function'
? refetchOnMount(query)
: refetchOnMount
if (value === 'always') {
return true
}
if (value !== false) {
// stale 여부 판단
if (query.state.isInvalidated) { // invalidateQueries 호출된 경우
return true
}
const remainingTime = timeUntilStale(query.state.dataUpdatedAt, staleTime)
return remainingTime <= 0 // staleTime 초과 여부
}
}
return false // fetch 안 함
}5. 옵션 동기화
React.useEffect(() => {
observer.setOptions(defaultedOptions)
}, [defaultedOptions, observer])React.useEffect(() => {
observer.setOptions(defaultedOptions)
}, [defaultedOptions, observer])- queryKey나 staleTime 같은 옵션이 바뀌면 Observer에 새 옵션을 전달
- 옵션 변경에 따라 fetch 필요 여부를 재판단한다. queryKey가 바뀌면 새 캐시를 조회하고 필요하면 fetch를 다시 실행
6. 결과 반환
return !defaultedOptions.notifyOnChangeProps
? observer.trackResult(result)
: resultreturn !defaultedOptions.notifyOnChangeProps
? observer.trackResult(result)
: resultnotifyOnChangeProps없을 때 →trackResult(Proxy)- 실제로 접근한 프로퍼티만 변할 때 리렌더링 (자동 최적화)
notifyOnChangeProps있을 때 →result- Observer 내부에서 명시한 notifyOnChangeProps 목록으로 판단
- ‘all’, ‘data’, ‘isLoading’
useQuery 동작 정리
- 사용자 입력기반으로 기본 옵션 준비
- 옵저버를 생성 → 캐시 조회 및 생성
- 리액트 렌더링 연결
- 옵션에 따라 fetch 여부 결정
- 옵션 동기화 로직
- 결과 반환
학습하면서 알게된 점
- Provider 내부가 context api로 이루어져있다.
- select를 최적화에 활용할 수 있다.
- shouldNotifyListeners 체크시 select 결과가 변했을때 리렌더링을 트리거한다.
- result 가 스냅샷 형식으로 저장된다.