옵저버 패턴과 Tanstack Query에서의 옵저버 패턴
우아한 테크코스 Level1을 하면서, 바닐라 JS에 대한 이해도가 조금 생긴 것 같다.
이 과정에서 웹을 구성하는 핵심이 무엇일까?, 바닐라JS로 웹을 구현하는데 핵심이 무엇일까? 생각해 보았을때, 나는 ‘비동기로 실행되는 이벤트’라고 생각했다. 즉, 상태가 계속해서 변할 때 이벤트를 어떻게 효율적으로 실행하는가? 이다.
리액트에서는 useEffect 와 useState 와 더불어, Redux React Query등을 사용하며, ‘상태를 감지하는 시스템’을 통해 간편하게 관리를 하지만, 바닐라에서는 어떻게 구현하면 좋을까? 생각을 많이 했다.
그 중에서 눈에 띄었던 기능은 ‘웹 컴포넌트’에서의 observedAttributes 메서드, 그리고 Mutation Observer를 통해서 DOM에 있는 요소를 상태 감지하여 함수를 실행할 수 있다.
이것 외로 Intersection Observer API 와 같이, ‘Observer’라는 용어를 많이 볼 수 있었고, 웹에서 이벤트를 발생시키는 좋은 아이디어라고 생각되어, 이번 기회에 ‘옵저버 패턴’ 그리고 리액트의 ‘Tanstack Query(React Query)’를 파헤쳐 보기로 했다!!
우선 옵저버 패턴이 무엇인지에 대해서 알아보도록 하자.
❓옵저버 패턴은 무엇인가
- 말 그대로 Observer, 즉 관찰자를 생성하여 '관찰' 하는 것.
- 주로 ‘분산 이벤트 핸들링 시스템’에서 사용
- 프로그래밍적으로 옵저버 패턴은 사실 '관찰' 하기 보단 갱신을 위한 힌트 정보를 '전달' 받길 기다린다고 보는 것이 적절하다. (이 부분에서 헷갈렸음. 옵저버가 어떻게 대상의 변화를 감지할 수 있는가? 에 대한 해답이 풀림)
- 관찰을 당하는 대상, Subject. 그리고 관찰자(구독자, 이벤트 알림 수신자의 역할), Observer로 구성된다.
- 옵저버 패턴를 그림으로 본다면 다음과 같이 구성된다.
(그림에 표시된 update는 update함수가 아닌, 사용자로 인한 state의 동적 변화를 나타낸 것이다.. 동적인 변화를 옵저버에게 알리면, 옵저버가 주체의 update함수를 실행한다.)
- 옵저버 패턴에서, Observer와 Subject는은 다음과 같은 기본 구조를 가진다.
- Observer내부에서 Subject의 update함수를 실행하고, Observer의 notify함수는 Subject에서 실행되는 구조.
- 즉 Subject는 Observer에게 notify하고, Observer는 Subject의 이벤트를 감지하여 update한다.
❓그래서 옵저버 패턴을 왜 쓸까요?
- 여러개의 분산 이벤트를 '구독' 할 수 있다는 것.
- 이 '구독' 시스템을 통해, '원하는 주체'에만 이벤트를 실행시킬 수 있다는 것
- 그리고 주체 (subject)에서 직접 이벤트를 실행하는 것이 아니기 때문에, 느슨한 결합을 유지하여 확장성이 용이하다는 것
- 이는 분산 이벤트의 수가 많아질 수록 빛을 바라는 패턴이라고 생각이 들었다.
아래 링크를 통해 옵저버 패턴을 더 자세히 알아보도록 하자. 개인적으로 제일 이해하기 쉬웠던 참조이다.
❗️React Query에서의 옵저버 패턴
리액트 쿼리의 구조는 다음과 같다.
query-core/src ├── notifyManager.ts ├── query.ts ├── queryCache.ts ├── queryClient.ts └── queryObserver.ts
react-query/src ├── useBaseQuery.ts └── useQuery.ts
- Tanstack Query 의 기본적인 로직을 담당하는 query-core/src 소스파일이 있고, 리액트 훅을 담당하는 코드인 react-query/src 소스파일이 있다.
- React Query에서의 주체(subject)는 Query클래스 이다.
- Observer는 QueryObserver클래스 이다.
- 한눈에 간단하게 파일의 구조를 본다면 다음과 같다.
- React Query 에서의 옵저버 인터페이스는 Removable과 Subscribable 으로 구성된다.
- Subject의 인터페이스는 Removable로 구성된다. (Removable은 gcTime을 관리한다.)
- gcTime은 가비지컬렉팅 타임이다. 즉 gcTime을 통해 remove하는기능을 가진다. (구독 해지)
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 })
})
}
}
- Query의 state가 update될 때 실행해야 될 update함수는 dispatch이다.
- 내부적으로 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의 로직이 실행됨 }
- Query에는 state를 변경하는 로직이 있음. 이 로직에 내부적으로 dispatch함수가 실행됨.
- 그런데 Query에서는 이 state변경 로직을 실행하진 않음. Observer의 함수를 실행할 뿐.
- Query의 내부 state변경 로직은 Observer에서 실행됨.
자세한 코드는 아래 Tanstack Query 깃 레포에서!
❗️React 환경에서 view에 렌더링 되기까지.
- 이 부분은 useQuery 훅과 useBaseQuery훅이 관여한다.
- 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);
}
자세하게 보려면 조금 어렵지만, 가볍게 본다면 다음과 같을 것이다.
- useQuery를 실행하면, useBaseQuery가 실행된다. 이 때 매개변수로 useQeury의 옵션과 옵저버, QueryClient가 들어간다.
- useBaseQuery 내부에서 새로운 옵저버 인스턴스를 만든다. (매개변수로 받은 Obeserver 클래스로 생성)
- 옵저버를 통해 Query 클래스의 데이터를 감지하고, 옵저버 내부의 update함수를 실행한다.
- 이 과정에서 리액트 18버전에 추가된 훅인 useSyncExternalStore가 관여된다.
자세한 코드는 아래 Tanstack Query 깃 레포에서!
리액트 쿼리를 이해하는데 도움이 되었던 참조
🔗 https://fe-developers.kakaoent.com/2023/230720-react-query/