서버 상태 관리를 위해 많이 사용하는 React Query는 캐싱, 동기화, 업데이트 등 많은 장점이 있어 자주 사용되는 라이브러리입니다. 그 중에서도 가장 많이 사용되는 것을 꼽자면 데이터 조회를 위해 사용되는 useQuery
기능이라고 생각합니다.
실무에 적용하고 사용하다보니 어떻게 캐싱 기능이 동작하는지, 자동으로 업데이트를 하는지 궁금해졌고 React Query의 useQuery 구현의 일부를 찾아보게 되었습니다. 이 과정을 통해 이전보다 좀 더 React Query를 효율적으로 사용할 수 있었으면 좋겠습니다!
node_modules에서 확인해본 React Query의 라이브러리 내부 구조는 아래와 같습니다.
핵심 기능을 가지고 있는 @tanstack/query-core라는 패키지와 React, Vue에서 사용할 수 있도록 도와주는 react-query, vue-query 패키지가 존재합니다.
npm i @tanstack/react-query
or npm i @tanstack/vue-query
이 과정에서 생겨나는 패키지입니다.
출처: 카카오 FE 기술 블로그
export class QueryClient {
constructor(config: QueryClientConfig = {}) {
this.#queryCache = config.queryCache || new QueryCache()
this.#mutationCache = config.mutationCache || new MutationCache()
this.#defaultOptions = config.defaultOptions || {}
this.#queryDefaults = new Map()
this.#mutationDefaults = new Map()
this.#mountCount = 0
}
invalidateQueries() {}
setQueryData() {}
getQueryData() {}
getQueryCache() {}
//...
}
QueryClient는 Query Cache를 가지며 이 캐시를 사용할 수 있는 메소드를 제공합니다.
export class QueryCache extends Subscribable<QueryCacheListener> {
#queries: QueryStore
constructor(public config: QueryCacheConfig = {}) {
super()
this.#queries = new Map<string, Query>()
}
build<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
client: QueryClient,
options: WithRequired<
QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
'queryKey'
>,
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({
client,
queryKey,
queryHash,
options: client.defaultQueryOptions(options),
state,
defaultOptions: client.getQueryDefaults(queryKey),
})
this.add(query)
}
return query
}
// 쿼리 데이터 설정
setQueryData(queryKey, data) {
this.queries.set(queryKey, data);
}
// 쿼리 데이터 가져오기
getQueryData(queryKey) {
return this.queries.get(queryKey);
}
// 쿼리 무효화
invalidateQueries(queryKey) {
// 쿼리 무효화 로직
// 예: 쿼리 상태를 'stale'로 설정
}
// 쿼리 삭제
removeQueries(queryKey) {
this.queries.delete(queryKey);
}
// 쿼리 상태 구독
subscribe(callback) {
// 상태 변경 시 콜백 호출
}
// 모든 쿼리 데이터 삭제
clear() {
this.queries.clear();
}
}
Query Cache는 내부 프로퍼티인 queries
라는 Map 형태에 등록하여 관리합니다.
Query의 Build 메서드가 실행될 때,
let query = this.get<TQueryFnData, TError, TData, TQueryKey>(queryHash)
queryHash를 기반으로 queries를 조회하여 인스턴스를 조회하고, 존재한다면 반환하고 존재하지 않는다면 생성 후, 캐시에 등록한 다음 반환합니다.
class Query {
constructor(config: QueryConfig<TQueryFnData, TError, TData, TQueryKey>) {
super()
this.queryKey = config.queryKey; // 쿼리의 고유 키
this.setOptions(config.options)
this.state = config.state ?? this.#initialState;
this.cache = config.cache
this.observers= [];
this.subscribers = []; // 상태 변경 시 호출될 콜백 함수 목록
}
#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':
//...
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':
const error = action.error
if (isCancelledError(error) && error.revert && this.#revertState) {
return { ...this.#revertState, fetchStatus: 'idle' }
}
return {
...state,
error,
errorUpdateCount: state.errorUpdateCount + 1,
errorUpdatedAt: Date.now(),
fetchFailureCount: state.fetchFailureCount + 1,
fetchFailureReason: error,
fetchStatus: 'idle',
status: '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 })
})
}
}
setData();
setError();
setLoading();
notifySubscribers();
subscribe();
setOptions();
//...
}
Query 값의 변화가 발생하는 경우 observer에게 알림을 주기 위해, Dispatch, Action, Reducer를 사용합니다. Switch 문을 통해 Action 타입에 따라 Reducer를 사용하여 State를 업데이트하고, Quer를 구독하고 있는 Observer의 메서드를 실행하고 있습니다.
export class QueryObserver extends Subscribable {
constructor() {}
updateResult(): void {
const prevResult = this.#currentResult as
| QueryObserverResult<TData, TError>
| undefined
const nextResult = this.createResult(this.#currentQuery, this.options)
this.#currentResultState = this.#currentQuery.state
this.#currentResultOptions = this.options
if (this.#currentResultState.data !== undefined) {
this.#lastQueryWithDefinedData = this.#currentQuery
}
// Only notify and update result if something has changed
if (shallowEqualObjects(nextResult, prevResult)) {
return
}
this.#currentResult = nextResult
const shouldNotifyListeners = (): boolean => {
//...
}
this.#notify({ listeners: shouldNotifyListeners() })
}
onQueryUpdate(): void {
this.updateResult()
if (this.hasListeners()) {
this.#updateTimers()
}
}
onSubscribe();
onUnSubscribe();
destroy();
Query Obsever는 데이터의 변화를 관찰하고 리스너에게 알려줍니다. shallowEqualObjects 메서드를 통해 데이터의 변화를 관찰, 변화가 생겼다면 shouldNotifyListeners 통해 이전 결과가 있는지, 이전 결과와 비교하여 어떤 속성이 변경되었는지를 확인하고, 특정 조건에 따라 알림을 보낼지를 결정합니다.
코드의 흐름을 통해 살펴본 useQuery의 흐름은 아래와 같습니다.
1. Query 객체에 변화가 발생합니다.
2. Query와 연결되어 있는 QueryObserver에게 상태가 변경되었음을 알려줍니다.
3. QueryObserver는 연결된 Listener에게 Query의 상태 변화가 일어났는지 판단하고 Listener에게 알려야할지 결정합니다.
4. Listener는 업데이트 된 Query의 값을 기반으로 데이터를 새로 갱신합니다.
흐름도만 정리해도 너무 길어지는 것 같습니다. 다음주에 React를 기반으로 useQuery의 흐름과 컴포넌트가 어떻게 업데이트 되는지 알아보겠습니다. 감사합니다!