옵저버 패턴(Observer Pattern)과 React Query


우아한 테크코스 Level1을 하면서, 바닐라 JS에 대한 이해도가 조금 생긴 것 같다.
이 과정에서 웹을 구성하는 핵심이 무엇일까?, 바닐라JS로 웹을 구현하는데 핵심이 무엇일까? 생각해 보았을때, 나는 ‘비동기로 실행되는 이벤트’라고 생각했다. 즉, 상태가 계속해서 변할 때 이벤트를 어떻게 효율적으로 실행하는가? 이다.
 
리액트에서는 useEffect 와 useState 와 더불어, Redux React Query등을 사용하며, ‘상태를 감지하는 시스템’을 통해 간편하게 관리를 하지만, 바닐라에서는 어떻게 구현하면 좋을까? 생각을 많이 했다.
 
그 중에서 눈에 띄었던 기능은 ‘웹 컴포넌트’에서의 observedAttributes 메서드, 그리고 Mutation Observer를 통해서 DOM에 있는 요소를 상태 감지하여 함수를 실행할 수 있었던 것이다.

Using custom elements - Web APIs | MDN

One of the key features of web components is the ability to create custom elements: that is, HTML elements whose behavior is defined by the web developer, that extend the set of elements available in the browser.

developer.mozilla.org

 

MutationObserver - Web APIs | MDN

The MutationObserver interface provides the ability to watch for changes being made to the DOM tree. It is designed as a replacement for the older Mutation Events feature, which was part of the DOM3 Events specification.

developer.mozilla.org

이것 외로 Intersection Observer API 와 같이, ‘Observer’라는 용어를 많이 볼 수 있었고, 웹에서 이벤트를 발생시키는 좋은 아이디어라고 생각되어, 이번 기회에 ‘옵저버 패턴’그리고 리액트의 상태관리 라이브러리인 ‘Tanstack Query(React Query)’를 파헤쳐 보기로 했다!!

Intersection Observer API - Web APIs | MDN

The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport.

developer.mozilla.org

우선 옵저버 패턴이 무엇인지에 대해서 알아보도록 하자.

❓옵저버 패턴은 무엇인가

  1. 말 그대로 Observer, 즉 관찰자를 생성하여 '관찰' 하는 것.
  2. 주체가 이벤트를 실행하지 않는다. 즉, 이벤트를 주체에게 전달하는 것이 아니라, 옵저버(관찰자) 객체로 전달해주고(notify), 이 옵저버 객체가 각각의 주체에 이벤트를 실행(update)한다. 
  3. 주로 ‘분산 이벤트 핸들링 시스템’에서 사용
  4. 프로그래밍적으로 옵저버 패턴은 사실 '관찰' 하기 보단 갱신을 위한 힌트 정보를 '전달' 받길 기다린다고 보는 것이 적절하다. (이 부분에서 헷갈렸음. 옵저버가 어떻게 대상의 변화를 감지할 수 있는가? 에 대한 해답이 풀림)
  5. 관찰을 당하는 대상, Subject. 그리고 관찰자(구독자, 이벤트 알림 수신자의 역할), Observer로 구성된다.
  6. 옵저버 패턴를 그림으로 본다면 다음과 같이 구성된다. 
하나의 옵저버를 그린다면 다음과 같을 것.
실제로는 여러개의 분산 이벤트가 복합적으로 연결되어 있을 것이다.

(그림에 표시된 update는 update함수가 아닌, 사용자로 인한 state의 동적 변화를 나타낸 것이다.. 동적인 변화를 옵저버에게 알리면, 옵저버가 주체의 update함수를 실행한다.)

  1. 옵저버 패턴에서, Observer와 Subject는은 다음과 같은 기본 구조를 가진다.
  2. Observer내부에서 Subject의 update함수를 실행하고, Observer의 notify함수는 Subject에서 실행되는 구조.
  3. 즉 Subject는 Observer에게 notify하고, Observer는 Subject의 이벤트를 감지하여 update한다.

❓그래서 옵저버 패턴을 왜 쓸까요?

  1. 여러개의 분산 이벤트를 '구독' 할 수 있다는 것.
  2. 이 '구독' 시스템을 통해, '원하는 주체'에만 이벤트를 실행시킬 수 있다는 것
  3. 그리고 주체 (subject)에서 직접 이벤트를 실행하는 것이 아니기 때문에, 느슨한 결합을 유지하여 확장성이 용이하다는 것
  4. 이는 분산 이벤트의 수가 많아질 수록 빛을 바라는 패턴이라고 생각이 들었다.

아래 링크를 통해 옵저버 패턴을 더 자세히 알아보도록 하자. 개인적으로 제일 이해하기 쉬웠던 참조이다.

옵서버 패턴

/ 디자인 패턴들 / 행동 패턴 옵서버 패턴 다음 이름으로도 불립니다: 이벤트 구독자, 경청자, Observer 의도 옵서버 패턴은 당신이 여러 객체에 자신이 관찰 중인 객체에 발생하는 모든 이벤트에

refactoring.guru

❗React Query에서의 옵저버 패턴

리액트 쿼리의 구조는 다음과 같다.

query-core/src
├── notifyManager.ts
├── query.ts
├── queryCache.ts
├── queryClient.ts
└── queryObserver.ts

react-query/src
├── useBaseQuery.ts
└── useQuery.ts

  1. Tanstack Query 의 기본적인 로직을 담당하는 query-core/src 소스파일이 있고, 리액트 훅을 담당하는 코드인 react-query/src 소스파일이 있다.
  2. React Query에서의 주체(subject)는 Query클래스 이다.
  3. Observer는 QueryObserver클래스 이다.
  4. 한눈에 간단하게 파일의 구조를 본다면 다음과 같다.
  1. React Query 에서의 옵저버 인터페이스는 Subscribable클래스 으로 구성된다.
  2. Subscribable 클래스는 원하는 주체를 저장하는 listeners[] 필드가 있다.
  3. Subject의 인터페이스는 Removable클래스로 구성된다. (Removable은 gcTime을 관리한다.)
  4. gcTime은 가비지컬렉팅 타임이다. 즉 gcTime을 통해 remove하는기능을 가진다. (비유를 한다면 구독 해지 정도가 되겠다)

😊주체 subject(Query 클래스)는 다음과 같다.

// Subject ... 
addObserver(observer: QueryObserver<any, any, any, any, any>): void {
    if (!this.#observers.includes(observer)) {
	    //...
      this.#cache.notify({ type: 'observerAdded', query: this, observer })
    }
  }

removeObserver(observer: QueryObserver<any, any, any, any, any>): void {
	  //...
      if (!this.#observers.length) {
	      //...
      }

      this.#cache.notify({ type: 'observerRemoved', query: this, observer })
    }
  }
// ...
  #dispatch(action: Action<TData, TError>): void {
    const reducer = (
      state: QueryState<TData, TError>,
    ): QueryState<TData, TError> => {
      switch (action.type) {
        case 'failed':
          return {
            ...state,
            fetchFailureCount: action.failureCount,
            fetchFailureReason: action.error,
          }
        case 'pause':
          return {
            ...state,
            fetchStatus: 'paused',
          }
        case 'continue':
	        // ...
        case 'fetch':
		    // ...
        case 'success':
	        // ...
        case 'error':
	        // ...
	    // ...
    }

    this.state = reducer(this.state)

    notifyManager.batch(() => {
      this.#observers.forEach((observer) => {
        observer.onQueryUpdate()
        // Query에서 옵저버에게 notify한다. 이후 옵저버의 update함수가 실행된다.
      })

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

  1. Query의 state가 update될 때 실행해야 될 update함수는 dispatch이다.
  2. 내부적으로 reducer함수가 적용되어 있는 것을 볼 수 있었다.

😊관찰자 observer(QueryObserver)는 다음과 같다.

onQueryUpdate(): void {
    this.updateResult()

    if (this.hasListeners()) {
      this.#updateTimers()
    }
  }
// ...
  setOptions(
    options: QueryObserverOptions<
      TQueryFnData,
      TError,
      TData,
      TQueryData,
      TQueryKey
    >,
    notifyOptions?: NotifyOptions,
  ): void {
    const prevOptions = this.options
    const prevQuery = this.#currentQuery

    this.options = this.#client.defaultQueryOptions(options)

    if (
      this.options.enabled !== undefined &&
      typeof this.options.enabled !== 'boolean'
    ) {
      throw new Error('Expected enabled to be a boolean')
    }

    this.#updateQuery()
    this.#currentQuery.setOptions(this.options); // 옵저버에서 Query의 로직이 실행됨
}

  1. Query에는 state를 변경하는 로직이 있다. 이 로직에 내부적으로 주체의 dispatch함수가 실행된다.
  2. 그런데 Query에서는 이 state변경 로직을 실행하진 않는다. Observer의 함수를 실행할 뿐이다.
  3. Query의 내부 state변경 로직은 Observer에서 실행된다. 즉 옵저버의 notify함수 내부에서 실행된다.

자세한 코드는 아래 Tanstack Query 깃 레포에서!

query/packages/query-core/src at main · TanStack/query

🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query. - TanStack/query

github.com

❗React 환경에서 view에 렌더링 되기까지.

  1. 이 부분은 useQuery 훅과 useBaseQuery훅이 관여한다.
  2. useQuery는 단순히 useBaseQuery를 호출한다.
// useQuery
export function useQuery(options: UseQueryOptions, queryClient?: QueryClient) {
  return useBaseQuery(options, QueryObserver, queryClient)
}

// useBaseQuery
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(),
  )

  React.useEffect(() => {
    // Do not notify on updates because of changes in the options because
    // these changes should already be reflected in the optimistic result.
    observer.setOptions(defaultedOptions, { listeners: false })
  }, [defaultedOptions, observer])

  // Handle suspense
  if (shouldSuspend(defaultedOptions, result)) {
    // Do the same thing as the effect right above because the effect won't run
    // when we suspend but also, the component won't re-mount so our observer would
    // be out of date.
    throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary)
  }

자세하게 보려면 조금 어렵지만, 가볍게 본다면 다음과 같을 것이다.

  1. useQuery를 실행하면, useBaseQuery가 실행된다. 이 때 매개변수로 useQeury의 옵션과 옵저버, QueryClient가 들어간다.
  2. useBaseQuery 내부에서 새로운 옵저버 인스턴스를 만든다. (매개변수로 받은 Obeserver 클래스로 생성)
  3. 옵저버를 통해 Query 클래스의 데이터를 감지하고, 옵저버 내부의 update함수를 실행한다.
  4. 이 과정에서 리액트 18버전에 추가된 훅인 useSyncExternalStore가 관여된다.

자세한 코드는 아래 Tanstack Query 깃 레포에서!

query/packages/react-query/src at main · TanStack/query

🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query. - TanStack/query

github.com

 
리액트 쿼리를 이해하는데 도움이 되었던 참조

React Query의 구조와 useQuery 실행 흐름 살펴보기 | 카카오엔터테인먼트 FE 기술블로그

함성준(kevin) 개발에는 인생(喜怒哀樂)이 담겨있습니다. 커피 좋아합니다.

fe-developers.kakaoent.com