Tech

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 동작 정리

QueryClient가 마운트될 때 일어나는 동작을 정리한 도식

  • 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)
  : result
return !defaultedOptions.notifyOnChangeProps
  ? observer.trackResult(result)
  : result
  • notifyOnChangeProps 없을 때 → trackResult(Proxy)
    • 실제로 접근한 프로퍼티만 변할 때 리렌더링 (자동 최적화)
  • notifyOnChangeProps 있을 때 → result
    • Observer 내부에서 명시한 notifyOnChangeProps 목록으로 판단
    • ‘all’, ‘data’, ‘isLoading’

useQuery 동작 정리

  1. 사용자 입력기반으로 기본 옵션 준비
  2. 옵저버를 생성 → 캐시 조회 및 생성
  3. 리액트 렌더링 연결
  4. 옵션에 따라 fetch 여부 결정
  5. 옵션 동기화 로직
  6. 결과 반환

학습하면서 알게된 점

  • Provider 내부가 context api로 이루어져있다.
  • select를 최적화에 활용할 수 있다.
    • shouldNotifyListeners 체크시 select 결과가 변했을때 리렌더링을 트리거한다.
  • result 가 스냅샷 형식으로 저장된다.
이 글이 도움이 되었나요?좋아요는 다음 글을 쓰는 힘이 됩니다

다른 글