안녕하세요. 인재 채용 플랫폼 잡다의 웹 프론트엔드 개발을 하고 있는 배준형입니다.
직전 포스팅에서 React-Query 동작 원리 중 Query-Core의 QueryClient, QueryCache에 대해 알아보았는데요. 그 과정에서 React-Query가 데이터를 어떻게 캐싱하는지, 어떻게 최적화해서 API를 호출하는지 등 일부 파편화된 핵심 로직들을 확인해 보았습니다.
이번에는 아직 확인해 보지 못한 QueryObserver
, NotifyManager
같은 요소들을 확인해 보고 React-Query의 data fetching 전반적인 흐름을 알아보겠습니다.
※ 여기서는 Tanstack Query v5.18.0 버전으로 살펴봅니다. 버전별로 내용이 다를 수 있으니 참고 부탁드립니다.🙏
이해를 돕기 위해 가져온 코드 중 일부 내용, Generic Type 등은 생략했습니다.
https://github.com/TanStack/query
직전 포스팅을 확인하면 글 내용을 이해하는 데 도움이 될 수 있습니다.
[React] React-Query 동작 원리 (1)
QueryObserver
는 특정 쿼리의 상태를 추적하고 관찰하는 역할을 수행합니다.
query-core/src/queryObserver.ts
export class QueryObserver extends Subscribable {
#client: QueryClient
#currentQuery: Query = undefined!
#currentResult: QueryObserverResult = undefined!
#currentResultOptions?: QueryObserverOptions
// ...
constructor(
client: QueryClient,
public options: QueryObserverOptions,
) {
super()
this.#client = client
// ...
}
// ...
onQueryUpdate(): void {
this.updateResult()
if (this.hasListeners()) {
this.#updateTimers()
}
}
// ...
}
#client
: QueryClient 인스턴스. 쿼리 실행 등을 위해 저장#currentQuery
: 현재 실행 중인 쿼리#currentResult
: 현재 쿼리 결과#currentResultOptions
: 현재 쿼리 결과 관련 설정QueryObserver
는 쿼리의 상태가 변경되었다면 이를 감지하여 구독하고 있는 컴포넌트에 알립니다. 이렇게 함으로써, 쿼리의 상태 변화를 실시간으로 UI에 반영할 수 있는 것이죠.
React에서 React-Query를 사용하려면 QueryProvider Context를 사용하여 QueryClient를 내려줘야 합니다.
// React-Query 사용 시 QueryClient 세팅
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
<Page />
</QueryClientProvider>
)
}
React-Query를 사용할 때 QueryClient
는 총 1개만 생성되고, 해당 QueryClient
에서 QueryCache
1개만 생성하여 멤버 변수로 저장해 사용합니다. QueryCache
에서는 #queries
Map 객체에 쿼리를 캐싱하여 사용하는데, 이 Query의 데이터 변경, 재실행 등의 변화가 발생했을 때 QueryObserver
가 이를 감지하여 컴포넌트에 알리도록 구현이 되어 있어요.
그러면 QueryObserver
는 쿼리가 변경되었음을 어떻게 감지할까요??
Query
는 #dispatch
메서드로 상태를 변경하고, 이를 통해 QueryObserver
에게 상태 변화를 알립니다.
#dispatch(action: Action<TData, TError>): void {
const reducer = (
state: QueryState<TData, TError>,
): QueryState<TData, TError> => {
switch (action.type) {
case 'failed':
// ...
notifyManager.batch(() => {
this.#observers.forEach((observer) => {
observer.onQueryUpdate()
})
this.#cache.notify({ query: this, type: 'updated', action })
})
}
#dispatch
메서드 내부에서 쿼리의 상태 변화를 모든 관찰자에게 알리기 위해 observer의 onQueryUpdate
메서드를 호출합니다.
그 과정에서 notifyManager
batch
메서드가 사용되는데요. batch
메서드는 모든 상태 변경이 동시에 발생해도 한 번에 처리할 수 있도록 해줍니다.
notifyManager
는 여러 상태 변경을 한 번에 처리하는 역할을 합니다.
export function createNotifyManager() {
let queue: Array<NotifyCallback> = []
let transactions = 0
let notifyFn: NotifyFunction = (callback) => {
callback()
}
let batchNotifyFn: BatchNotifyFunction = (callback: () => void) => {
callback()
}
let scheduleFn: ScheduleFunction = (cb) => setTimeout(cb, 0)
const setScheduler = (fn: ScheduleFunction) => {}
const batch = <T>(callback: () => T): T => {}
const schedule = (callback: NotifyCallback): void => {}
const batchCalls = (callback: BatchCallsCallback): BatchCallsCallback => {}
const flush = (): void => {}
// ...
}
// SINGLETON
export const notifyManager = createNotifyManager()
batch
메서드는 여러 상태 변경을 한 번에 처리하는 역할을 합니다. 이 과정은 transaction
변수를 통해 이루어지는데요.
notifyManager
는 하나의 인스턴스만 생성(Sington 패턴)되고, 내부에서 정의된 batch
같은 중첩된 함수들을 반환합니다. 이는 자바스크립트 클로저(closure) 개념을 활용한 것으로 외부에서는 transaction
값에 접근할 수 없지만, 내부에서 정의된 함수에선 해당 값에 접근할 수 있으므로 여러 상태 변경이 동시에 발생해도 안정적으로 처리할 수 있게 됩니다.
QueryObserver
는 Query
인스턴스의 상태 변경을 구독하고, Query
인스턴스는 상태 변경이 발생할 때마다 이를 notifyManager
를 통해 처리합니다. 이렇게 함으로써, QueryObserver
는 Query
인스턴스의 상태 변경을 실시간으로 반영할 수 있는 것이죠.
지금까지의 내용을 바탕으로 QueryClient를 통해 data fetching 전반적인 흐름을 살펴보겠습니다.
await queryClient.fetchQuery({
queryKey: key,
queryFn: fetchFn,
})
fetchQuery
를 호출하여 비동기 데이터를 가져온다고 가정하겠습니다. 그러면 QueryCache
의 build
메서드를 통해 캐싱된 쿼리를 가져오고 query.fetch
함수를 호출합니다.
fetchQuery(options: FetchQueryOptions,): Promise<TData> {
// ...
const query = this.#queryCache.build(this, defaultedOptions)
return query.isStaleByTime(defaultedOptions.staleTime)
? query.fetch(defaultedOptions)
: Promise.
}
query.fetch
함수 내부에선 조건에 따라 실행을 중단하거나 options
를 세팅하는 여러 로직을 거쳐 fetching
이 진행될 때 dispatch
메서드를 호출하여 상태를 변경해 줍니다.
fetch(options?: QueryOptions, fetchOptions?: FetchOptions): Promise<TData> {
// ...
// Set to fetching state if not already in it
if (
this.state.fetchStatus === 'idle' ||
this.state.fetchMeta !== context.fetchOptions?.meta
) {
this.#dispatch({ type: 'fetch', meta: context.fetchOptions?.meta })
}
// Try to fetch the data
this.#retryer = createRetryer({
// ...
onSuccess: (data) => {
// ...
this.setData(data)
// ...
},
// ...
})
this.#promise = this.#retryer.promise
return this.#promise
}
setData(newData: TData, options?: SetDataOptions & { manual: boolean }): TData {
const data = replaceData(this.state.data, newData, this.options)
// Set data and mark it as cached
this.#dispatch({
data,
type: 'success',
dataUpdatedAt: options?.updatedAt,
manual: options?.manual,
})
return data
}
많은 부분이 생략됐지만, 요약하자면
fetching
해야 하는 조건을 만족하면 #dispatch
메서드로 쿼리의 상태를 fetch
로 바꾼다.createRetryer
내부에서 이루어지는데, 만약 성공한다면 setData
메서드를 호출한다.setData
내부에서 쿼리에 fetching 된 데이터를 저장하고 상태를 success
로 바꾼다.의 흐름으로 data fetching이 이루어집니다.
query.fetch() 내부에서 error 없이 정상 success 되었다면 fetch
, success
상태 변경으로 총 2회의 #dispatch가 호출됩니다.
#dispatch(action: Action<TData, TError>): void {
const reducer = (
state: QueryState<TData, TError>,
): QueryState<TData, TError> => {
switch (action.type) {
case 'failed': // ...
case 'pause': // ...
case 'continue': // ...
case 'fetch':
return {
...state,
fetchStatus: canFetch(this.options.networkMode)
? 'fetching'
: 'paused',
status: 'pending',
// ...
}
case 'success':
return {
...state,
data: action.data,
status: 'success',
fetchStatus: 'idle',
// ...
}
case 'error': // ...
case 'invalidate': // ...
case 'setState': // ...
}
}
this.state = reducer(this.state)
notifyManager.batch(() => {
this.#observers.forEach((observer) => {
observer.onQueryUpdate()
})
this.#cache.notify({ query: this, type: 'updated', action })
})
}
#dispatch 내부에선 정의된 reducer에 의해 state를 업데이트한 뒤 notifyManager
, observer
를 통해 쿼리가 업데이트되었음을 모든 관찰자에게 알리게 됩니다.
observer
는 onQueryUpdate
메서드를 호출하여 쿼리를 업데이트하고, cache
의 notify
메서드를 호출하여 구독(subscribe)되어 있는 콜백 함수들을 모두 호출하게 됩니다.
queryClient.fetchQuery()
로 데이터 페칭 시작queryCache.build()
로 캐시된 쿼리 객체 가져옴query.fetch()
로 실제 데이터 요청query.dispatch()
로 쿼리 상태 업데이트observer
의 onQueryUpdate()
로 최신 상태 반영notifyManager
로 한번에 업데이트cache.notify()
로 변경 알림지금까지 알아본 내용에 의하면 위의 과정으로 data fetching이 진행됩니다. 그 과정에서 query caching, batch 등의 최적화가 이루어집니다.
지금까지는 react가 아닌 query-core의 핵심 내용만 알아보았습니다. 이제 드디어 react-query 파트를 알아볼 수 있을 것 같아요.
react에서 해당 내용들을 활용할 수 있는 여러 hook 들이 존재하는데, 그중 data fetching 관련 useQuery
hook을 알아보겠습니다.
export function useQuery(options: UseQueryOptions, queryClient?: QueryClient) {
return useBaseQuery(options, QueryObserver, queryClient)
}
useQuery
는 options, queryClient를 인자로 받아서 QueryObserver
와 함께 useBaseQuery
로 전달해 주는 역할을 합니다.
react-query/src/useBaseQuery.ts
export function useBaseQuery(
options: UseBaseQueryOptions,
Observer: typeof QueryObserver,
queryClient?: QueryClient,
): QueryObserverResult {
// ...
const client = useQueryClient(queryClient)
const isRestoring = useIsRestoring()
// ...
const [observer] = React.useState(
() => new Observer(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
}
useBaseQuery
를 하나씩 살펴보면, useQueryClient
hook을 호출하여 전달 인자로 받은 queryClient
또는 Provider를 통해 미리 전달해 놓은 queryClient
를 받아옵니다.
react-query/src/QueryClientProvider.tsx
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
}
위에서 react-query를 사용할 때 QueryClient
를 Provider로 넘겨준다고 했습니다. 그러면 useQueryClient
hook으로 미리 넘겨준 QueryClient
를 받아올 수 있게 되니 Provider로 미리 넘겨줬다면 useQuery를 사용할 때 queryClient를 넘겨주는 것과 상관없이 QueryClient
를 반환합니다.
const [observer] = React.useState(
() => new Observer(client, defaultedOptions),
)
다음으로 observer
를 생성하는데, useState
를 이용해서 React state로 정의한 코드입니다. 보통 useState를 사용하면 setState까지 같이 사용하는데, 무엇 때문에 state만 반환하도록 설계했을까요?
이 부분은 해당 코드만으로 추론하긴 어렵고, 코드 작성자의 의견이나 어떤 과정을 거쳐서 useState로 왔는지 파악이 필요할 것 같아요.
useState를 사용하기 전 Observer 생성 코드 Pull Request
// Create query observer
const observerRef = React.useRef<QueryObserver<any, any, any, any>>()
const observer =
observerRef.current || new Observer(queryClient, defaultedOptions)
observerRef.current = observer
Github Pull Request를 확인해 보니 이전에는 useRef
를 사용해서 생성했었네요. useQuery
가 호출될 때마다 1개의 QueryObserver
만 생성해서 재활용하려고 했던 것으로 판단됩니다.
useState로 변경된 Pull Requset
refactor: use react-state for one-time initialization over instance refs
// AS-IS
const obsRef = React.useRef<
QueryObserver<...>
>()
if (!obsRef.current) {
obsRef.current = new Observer<...>(queryClient, defaultedOptions)
}
// TO-BE
const [observer] = React.useState(
() =>
new Observer<...>(
queryClient,
defaultedOptions
)
)
다음 수정 사항에서 useState로 변경됩니다. 그 이유는
이라고 합니다. 1회만 생성해서 재활용하려는 목적은 유지되는 것 같아요.
추론해 보자면 useQuery
를 호출할 때마다 1개의 QueryObserver
만 생성해서 재활용하기 위해 setState 없이 작성된 것 같습니다.
여기서 의문점이 생겼습니다. useQuery
가 호출되어서 일련의 과정을 거쳐 Query
의 상태가 업데이트되었어도 그 Query
의 상태는 React에서 바라보는 state도 아니고 context도 아닙니다. Query
의 상태를 업데이트시켜도 컴포넌트에서는 해당 값에 의해 리렌더링이 일어나지 않아야 하는 것이죠.
state로 생성된 observer
로 변화를 감지하여 알릴 때도 setObserver
데이터를 변경시키는 것도 아니고 observer
의 내부 메서드를 통해서 알리게 되는데요.
그러면, 생성된 QueryObserver
가 쿼리의 상태가 변경되었음을 감지하여 컴포넌트에 알렸을 때 React Component에서 리렌더링은 어떻게 이루어질까요?
useSyncExternalStore
답은 useSyncExternalStore
훅에 있습니다. 이 훅은 외부 스토어를 구독할 수 있는 React 훅으로 useBaseQuery
를 호출하면 내부에서 useSyncExternalStore
훅을 호출하여 상태를 구독하는데요.
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(),
)
useSyncExternalStore(subscribe, getSnapshot, optional getServerSnapshot)
subscribe
: 하나의 callback
인자를 받아 스토어를 구독하는 함수.callback
을 호출해야 하고, 이로부터 컴포넌트가 리렌더링 됩니다.getSnapshot
: 컴포넌트에 필요한 스토어 데이터의 스냅샷을 반환하는 함수.getSnapshot
을 반복적으로 호출하면 동일한 값을 반환해야 합니다.getServerSnapshot
: 스토어에 있는 데이터의 초기 스냅샷을 반환하는 함수.QueryObserver
의 내부 구현에 따라 subscribe
함수를 호출하여 구독할 때도 queryFn
이 호출됩니다. 해당 훅을 이용해서 query
의 상태 변경이 발생하면 queryObserver.subscribe
함수가 호출되고, useSyncExternalStore
내부 로직에 의해 React 컴포넌트의 리렌더링이 일어납니다.
그림이 도움이 될 지는 모르겠네요😂
QueryClient
1개, QueryCache
1개만 존재QueryProvider
로 QueryClient
를 넘겨서 동일한 QueryClient
를 사용하도록 설정useQuery
호출 → useBaseQuery
호출useQueryClient
로 QueryClient
를 받아와 사용QueryObserver
생성 후 useSyncExternalStore
훅으로 외부 데이터 구독Observer
의 subscribe
→ onSubscribe
순서대로 호출onSubscribe
내부의 executeFetch
에서 updateQuery
를 통해 Query
생성build
메서드로 캐싱된 쿼리가 있다면 캐싱된 쿼리를 쓰지만, 여기선 첫 호출이므로 생성됨.Query
의 fetch
로 data fetching 후 상태 업데이트Observer
의 subscribe
로 상태 변화를 알림notifyManager
batch
로 여러 상태 변경을 한 번에 처리useSyncExternalStore
에 의해 컴포넌트 리렌더링순서가 될 것 같습니다.
QueryObserver
: Query의 상태 변화를 감지하고, 상태 변경 시 모든 관찰자에게 알리는 역할을 수행notifyManager
: 클로저 기능을 활용하여 여러 상태 업데이트를 안정적으로 한 번에 처리함.useQuery
/ useBaseQuery
: 호출 시 Observer를 생성 → Observer.subscribe로 Query 생성 → Query에서 fetch 진행 → 상태 변경 시 notifyManager를 통해 한 번에 업데이트 처리 → 결과 반환이번에는 QueryClient.fetchQuery
, useQuery
를 호출했을 때 실질적으로 data fetching이 어떻게 이루어지는지 알아보았습니다. 이제 어떻게 data fetching이 이루어지는지 알게 되었지만, 내부적으로 살펴보지 않은 여러 동작이 많이 남아있는 것을 알게돼서 알아가면 알아갈수록 모르는 게 더 많아지는 느낌도 듭니다😂
그럼에도 코드를 분석하고 이해하는 과정에서 새롭게 알게 되는 내용들이 많아서 좋았고(useSyncExternalStore
등) 아직 알아보지 못한 내용들을 확인하면서도 더 도움이 될 것이라는 믿음도 생깁니다.
뒤에 얼마나 많은 포스팅들이 추가될 지는 모르겠지만, 아직 직전 포스트에서 언급했던 SSR 지원 같은 내용들도 어떻게 동작하는지 모르기에 관련 내용들을 하나씩 뜯어보고, 천천히 알아보고자 합니다.