실무에서 react-query는 이제 꽤나 사용하는 스택으로 자리 잡았다. 비동기 데이터를 쉽게 가져오고 상태관리에 용이하고 각 종 제공해주는 기능을 통해 편리하게 개발할 수 있도록 도와주고 자주 사용하는 라이브러리인데, 그 내부 로직이 어떻게 구현되어 있길래 이 기능들을 제공해주는걸까 하는 의문을 크게 가져본적은 없던 것 같다. 그래서 이번 기회를 통해 react-query가 어떻게 cache를 관리하고 동작하는지 알아보자.
QueryClient
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
const queryClient = new QueryClient()
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Example />
</QueryClientProvider>
)
}
react-query를 사용할 때 기본적으로 QueryClient라는 인스턴스를 생성해주고 QueryClientProvider의 client로 넘겨주어 전역에서 사용할 수 있게 해준다.
QueryClient의 내부를 봐보자.
export class QueryClient {
constructor(config: QueryClientConfig = {}) {
this.#queryCache = config.queryCache || new QueryCache()
...
...
...
}
}
QueryClient 내부에 정말 많은 역할을 수행하는 코드들이 있지만 패스하고, QueryClient의 인스턴스가 생성될 때 실행될 생성자 함수를 봐보면 QueryClient가 QueryCache라는 인스턴스를 갖게 된다.
QueryCache
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
}
...
...
...
}
QueryCache의 queries에 Query들로 이루어진 Map객체를 저장하고 있는데 여기서 Query가 있는지 조회하여 있으면 그대로 반환하고 없으면 Query 인스턴스를 생성하여 queries에 추가해준다. 이를 build라는 메소드를 통해 수행한다.
Query
그럼 다시, queryCache에 저장되던 Query는 어떻게 이루어져 있는지 살펴보자. Query는 내부에서 flux 패턴을 활용하여 state로 데이터를 관리한다.
#dispatch(action: Action<TData, TError>): void {
const reducer = (
state: QueryState<TData, TError>,
): QueryState<TData, TError> => {
switch (action.type) {
case 'failed':
return {...}
case 'pause':
return {...}
case 'continue':
return {...}
case 'fetch':
return {...}
case 'success':
return {
...state,
data: action.data,
dataUpdateCount: state.dataUpdateCount + 1,
dataUpdatedAt: action.dataUpdatedAt ?? Date.now(),
error: null,
isInvalidated: false,
status: 'success',
...(!action.manual && {
fetchStatus: 'idle',
fetchFailureCount: 0,
fetchFailureReason: null,
}),
}
case 'error':
return {...}
case 'invalidate':
return {...}
case 'setState':
return {...}
}
}
this.state = reducer(this.state)
notifyManager.batch(() => {
this.#observers.forEach((observer) => {
observer.onQueryUpdate()
})
this.#cache.notify({ query: this, type: 'updated', action })
})
}
dispatch 내부에서 action에 따라 각 역할을 수행하는 reduce를 통해 state를 업데이트 하고 observers라는 데이터를 반복을 돌며 onQueryUpdate 메소드를 실행한다.
그렇다면 여기서 observer는 무엇일까?
QueryObserver
export class QueryObserver<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
> extends Subscribable<QueryObserverListener<TData, TError>> {
...
...
updateResult(notifyOptions?: NotifyOptions): void {
const prevResult = this.#currentResult;
const nextResult = this.createResult(this.#currentQuery, this.options)
...
...
// Only notify and update result if something has changed
if (shallowEqualObjects(nextResult, prevResult)) {
return
}
this.#currentResult = nextResult
// Determine which callbacks to trigger
const defaultNotifyOptions: NotifyOptions = {}
const shouldNotifyListeners = (): boolean => {
...
}
if (notifyOptions?.listeners !== false && shouldNotifyListeners()) {
defaultNotifyOptions.listeners = true
}
this.#notify({ ...defaultNotifyOptions, ...notifyOptions })
}
onQueryUpdate(): void {
this.updateResult()
if (this.hasListeners()) {
this.#updateTimers()
}
}
}
queryObserver는 리스너에게 변경을 알린다. Query 클래스의 dispatch 내부에서 수행하였던 onQueryUpdate 를 따라가보면 다시 updateResult메소드를 호출하고 있다. updateResult에서는 이전 쿼리와 현재 쿼리가 같은지 비교 후 같지 않다면 notify 함수를 실행한다.
#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',
})
})
}
여기서 listeners를 순회하면서 listener함수를 호출한다. listeners는 QueryObserver의 부모인 Subscribable의 멤버변수이다.
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()
}
}
...
...
...
}
listeners는 Set객체로 이루어져 있고 subscribe라는 메소드를 통해 listener를 인자로 받아 listeners에 추가된다. 이 subscribe는 언제 어디서 호출될까? 이제 useQuery를 살펴보자.
useQuery
export function useQuery(options: UseQueryOptions, queryClient?: QueryClient) {
return useBaseQuery(options, QueryObserver, queryClient)
}
useQuery는 곧 useBaseQuery로 되어있다.
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,
),
)
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(),
)
}
useBaseQuery는 중요한 많은 로직들을 담고 있다. 처음 QueryClientProvider를 통해 주입했던 client도 보이는데 useQueryClient를 통해 React Context에 접근하여 client를 읽어오고 있다.
useBaseQuery는 Observer 클래스의 인스턴스가 생성되며 이는 state로 관리된다. 다시 말해서, useQuery를 사용하는 각 리액트 컴포넌트마다 QueryObserver 인스턴스를 갖고있는 셈이다.
아래에서, QueryObserver는 리액트 컴포넌트를 리렌더링 시키기 위해 useSyncExternalStore 훅을 사용하여 리액트 외부에서도 상태가 변경되었을 경우 리렌더링이 발생할 수 있도록 해준다. 여기서 바로 observer.subscribe를 호출한다. 이를 통해 리액트 컴포넌트에 대해 리렌더링을 지시한다.
💡 useSyncExternalStore hook은 React 18에서 새롭게 추가된 hook으로 여기서는 설명하지 않아 공식문서를 참고해주시면 좋을 것 같다.
긴 코드의 추적을 끝냈다. 이 흐름을 React-Query를 사용하여 리액트 컴포넌트를 만드는 개발자 입장에서 총정리 해보자.
- 각 리액트 컴포넌트에서 useQuery를 사용하니 useBaseQuery에서 QueryObserver 인스턴스가 state로 생성
- 이 때 QueryObserver는 React.useSyncExternalStore와 listener subscribe를 사용하여 외부에서 상태 변경을 감지하고 알림
- Observer가 updateResult를 통해 이전 쿼리와 현재 쿼리를 비교한다. 쿼리는 Query 클래스로 이루어져 있는데 Flux패턴을 사용해 state를 업데이트 하고 updateResult와 notify 함수 실행
- notify 함수 내부에서 listeners를 순회하며 listener 함수를 실행 ⇒ 즉, 여기서 리액트 컴포넌트에 리렌더링을 지시
마치며
이정도의 로직을 살펴보는데도 굉장히 어렵고 모두가 사용하는 라이브러리, 공통 모듈 등을 만드는것은 정말 쉽지 않겠다 라는 것을 느꼈다.
핵심 로직만 분석해보자 하고 react-query 소스를 까보았는데, 전부 다 핵심 로직인것 같아서 이것저것 버리고 가기가 쉽지 않았다. getOptimisticResult
, useSyncExternalStore notifyManagerhashFn, setOptions
등 더 알아보고 싶은 코드들이 추가로 몇 가지 더 있는데 곧이어 2회 분량으로 작성해볼까 한다.
참고
'Frontend' 카테고리의 다른 글
TDD로 배우는 웹 프론트엔드 강의 후기 (0) | 2024.04.28 |
---|---|
Artillery 서버 부하테스트 오픈소스 알아보기(1) (0) | 2024.03.30 |
[TanStack Query] v5 주요 변경 사항 (0) | 2023.12.10 |
나는 웹 성능 지표를 잘 알고 있었나? (0) | 2023.06.04 |
그래서 모노레포가 뭐지? (feat. yarn workspace) (0) | 2023.04.09 |