[React] React-Query 동작 원리 (2)

배준형·2024년 2월 13일
2
post-thumbnail

서문

안녕하세요. 인재 채용 플랫폼 잡다의 웹 프론트엔드 개발을 하고 있는 배준형입니다.

직전 포스팅에서 React-Query 동작 원리 중 Query-Core의 QueryClient, QueryCache에 대해 알아보았는데요. 그 과정에서 React-Query가 데이터를 어떻게 캐싱하는지, 어떻게 최적화해서 API를 호출하는지 등 일부 파편화된 핵심 로직들을 확인해 보았습니다.

이번에는 아직 확인해 보지 못한 QueryObserver, NotifyManager 같은 요소들을 확인해 보고 React-Query의 data fetching 전반적인 흐름을 알아보겠습니다.

※ 여기서는 Tanstack Query v5.18.0 버전으로 살펴봅니다. 버전별로 내용이 다를 수 있으니 참고 부탁드립니다.🙏

이해를 돕기 위해 가져온 코드 중 일부 내용, Generic Type 등은 생략했습니다.
https://github.com/TanStack/query

직전 포스팅을 확인하면 글 내용을 이해하는 데 도움이 될 수 있습니다.
[React] React-Query 동작 원리 (1)


QueryObserver

QueryObserver는 특정 쿼리의 상태를 추적하고 관찰하는 역할을 수행합니다.

query-core/src/queryObserver.ts

export class QueryObserver extends Subscribable {
  #client: QueryClient
  #currentQuery: Query = undefined!
  #currentResult: QueryObserverResult = undefined!
  #currentResultOptions?: QueryObserverOptions
  // ...

  constructor(
    client: QueryClient,
    public options: QueryObserverOptions,
  ) {
    super()

    this.#client = client
    // ...
  }

  // ...

  onQueryUpdate(): void {
    this.updateResult()

    if (this.hasListeners()) {
      this.#updateTimers()
    }
  }

  // ...
}
  • #client: QueryClient 인스턴스. 쿼리 실행 등을 위해 저장
  • #currentQuery: 현재 실행 중인 쿼리
  • #currentResult: 현재 쿼리 결과
  • #currentResultOptions: 현재 쿼리 결과 관련 설정

QueryObserver는 쿼리의 상태가 변경되었다면 이를 감지하여 구독하고 있는 컴포넌트에 알립니다. 이렇게 함으로써, 쿼리의 상태 변화를 실시간으로 UI에 반영할 수 있는 것이죠.


React에서 React-Query를 사용하려면 QueryProvider Context를 사용하여 QueryClient를 내려줘야 합니다.

// React-Query 사용 시 QueryClient 세팅
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

const queryClient = new QueryClient()

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Page />
    </QueryClientProvider>
  )
}

React-Query를 사용할 때 QueryClient는 총 1개만 생성되고, 해당 QueryClient에서 QueryCache 1개만 생성하여 멤버 변수로 저장해 사용합니다. QueryCache에서는 #queries Map 객체에 쿼리를 캐싱하여 사용하는데, 이 Query의 데이터 변경, 재실행 등의 변화가 발생했을 때 QueryObserver가 이를 감지하여 컴포넌트에 알리도록 구현이 되어 있어요.

그러면 QueryObserver는 쿼리가 변경되었음을 어떻게 감지할까요??


Query

Query#dispatch 메서드로 상태를 변경하고, 이를 통해 QueryObserver에게 상태 변화를 알립니다.

query-core/src/query.ts

#dispatch(action: Action<TData, TError>): void {
    const reducer = (
      state: QueryState<TData, TError>,
    ): QueryState<TData, TError> => {
      switch (action.type) {
        case 'failed':

  // ...

  notifyManager.batch(() => {
    this.#observers.forEach((observer) => {
      observer.onQueryUpdate()
    })

    this.#cache.notify({ query: this, type: 'updated', action })
  })
}

#dispatch 메서드 내부에서 쿼리의 상태 변화를 모든 관찰자에게 알리기 위해 observer의 onQueryUpdate 메서드를 호출합니다.

그 과정에서 notifyManager batch 메서드가 사용되는데요. batch 메서드는 모든 상태 변경이 동시에 발생해도 한 번에 처리할 수 있도록 해줍니다.


NotifyManager

notifyManager는 여러 상태 변경을 한 번에 처리하는 역할을 합니다.

export function createNotifyManager() {
  let queue: Array<NotifyCallback> = []
  let transactions = 0
  let notifyFn: NotifyFunction = (callback) => {
    callback()
  }
  let batchNotifyFn: BatchNotifyFunction = (callback: () => void) => {
    callback()
  }
  let scheduleFn: ScheduleFunction = (cb) => setTimeout(cb, 0)

  const setScheduler = (fn: ScheduleFunction) => {}
  const batch = <T>(callback: () => T): T => {}
  const schedule = (callback: NotifyCallback): void => {}
  const batchCalls = (callback: BatchCallsCallback): BatchCallsCallback => {}
  const flush = (): void => {}

  // ...
}

// SINGLETON
export const notifyManager = createNotifyManager()

batch 메서드는 여러 상태 변경을 한 번에 처리하는 역할을 합니다. 이 과정은 transaction 변수를 통해 이루어지는데요.

notifyManager는 하나의 인스턴스만 생성(Sington 패턴)되고, 내부에서 정의된 batch 같은 중첩된 함수들을 반환합니다. 이는 자바스크립트 클로저(closure) 개념을 활용한 것으로 외부에서는 transaction 값에 접근할 수 없지만, 내부에서 정의된 함수에선 해당 값에 접근할 수 있으므로 여러 상태 변경이 동시에 발생해도 안정적으로 처리할 수 있게 됩니다.

QueryObserver는 Query 인스턴스의 상태 변경을 구독하고, Query 인스턴스는 상태 변경이 발생할 때마다 이를 notifyManager를 통해 처리합니다. 이렇게 함으로써, QueryObserver는 Query 인스턴스의 상태 변경을 실시간으로 반영할 수 있는 것이죠.


상태 변화 감지 흐름

지금까지의 내용을 바탕으로 QueryClient를 통해 data fetching 전반적인 흐름을 살펴보겠습니다.

1) queryClient.fetchQuery() - data fetching 시작

await queryClient.fetchQuery({
  queryKey: key,
  queryFn: fetchFn,
})

fetchQuery를 호출하여 비동기 데이터를 가져온다고 가정하겠습니다. 그러면 QueryCachebuild 메서드를 통해 캐싱된 쿼리를 가져오고 query.fetch 함수를 호출합니다.

fetchQuery(options: FetchQueryOptions,): Promise<TData> {
  // ...
  const query = this.#queryCache.build(this, defaultedOptions)

  return query.isStaleByTime(defaultedOptions.staleTime)
    ? query.fetch(defaultedOptions)
    : Promise.
}

2) query.fetch() - 실질적인 data fetching 로직 실행

query.fetch 함수 내부에선 조건에 따라 실행을 중단하거나 options를 세팅하는 여러 로직을 거쳐 fetching이 진행될 때 dispatch 메서드를 호출하여 상태를 변경해 줍니다.

fetch(options?: QueryOptions, fetchOptions?: FetchOptions): Promise<TData> {
  // ...

  // Set to fetching state if not already in it
  if (
    this.state.fetchStatus === 'idle' ||
    this.state.fetchMeta !== context.fetchOptions?.meta
  ) {
    this.#dispatch({ type: 'fetch', meta: context.fetchOptions?.meta })
  }

  // Try to fetch the data
  this.#retryer = createRetryer({
    // ...
    onSuccess: (data) => {
      // ...

      this.setData(data)

      // ...
    },
    // ...
  })

  this.#promise = this.#retryer.promise

  return this.#promise
}

setData(newData: TData, options?: SetDataOptions & { manual: boolean }): TData {
  const data = replaceData(this.state.data, newData, this.options)

  // Set data and mark it as cached
  this.#dispatch({
    data,
    type: 'success',
    dataUpdatedAt: options?.updatedAt,
    manual: options?.manual,
  })

  return data
}

많은 부분이 생략됐지만, 요약하자면

  • fetching 해야 하는 조건을 만족하면 #dispatch 메서드로 쿼리의 상태를 fetch로 바꾼다.
  • data fetching은 createRetryer 내부에서 이루어지는데, 만약 성공한다면 setData 메서드를 호출한다.
  • setData 내부에서 쿼리에 fetching 된 데이터를 저장하고 상태를 success로 바꾼다.

의 흐름으로 data fetching이 이루어집니다.


3) query.#dispatch() - 쿼리 상태 변경

query.fetch() 내부에서 error 없이 정상 success 되었다면 fetch, success 상태 변경으로 총 2회의 #dispatch가 호출됩니다.

#dispatch(action: Action<TData, TError>): void {
  const reducer = (
    state: QueryState<TData, TError>,
  ): QueryState<TData, TError> => {
    switch (action.type) {
      case 'failed': // ...
      case 'pause': // ...
      case 'continue': // ...
      case 'fetch':
        return {
          ...state,
          fetchStatus: canFetch(this.options.networkMode)
            ? 'fetching'
            : 'paused',
          status: 'pending',
          // ...
        }
      case 'success':
        return {
          ...state,
          data: action.data,
          status: 'success',
          fetchStatus: 'idle',
          // ...
        }
      case 'error': // ...
      case 'invalidate': // ...
      case 'setState': // ...
    }
  }

  this.state = reducer(this.state)

  notifyManager.batch(() => {
    this.#observers.forEach((observer) => {
      observer.onQueryUpdate()
    })

    this.#cache.notify({ query: this, type: 'updated', action })
  })
}

#dispatch 내부에선 정의된 reducer에 의해 state를 업데이트한 뒤 notifyManager, observer를 통해 쿼리가 업데이트되었음을 모든 관찰자에게 알리게 됩니다.

observeronQueryUpdate 메서드를 호출하여 쿼리를 업데이트하고, cachenotify 메서드를 호출하여 구독(subscribe)되어 있는 콜백 함수들을 모두 호출하게 됩니다.


data fetching의 흐름 정리

  1. queryClient.fetchQuery()로 데이터 페칭 시작
  2. queryCache.build()로 캐시된 쿼리 객체 가져옴
  3. query.fetch()로 실제 데이터 요청
  4. query.dispatch()로 쿼리 상태 업데이트
  5. observeronQueryUpdate()로 최신 상태 반영
  6. notifyManager로 한번에 업데이트
  7. 완료되면 cache.notify()로 변경 알림

지금까지 알아본 내용에 의하면 위의 과정으로 data fetching이 진행됩니다. 그 과정에서 query caching, batch 등의 최적화가 이루어집니다.


useQuery

지금까지는 react가 아닌 query-core의 핵심 내용만 알아보았습니다. 이제 드디어 react-query 파트를 알아볼 수 있을 것 같아요.

react에서 해당 내용들을 활용할 수 있는 여러 hook 들이 존재하는데, 그중 data fetching 관련 useQuery hook을 알아보겠습니다.

react-query/src/useQuery.ts

export function useQuery(options: UseQueryOptions, queryClient?: QueryClient) {
  return useBaseQuery(options, QueryObserver, queryClient)
}

useQuery는 options, queryClient를 인자로 받아서 QueryObserver와 함께 useBaseQuery로 전달해 주는 역할을 합니다.


react-query/src/useBaseQuery.ts

export function useBaseQuery(
  options: UseBaseQueryOptions,
  Observer: typeof QueryObserver,
  queryClient?: QueryClient,
): QueryObserverResult {
  // ...

  const client = useQueryClient(queryClient)
  const isRestoring = useIsRestoring()

  // ...

  const [observer] = React.useState(
    () => new Observer(client, defaultedOptions),
  )

  const result = observer.getOptimisticResult(defaultedOptions)

  React.useSyncExternalStore(
    React.useCallback(
      (onStoreChange) => {
        const unsubscribe = isRestoring
          ? () => undefined
          : observer.subscribe(notifyManager.batchCalls(onStoreChange))

        // 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, isRestoring],
    ),
    () => observer.getCurrentResult(),
    () => observer.getCurrentResult(),
  )

  // Handle result property usage tracking
  return !defaultedOptions.notifyOnChangeProps
    ? observer.trackResult(result)
    : result
}

useBaseQuery를 하나씩 살펴보면, useQueryClient hook을 호출하여 전달 인자로 받은 queryClient 또는 Provider를 통해 미리 전달해 놓은 queryClient를 받아옵니다.


react-query/src/QueryClientProvider.tsx

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
}

위에서 react-query를 사용할 때 QueryClient를 Provider로 넘겨준다고 했습니다. 그러면 useQueryClient hook으로 미리 넘겨준 QueryClient를 받아올 수 있게 되니 Provider로 미리 넘겨줬다면 useQuery를 사용할 때 queryClient를 넘겨주는 것과 상관없이 QueryClient를 반환합니다.


const [observer] = React.useState(
  () => new Observer(client, defaultedOptions),
)

다음으로 observer를 생성하는데, useState를 이용해서 React state로 정의한 코드입니다. 보통 useState를 사용하면 setState까지 같이 사용하는데, 무엇 때문에 state만 반환하도록 설계했을까요?

이 부분은 해당 코드만으로 추론하긴 어렵고, 코드 작성자의 의견이나 어떤 과정을 거쳐서 useState로 왔는지 파악이 필요할 것 같아요.


useState를 사용하기 전 Observer 생성 코드 Pull Request

v3 Pull Request

// Create query observer
const observerRef = React.useRef<QueryObserver<any, any, any, any>>()
const observer =
  observerRef.current || new Observer(queryClient, defaultedOptions)
observerRef.current = observer

Github Pull Request를 확인해 보니 이전에는 useRef를 사용해서 생성했었네요. useQuery가 호출될 때마다 1개의 QueryObserver만 생성해서 재활용하려고 했던 것으로 판단됩니다.


useState로 변경된 Pull Requset

refactor: use react-state for one-time initialization over instance refs

// AS-IS
const obsRef = React.useRef<
  QueryObserver<...>
>()

if (!obsRef.current) {
  obsRef.current = new Observer<...>(queryClient, defaultedOptions)
}

// TO-BE
const [observer] = React.useState(
  () =>
    new Observer<...>(
      queryClient,
      defaultedOptions
    )
)

다음 수정 사항에서 useState로 변경됩니다. 그 이유는

  • 더 짧음.
  • 타입 추론이 더 쉬움.
  • ref.current에 접근할 때 ! 키워드를 피함.

이라고 합니다. 1회만 생성해서 재활용하려는 목적은 유지되는 것 같아요.

추론해 보자면 useQuery를 호출할 때마다 1개의 QueryObserver만 생성해서 재활용하기 위해 setState 없이 작성된 것 같습니다.


여기서 의문점이 생겼습니다. useQuery가 호출되어서 일련의 과정을 거쳐 Query의 상태가 업데이트되었어도 그 Query의 상태는 React에서 바라보는 state도 아니고 context도 아닙니다. Query의 상태를 업데이트시켜도 컴포넌트에서는 해당 값에 의해 리렌더링이 일어나지 않아야 하는 것이죠.

state로 생성된 observer로 변화를 감지하여 알릴 때도 setObserver 데이터를 변경시키는 것도 아니고 observer의 내부 메서드를 통해서 알리게 되는데요.

그러면, 생성된 QueryObserver가 쿼리의 상태가 변경되었음을 감지하여 컴포넌트에 알렸을 때 React Component에서 리렌더링은 어떻게 이루어질까요?


useSyncExternalStore

답은 useSyncExternalStore 훅에 있습니다. 이 훅은 외부 스토어를 구독할 수 있는 React 훅으로 useBaseQuery를 호출하면 내부에서 useSyncExternalStore 훅을 호출하여 상태를 구독하는데요.

React.useSyncExternalStore(
  React.useCallback(
    (onStoreChange) => {
      const unsubscribe = isRestoring
        ? () => undefined
        : observer.subscribe(notifyManager.batchCalls(onStoreChange))

      // 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, isRestoring],
  ),
  () => observer.getCurrentResult(),
  () => observer.getCurrentResult(),
)

useSyncExternalStore(subscribe, getSnapshot, optional getServerSnapshot)

  • subscribe: 하나의 callback 인자를 받아 스토어를 구독하는 함수.
    • 스토어가 변경되면 제공된 callback을 호출해야 하고, 이로부터 컴포넌트가 리렌더링 됩니다.
  • getSnapshot: 컴포넌트에 필요한 스토어 데이터의 스냅샷을 반환하는 함수.
    • 스토어가 변경되지 않은 상태에서 getSnapshot을 반복적으로 호출하면 동일한 값을 반환해야 합니다.
    • 저장소가 변경되어 반환된 값이 달라지면, React는 컴포넌트를 리렌더링 합니다.
  • getServerSnapshot: 스토어에 있는 데이터의 초기 스냅샷을 반환하는 함수.
    • 서버에서 렌더링할 때와 이를 클라이언트에서 hydrate하는 동안에만 사용됩니다.

QueryObserver의 내부 구현에 따라 subscribe 함수를 호출하여 구독할 때도 queryFn이 호출됩니다. 해당 훅을 이용해서 query의 상태 변경이 발생하면 queryObserver.subscribe 함수가 호출되고, useSyncExternalStore 내부 로직에 의해 React 컴포넌트의 리렌더링이 일어납니다.


useQuery를 호출했을 때 일어나는 일의 흐름

그림이 도움이 될 지는 모르겠네요😂

  • 전체 Application에 QueryClient 1개, QueryCache 1개만 존재
  • QueryProviderQueryClient를 넘겨서 동일한 QueryClient를 사용하도록 설정
  1. 컴포넌트에서 useQuery 호출 → useBaseQuery 호출
  2. useQueryClientQueryClient를 받아와 사용
  3. QueryObserver 생성 후 useSyncExternalStore 훅으로 외부 데이터 구독
  4. ObserversubscribeonSubscribe 순서대로 호출
  5. onSubscribe 내부의 executeFetch에서 updateQuery를 통해 Query 생성
    • build 메서드로 캐싱된 쿼리가 있다면 캐싱된 쿼리를 쓰지만, 여기선 첫 호출이므로 생성됨.
  6. 생성된 Queryfetch로 data fetching 후 상태 업데이트
  7. 업데이트되면 Observersubscribe로 상태 변화를 알림
  8. notifyManager batch로 여러 상태 변경을 한 번에 처리
  9. useSyncExternalStore에 의해 컴포넌트 리렌더링

순서가 될 것 같습니다.


정리

  • QueryObserver: Query의 상태 변화를 감지하고, 상태 변경 시 모든 관찰자에게 알리는 역할을 수행
  • notifyManager: 클로저 기능을 활용하여 여러 상태 업데이트를 안정적으로 한 번에 처리함.
  • useQuery / useBaseQuery: 호출 시 Observer를 생성 → Observer.subscribe로 Query 생성 → Query에서 fetch 진행 → 상태 변경 시 notifyManager를 통해 한 번에 업데이트 처리 → 결과 반환

이번에는 QueryClient.fetchQuery, useQuery를 호출했을 때 실질적으로 data fetching이 어떻게 이루어지는지 알아보았습니다. 이제 어떻게 data fetching이 이루어지는지 알게 되었지만, 내부적으로 살펴보지 않은 여러 동작이 많이 남아있는 것을 알게돼서 알아가면 알아갈수록 모르는 게 더 많아지는 느낌도 듭니다😂

그럼에도 코드를 분석하고 이해하는 과정에서 새롭게 알게 되는 내용들이 많아서 좋았고(useSyncExternalStore 등) 아직 알아보지 못한 내용들을 확인하면서도 더 도움이 될 것이라는 믿음도 생깁니다.

뒤에 얼마나 많은 포스팅들이 추가될 지는 모르겠지만, 아직 직전 포스트에서 언급했던 SSR 지원 같은 내용들도 어떻게 동작하는지 모르기에 관련 내용들을 하나씩 뜯어보고, 천천히 알아보고자 합니다.


참조

profile
프론트엔드 개발자 배준형입니다.

0개의 댓글