옵저버 패턴과 Tanstack Query에서의 옵저버 패턴

thumbnail

우아한 테크코스 Level1을 하면서, 바닐라 JS에 대한 이해도가 조금 생긴 것 같다.

이 과정에서 웹을 구성하는 핵심이 무엇일까?, 바닐라JS로 웹을 구현하는데 핵심이 무엇일까? 생각해 보았을때, 나는 ‘비동기로 실행되는 이벤트’라고 생각했다. 즉, 상태가 계속해서 변할 때 이벤트를 어떻게 효율적으로 실행하는가? 이다.

리액트에서는 useEffectuseState 와 더불어, Redux React Query등을 사용하며, ‘상태를 감지하는 시스템’을 통해 간편하게 관리를 하지만, 바닐라에서는 어떻게 구현하면 좋을까? 생각을 많이 했다.

그 중에서 눈에 띄었던 기능은 ‘웹 컴포넌트’에서의 observedAttributes 메서드, 그리고 Mutation Observer를 통해서 DOM에 있는 요소를 상태 감지하여 함수를 실행할 수 있다.

링크 미리보기 이미지Using custom elements - Web APIs | MDNOne 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.

링크 미리보기 이미지MutationObserver - Web APIs | MDNThe 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.

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

링크 미리보기 이미지Intersection Observer API - Web APIs | MDNThe 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.

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

❓옵저버 패턴은 무엇인가

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

하나의 옵저버 다이어그램

실제로는 여러개의 분산 이벤트가 복합적 연결된 옵저버 다이어그램

(그림에 표시된 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. 이는 분산 이벤트의 수가 많아질 수록 빛을 바라는 패턴이라고 생각이 들었다.

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

링크 미리보기 이미지

❗️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. 한눈에 간단하게 파일의 구조를 본다면 다음과 같다.

TanStack Query의 옵저버 패턴

  1. React Query 에서의 옵저버 인터페이스는 RemovableSubscribable 으로 구성된다.
  2. Subject의 인터페이스는 Removable로 구성된다. (Removable은 gcTime을 관리한다.)
  3. gcTime은 가비지컬렉팅 타임이다. 즉 gcTime을 통해 remove하는기능을 가진다. (구독 해지)

Tanstack Query의 객체

export abstract class Removable { gcTime!: number #gcTimeout?: ReturnType<typeof setTimeout> //...

주체 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에서 실행됨.

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

❗️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

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

🔗 https://fe-developers.kakaoent.com/2023/230720-react-query/