React-query를 뜯어보며 Observer 패턴 알아보기

데브현·2023년 12월 6일
2

프론트엔드 모음집

목록 보기
5/11

들어가면서

원티드 프리온보딩 강의에서 오픈소스(react-query와 redux)를 뜯어보며 옵저버 패턴을 깨달았다는 얘기를 하길래 나도 직접 한번 뜯어보기로 했다.

React-query 뜯어보기

React-query는 오픈소스 라이브러리로 누구나 구현된 소스코드를 볼 수 있다.
Tanstack에서는 여러 라이브러리를 관리하고 있고 이중에 query라이브러리를 보면 된다.

이중에 나는 react-query를 살펴볼 것이다.

가장 흔하게 사용되는 useQuery로 코드를 한번 분석해보았다.

useQuery.ts

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

useQuery는 매우 간단하게 구현이되어 있는데 useBaseQueryparams를 넘겨서 return해주는 것 뿐이다. 그럼 useBaseQuery 를 보자

useBaseQuery.ts

export function useBaseQuery<
  TQueryFnData,
  TError,
  TData,
  TQueryData,
  TQueryKey extends QueryKey,
>(
  options: UseBaseQueryOptions<
    TQueryFnData,
    TError,
    TData,
    TQueryData,
    TQueryKey
  >,
  Observer: typeof QueryObserver,
  queryClient?: QueryClient,
) {
  const client = useQueryClient(queryClient)
  const [observer] = React.useState(
    () =>
      new Observer<TQueryFnData, TError, TData, TQueryData, TQueryKey>(
        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
}

코드가 복잡하지만 결국 return 하는 값은 result이다.
그럼 result는 무엇인가?

result는 observer라는 state에서 getOptimisticResult로 값을 받아온 값이다.

 const result = observer.getOptimisticResult(defaultedOptions)

그럼 다시 observer 는 무엇인지 살펴봐야 한다.

  const client = useQueryClient(queryClient)
  const [observer] = React.useState(
    () =>
      new Observer<TQueryFnData, TError, TData, TQueryData, TQueryKey>(
        client,
        defaultedOptions,
      ),
  )

상태로 정의한 observeruseBaseQuery에서 받은 Observer를 인스턴스로 생성해서 저장해놓은 값이다. Params로 받은 ObserveruseQuery에서 넘겼던 query-core에 있는 QueryObserver여서 결국엔 핵심 로직은 여기에 담겨져 있는 것이다.

그럼 queryObserver를 살펴보도록 하자

queryObserver.ts

result에서 getOptimisticResult를 사용해서 값을 가져왔는데 이는 단순히 공문에서 쓰여있듯 useQuery에서 제공해주는 값들을 그대로 리턴해준다.

export class QueryObserver<
  TQueryFnData = unknown,
  TError = DefaultError,
  TData = TQueryFnData,
  TQueryData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey,
> extends Subscribable<QueryObserverListener<TData, TError>> {
  options: QueryObserverOptions<
    TQueryFnData,
    TError,
    TData,
    TQueryData,
    TQueryKey
  >
  // ...
  getOptimisticResult(
    options: DefaultedQueryObserverOptions<
      TQueryFnData,
      TError,
      TData,
      TQueryData,
      TQueryKey
    >,
  ): QueryObserverResult<TData, TError> {
    const query = this.#client.getQueryCache().build(this.#client, options)
    const result = this.createResult(query, options)
    // this function would decide if we will update the observer's 'current'
    // properties after an optimistic reading via getOptimisticResult
    // 현재 properties를 업데이트 할지 말지 결정해주는 함수이다.
    
    if (shouldAssignObserverCurrentProperties(this, result)) {
      // this assigns the optimistic result to the current Observer
      // because if the query function changes, useQuery will be performing
      // an effect where it would fetch again.
      // When the fetch finishes, we perform a deep data cloning in order
      // to reuse objects references. This deep data clone is performed against
      // the `observer.currentResult.data` property
      // When QueryKey changes, we refresh the query and get new `optimistic`
      // result, while we leave the `observer.currentResult`, so when new data
      // arrives, it finds the old `observer.currentResult` which is related
      // to the old QueryKey. Which means that currentResult and selectData are
      // out of sync already.
      // To solve this, we move the cursor of the currentResult everytime
      // an observer reads an optimistic value.

      // When keeping the previous data, the result doesn't change until new
      // data arrives.
      this.#currentResult = result
      this.#currentResultOptions = this.options
      this.#currentResultState = this.#currentQuery.state
    }
    return result
  }
  // ...
  protected createResult(
    query: Query<TQueryFnData, TError, TQueryData, TQueryKey>,
    options: QueryObserverOptions<
      TQueryFnData,
      TError,
      TData,
      TQueryData,
      TQueryKey
    >,
  ): QueryObserverResult<TData, TError> {
    // 생략 ..

    const result: QueryObserverBaseResult<TData, TError> = {
      status,
      fetchStatus,
      isPending,
      isSuccess: status === 'success',
      isError,
      isInitialLoading: isLoading,
      isLoading,
      data,
      dataUpdatedAt: state.dataUpdatedAt,
      error,
      errorUpdatedAt,
      failureCount: state.fetchFailureCount,
      failureReason: state.fetchFailureReason,
      errorUpdateCount: state.errorUpdateCount,
      isFetched: state.dataUpdateCount > 0 || state.errorUpdateCount > 0,
      isFetchedAfterMount:
        state.dataUpdateCount > queryInitialState.dataUpdateCount ||
        state.errorUpdateCount > queryInitialState.errorUpdateCount,
      isFetching,
      isRefetching: isFetching && !isPending,
      isLoadingError: isError && state.dataUpdatedAt === 0,
      isPaused: fetchStatus === 'paused',
      isPlaceholderData,
      isRefetchError: isError && state.dataUpdatedAt !== 0,
      isStale: isStale(query, options),
      refetch: this.refetch,
    }

    return result as QueryObserverResult<TData, TError>
  }
}

중간에 현재 properties를 업데이트 할지, 아니면 그대로 내려줄지 하는 로직들이 있고 결국엔 result를 내려주게 된다. result에 쓰이는 query는 밑에서 다시 다루겠다.
여기서 눈여겨봐야 할 것은 Subscribable를 상속받고 있다는 것이다.

다시 observer 상태를 선언할때 쓰였던 client는 queryClient를 리엑트의 context API를 사용해서 만든 훅으로 정의되어 있다.

  const client = useQueryClient(queryClient)
  const [observer] = React.useState(
    () =>
      new Observer<TQueryFnData, TError, TData, TQueryData, TQueryKey>(
        client,
        defaultedOptions,
      ),
  )

QueryClientProvider.tsx

export const QueryClientContext = React.createContext<QueryClient | undefined>(
  undefined,
)
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
}

queryClient의 대한 구현도 같이 살펴보면 아까 봤던 클래스(Subscribable)를 다시 상속받는것을 알수 있게 된다.

queryClient.ts

export class QueryClient {
  #queryCache: QueryCache
  #mutationCache: MutationCache
  #defaultOptions: DefaultOptions
  #queryDefaults: Map<string, QueryDefaults>
  #mutationDefaults: Map<string, MutationDefaults>
  #mountCount: number
  #unsubscribeFocus?: () => void
  #unsubscribeOnline?: () => void

  constructor(config: QueryClientConfig = {}) {
    this.#queryCache = config.queryCache || new QueryCache()
    this.#mutationCache = config.mutationCache || new MutationCache()
    this.#defaultOptions = config.defaultOptions || {}
    this.#queryDefaults = new Map()
    this.#mutationDefaults = new Map()
    this.#mountCount = 0
  }
  // ...
  getQueryCache(): QueryCache {
    return this.#queryCache
  }
  // ...
}

생성자를 보면 우리가 흔히 사용할때 공식문서에 쓰여있는 option들, 즉 config를 받는 것을 볼 수 있고, queryCahce또한 받는 부분이 보인다. 아까 위에서 useQuery의 result를 리턴하기 전에 query를 선언 하는 부분이 잠깐 보였다. 코드로 보자면 다음과 같다.

  getOptimisticResult(
    options: DefaultedQueryObserverOptions<
      TQueryFnData,
      TError,
      TData,
      TQueryData,
      TQueryKey
    >,
  ): QueryObserverResult<TData, TError> {
    const query = this.#client.getQueryCache().build(this.#client, options)
    const result = this.createResult(query, options)
    // ...
    return result
  }

queryClient에서 쿼리 캐시를 가져와 해당 queryClientbuild 해서 query에 넣고 이를 result를 만드는데 사용한다.

queryCache.ts

export class QueryCache extends Subscribable<QueryCacheListener> {
  #queries: QueryStore

  constructor(public config: QueryCacheConfig = {}) {
    super()
    this.#queries = new Map<string, Query>()
  }

  build<TQueryFnData, TError, TData, TQueryKey extends QueryKey>(
    client: QueryClient,
    options: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
    state?: QueryState<TData, TError>,
  ): Query<TQueryFnData, TError, TData, TQueryKey> {
    const queryKey = options.queryKey!
    const queryHash =
      options.queryHash ?? hashQueryKeyByOptions(queryKey, options)
    let query = this.get<TQueryFnData, TError, TData, TQueryKey>(queryHash)

    if (!query) {
      query = new Query({
        cache: this,
        queryKey,
        queryHash,
        options: client.defaultQueryOptions(options),
        state,
        defaultOptions: client.getQueryDefaults(queryKey),
      })
      this.add(query)
    }

    return query
  }
  
  // ...
}

build 함수는 말그대로 해당 쿼리를 만드는 역할을 해준다. 세부적인 구현사항은 Query 도 자세히 살펴보아야 알겠지만 지금 봐야할 것은 QueryCache 또한 Subscribable class를 상속받는다는 것이다.

그럼 결국 Subscribable이 최상위 클래스라고 보이는데 무엇을 하는 역할인지 보도록 하자

subscribable.ts

type Listener = () => void

export class Subscribable<TListener extends Function = Listener> {
  protected listeners: Set<TListener>

  constructor() {
    this.listeners = new Set()
    this.subscribe = this.subscribe.bind(this)
  }

  subscribe(listener: TListener): () => void {
    this.listeners.add(listener)

    this.onSubscribe()

    return () => {
      this.listeners.delete(listener)
      this.onUnsubscribe()
    }
  }

  hasListeners(): boolean {
    return this.listeners.size > 0
  }

  protected onSubscribe(): void {
    // Do nothing
  }

  protected onUnsubscribe(): void {
    // Do nothing
  }
}

생각보다 짧은 코드로 구현이 되어있다.

listeneres라는 변수를 Set 자료형으로 선언하고 생성자에서는 listenerssubscribe를 초기화 해준다. 각각의 메서드들은 listener를 받아서 listeners에 추가해서 subscribe하는 메서드, listener 가 있는지 확인하는 메서드로 구성되어 있다.

이는 observer 패턴의 전형적인 코드의 형태이다.

옵저버 패턴에 대해서

옵저버 패턴이란?
옵저버 패턴(observer pattern)은 관찰자들이 관찰하고 있는 대상자의 상태가 변화가 있을 때마다 대상자는 직접 목록의 각 관찰자들에게 통지하고, 관찰자들은 알림(Notification)을 받아 조치를 취하는 행동 패턴이다.

옵저버 패턴 흐름

  • 옵저버 패턴에서는 한개의 관찰 대상자와 여러개의 관찰로 구성되어 있다.
  • 관찰 대상의 상태가 바뀌면 변경사항을 옵저버한테 통보(notify) 해준다.
  • 대상자로부터 통보를 받은 Observer는 값을 update한다.

react-query에서도 당연히 subscribe와 unsubscribe 그리고 notify하는 코드가 들어가 있다.

해당 코드

  // ...
  protected onSubscribe(): void {
    if (this.listeners.size === 1) {
      this.#currentQuery.addObserver(this)

      if (shouldFetchOnMount(this.#currentQuery, this.options)) {
        this.#executeFetch()
      } else {
        this.updateResult()
      }

      this.#updateTimers()
    }
  }

  protected onUnsubscribe(): void {
    if (!this.hasListeners()) {
      this.destroy()
    }
  }
  // ...
  #notify(notifyOptions: NotifyOptions): void {
    notifyManager.batch(() => {
      // First, trigger the listeners
      if (notifyOptions.listeners) {
        this.listeners.forEach((listener) => {
          listener(this.#currentResult)
        })
      }

      // Then the cache listeners
      this.#client.getQueryCache().notify({
        query: this.#currentQuery,
        type: 'observerResultsUpdated',
      })
    })
  }
  

참고 자료:

profile
I am a front-end developer with 4 years of experience who believes that there is nothing I cannot do.

0개의 댓글