“그래서 useQuery가 어떻게 렌더링을 야기하지?” 라는 의문이 들었고 ‘그러게..!?’ 라는 생각이 들어 문서와 tkdodo님의 블로그와 여러 개발블로그 그리고 직접 코드 타고 들어가보기를 하면서 나름의 이해를 세워보려고 한다!
https://tkdodo.eu/blog/inside-react-query
tkdodo 님의 블로그 글을 참고하였고, 첨부 이미지를 사용했습니다
https://fe-developers.kakaoent.com/2023/230720-react-query
추상적 구조도는 이렇다. 이를 이루고 있는 요소들을 먼저 알아보자!
App.tsx에서 QueryClient 인스턴스를 하나 만들고, Provider를 통해 앱 전체에서 사용할 수 있게 했다.
→ 즉, 전역상태가 되었다.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 0,
throwOnError: true,
},
},
queryCache: new QueryCache({
onError: () => {},
}),
});
const persistor = persistStore(store);
export const App = () => {
return (
<QueryClientProvider client={queryClient}>
그리고 QueryClient는 생성될 때 QueryCache 객체를 가진다!
QueryClient는 QueryCache와 MutationCache의 컨테이너일 뿐이다.
간단히 말해, QueryCache는 인메모리 객체로, 키는 안정적으로 직렬화된 버전의 쿼리키(쿼리키해시라고 함)이고 값은 쿼리 클래스의 인스턴스입니다. 관련문서
var QueryCache = class extends Subscribable {
constructor(config = {}) {
super();
this.config = config;
this.#queries = /* @__PURE__ */ new Map();
}
queries 맵 객체를 가지고 있군!!
일반적으로 쿼리캐시와 직접 상호 작용하지 않고 특정 캐시에 대해 쿼리클라이언트를 사용한다.
즉, 캐시에는 쿼리들의 정보(데이터 상태 등)가 포함되고, 쿼리 함수 실행-재시도-취소등 로직이 포함된다!!
쿼리는 쿼리 데이터에 관심이 있는 관찰자를 파악하고 해당 관찰자에게 모든 변경 사항을 알릴 수 있다!!
useQuery를 사용할 때 같은 키를 사용한다면, 동일한 쿼리를 여러개의 쿼리옵저버들이 공유하는 것!
useQuery를 호출할 때 생성되고, 정확히 하나의 쿼리를 구독한다! 그때 보내는 쿼리키가 식별자 역할인 것 같다
쿼리와 그 쿼리를 사용하고자 하는 컴포넌트를 잇는 역할이다
전체적인 구조가 다음과 같이 된다!
컴포넌트 관점으로는,
위 과정을 내부 코드를 열어보면서 자세히 이해해보쟛 !!
useQuery 내부는 사실 이것뿐이다.
import { QueryObserver } from "@tanstack/query-core";
import { useBaseQuery } from "./useBaseQuery.js";
function useQuery(options, queryClient) {
return useBaseQuery(options, QueryObserver, queryClient);
}
그렇다면 useBaseQuery를 봐야겠네 ?
useBaseQuery.js
"use client"; // src/useBaseQuery.ts import * as React from "react"; import { notifyManager } from "@tanstack/query-core"; import { useQueryErrorResetBoundary } from "./QueryErrorResetBoundary.js"; import { useQueryClient } from "./QueryClientProvider.js"; import { useIsRestoring } from "./isRestoring.js"; import { ensurePreventErrorBoundaryRetry, getHasError, useClearResetErrorBoundary } from "./errorBoundaryUtils.js"; import { ensureStaleTime, fetchOptimistic, shouldSuspend } from "./suspense.js"; function useBaseQuery(options, Observer, queryClient) { if (process.env.NODE_ENV !== "production") { if (typeof options !== "object" || Array.isArray(options)) { throw new Error( 'Bad argument type. Starting with v5, only the "Object" form is allowed when calling query related functions. Please use the error stack to find the culprit call. More info here: https://tanstack.com/query/latest/docs/react/guides/migrating-to-v5#supports-a-single-signature-one-object' ); } } **const client = useQueryClient(queryClient);** const isRestoring = useIsRestoring(); const errorResetBoundary = useQueryErrorResetBoundary(); const defaultedOptions = client.defaultQueryOptions(options); defaultedOptions._optimisticResults = isRestoring ? "isRestoring" : "optimistic"; ensureStaleTime(defaultedOptions); ensurePreventErrorBoundaryRetry(defaultedOptions, errorResetBoundary); useClearResetErrorBoundary(errorResetBoundary); **const [observer] = React.useState( () => new Observer( client, defaultedOptions ) );** **const result = observer.getOptimisticResult(defaultedOptions);** React.useSyncExternalStore( React.useCallback( (onStoreChange) => { **const unsubscribe = isRestoring ? () => void 0 : observer.subscribe(notifyManager.batchCalls(onStoreChange));** observer.updateResult(); return unsubscribe; }, [observer, isRestoring] ), () => observer.getCurrentResult(), () => observer.getCurrentResult() ); React.useEffect(() => { **observer.setOptions(defaultedOptions, { listeners: false });** }, [defaultedOptions, observer]); if (shouldSuspend(defaultedOptions, result)) { throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary); } if (getHasError({ result, errorResetBoundary, throwOnError: defaultedOptions.throwOnError, query: client.getQueryCache().get(defaultedOptions.queryHash) })) { throw result.error; } **return !defaultedOptions.notifyOnChangeProps ? observer.trackResult(result) : result;** } export { useBaseQuery };
순서를 매겨 간단하게 요약하자면,
과정을 더 자세히 알아볼 부분이 두가지 있다.
const [observer] = React.useState(
() => new Observer(
client,
defaultedOptions
)
);
const result = observer.getOptimisticResult(defaultedOptions);
React.useSyncExternalStore(
React.useCallback(
(onStoreChange) => {
const unsubscribe = isRestoring ? () => void 0 : observer.subscribe(notifyManager.batchCalls(onStoreChange));
observer.updateResult();
return unsubscribe;
},
[observer, isRestoring]
),
() => observer.getCurrentResult(),
() => observer.getCurrentResult()
);
React.useEffect(() => {
observer.setOptions(defaultedOptions, { listeners: false });
}, [defaultedOptions, observer]);
useBaseQuery.js
const [observer] = React.useState( () => new Observer( client, defaultedOptions ) );
queryObserver.js
var QueryObserver = class extends Subscribable { constructor(client, options) { super(); this.options = options; this.#client = client; this.#selectError = null; this.bindMethods(); this.setOptions(options); }
먼저 setOptions가 실행된다.
setOptions(options, notifyOptions) { const prevOptions = this.options; const prevQuery = this.#currentQuery; this.options = this.#client.defaultQueryOptions(options); if (this.options.enabled !== void 0 && typeof this.options.enabled !== "boolean") { throw new Error("Expected enabled to be a boolean"); } this.#updateQuery(); this.#currentQuery.setOptions(this.options); if (prevOptions._defaulted && !shallowEqualObjects(this.options, prevOptions)) { this.#client.getQueryCache().notify({ type: "observerOptionsUpdated", query: this.#currentQuery, observer: this }); } const mounted = this.hasListeners(); if (mounted && shouldFetchOptionally( this.#currentQuery, prevQuery, this.options, prevOptions )) { this.#executeFetch(); } this.updateResult(notifyOptions); if (mounted && (this.#currentQuery !== prevQuery || this.options.enabled !== prevOptions.enabled || this.options.staleTime !== prevOptions.staleTime)) { this.#updateStaleTimeout(); } const nextRefetchInterval = this.#computeRefetchInterval(); if (mounted && (this.#currentQuery !== prevQuery || this.options.enabled !== prevOptions.enabled || nextRefetchInterval !== this.#currentRefetchInterval)) { this.#updateRefetchInterval(nextRefetchInterval); } }
updateQuery 와 updateResult를 실행한다. updateQuery는 기존 쿼리와 비교하여 업데이트 하는 로직이다.
그런데! 지금은 아직 쿼리가 없다. Observer가 막 생성된 시점이기 때문이다.
그래서 여기서this.#updateQuery();
를 통해 쿼리를 생성한다
- #updateQuery()
#updateQuery() { const query = this.#client.getQueryCache().build(this.#client, this.options); if (query === this.#currentQuery) { return; } const prevQuery = this.#currentQuery; this.#currentQuery = query; this.#currentQueryInitialState = query.state; if (this.hasListeners()) { prevQuery?.removeObserver(this); query.addObserver(this); } }
첫줄 getQueryCache().build() 작동 코드를 자세히 보면,
- queryCache.js
생성과정인 build()는, useQuery에 넘겨받은 queryKey를 사용해서 해시를 만들고, 쿼리 객체에서 해시를 조회한 후 없다면 새로운 쿼리로 만듭니다.build(client, options, state) { const queryKey = options.queryKey; const queryHash = options.queryHash ?? hashQueryKeyByOptions(queryKey, options); let query = this.get(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.addObserver(this);
를 통해 옵저버에도 저장합니다.
그럼 이제!! 쿼리가 queryCache에도 queryObserver에도 연결된 상태가 됩니다 !!!!
돌아와서, setOptions에서 updateQuery가 마쳤으니, 이후 코드인 updateResult가 실행된다.
- updateResult()
updateResult(notifyOptions) { const prevResult = this.#currentResult; const nextResult = this.createResult(this.#currentQuery, this.options); this.#currentResultState = this.#currentQuery.state; this.#currentResultOptions = this.options; if (this.#currentResultState.data !== void 0) { this.#lastQueryWithDefinedData = this.#currentQuery; } if (shallowEqualObjects(nextResult, prevResult)) { return; } this.#currentResult = nextResult; const defaultNotifyOptions = {}; const shouldNotifyListeners = () => { if (!prevResult) { return true; } const { notifyOnChangeProps } = this.options; const notifyOnChangePropsValue = typeof notifyOnChangeProps === "function" ? notifyOnChangeProps() : notifyOnChangeProps; if (notifyOnChangePropsValue === "all" || !notifyOnChangePropsValue && !this.#trackedProps.size) { return true; } const includedProps = new Set( notifyOnChangePropsValue ?? this.#trackedProps ); if (this.options.throwOnError) { includedProps.add("error"); } return Object.keys(this.#currentResult).some((key) => { const typedKey = key; const changed = this.#currentResult[typedKey] !== prevResult[typedKey]; return changed && includedProps.has(typedKey); }); }; if (notifyOptions?.listeners !== false && shouldNotifyListeners()) { defaultNotifyOptions.listeners = true; } this.#notify({ ...defaultNotifyOptions, ...notifyOptions }); }
쿼리 상태의 전후를 비교하고, 다르다면 notify를 실행하고 최신화된 쿼리 상태를 전달합니다.
이렇게 Observer가 생성되었고, 이후 작동과정을 알아보자
useBaseQuery.js
const result = observer.getOptimisticResult(defaultedOptions); React.useSyncExternalStore( React.useCallback( (onStoreChange) => { const unsubscribe = isRestoring ? () => void 0 : observer.subscribe(notifyManager.batchCalls(onStoreChange)); observer.updateResult(); return unsubscribe; }, [observer, isRestoring] ), () => observer.getCurrentResult(), () => observer.getCurrentResult() ); React.useEffect(() => { observer.setOptions(defaultedOptions, { listeners: false }); }, [defaultedOptions, observer]);
여기선 getOptimisticResult, useSyncExternalStore, useEffect에 의한 setOptions 과정을 거친다!
먼저 getOptimisticResult가 실행됩니다.
queryObserver.js
getOptimisticResult(options) { const query = this.#client.getQueryCache().build(this.#client, options); const result = this.createResult(query, options); if (shouldAssignObserverCurrentProperties(this, result)) { this.#currentResult = result; this.#currentResultOptions = this.options; this.#currentResultState = this.#currentQuery.state; } return result; }
위에서 getQueryCache().build()는 넘겨주는 options에 맞는 쿼리가 있으면 그 쿼리를, 없다면 새로 생성한 쿼리를 반환하는 build임을 보았습니다.
이 쿼리를 가지고 createResult에 전달합니다.
상당히 긴 내부 코드인데 , 결국 반환하는 값은 useQuery의 반환 값인 객체가 된다.(여기서직접반환은아님)
queryCache.ts 내 코드는 이렇다
build(client, options, state) {
const queryKey = options.queryKey;
const queryHash = options.queryHash ?? hashQueryKeyByOptions(queryKey, options);
let query = this.get(queryHash);
if (!query) {
query = new Query({
cache: this,
queryKey,
queryHash,
options: client.defaultQueryOptions(options),
state,
defaultOptions: client.getQueryDefaults(queryKey)
});
this.add(query);
}
return query;
}
그리고, 렌더링을 위한 useSyncExternalStore가 실행된다.
React.useSyncExternalStore(
React.useCallback(
(onStoreChange) => {
const unsubscribe = isRestoring ? () => void 0 : observer.subscribe(notifyManager.batchCalls(onStoreChange));
observer.updateResult();
return unsubscribe;
},
[observer, isRestoring]
),
() => observer.getCurrentResult(),
() => observer.getCurrentResult()
);
useSyncExternalStore 는 React 18에 등장한 훅이다! https://ko.react.dev/reference/react/useSyncExternalStore
“외부 store를 구독”하는 용도로 쓰인다는 설명이다.
적용하자면, 내부 상태인 state, props, context api 외에 external한 상태가 변경되었을 때도 리렌더링이 이뤄지도록 하는 훅인 것이다. ((자세히는 따로 포스팅해보자))
그리고 최종적으로 useBaseQuery는 이렇게 변화를 감지하는 옵저버 결과를 반환한다. 즉 쿼리 값!
useQuery가 넘겨받은 optoins에 notifyOnChangeProps가 falsy하다면 trackResult 메소드를 실행한 결과를, 그렇지 않다면 위에서 찾은 쿼리 result 결과를 반환한다.
return !defaultedOptions.notifyOnChangeProps ? observer.trackResult(result) : result;
여기에 나오는 trackResult를 먼저 보자면,
queryObserver.js
trackResult(result, onPropTracked) { const trackedResult = {}; Object.keys(result).forEach((key) => { Object.defineProperty(trackedResult, key, { configurable: false, enumerable: true, get: () => { this.trackProp(key); onPropTracked?.(key); return result[key]; } }); }); return trackedResult; }
"this.trackProp()는 내부에서 useQuery를 통해 어떤 데이터를 요청했는지 저장합니다. 즉, 옵저버가 useQuery()를 통해 어떤 값을 요청했는지를 알게 됩니다." 참고
나만의 sum-up을 해보자면,
- 컴포넌트가 마운트되어 useQuery가 호출
- useQuery에 의해 useBaseQuery가 호출되고, 옵션(queryKey 등)과 Observer클래스 전달
- useBaseQuery에서 Observer 생성 ( !! )
- 이 Observer는 QueryCache의 Query를 구독한다
- 그 Query에 변화(패칭, stale ? ? )가 생기면
- fetch가 시작되면 쿼리의 상태가 바뀐다. 이에 대한 알림이 Observer에 전송된다.
- 그러면 Observer는 몇가지 최적화를 실행하고 잠재적으로 컴포넌트에게 업데이트를 알리고, 새로운 상태를 렌더링한다.
- 쿼리 실행이 완료된 후에도 Observer에게 이를 알린다.
ReactQuery 객체들과 관계, 그리고 useQuery 작동과정을 알아보고 있다. 메인 기능인 fetching 쪽은 코드가 더 많고 꽤나 복잡하다.. !
그부분까지 더 알아보고 정리해봐야겠담 - !
참고자료 🙇🏻♂️
https://tkdodo.eu/blog/inside-react-query
https://tanstack.com/query/latest/docs/reference/QueryCache
https://www.timegambit.com/blog/digging/react-query/02https://leego.tistory.com/entry/react-query%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%9E%91%EB%8F%99%ED%95%A0%EA%B9%8C
핵심은 옵저버 패턴과 useSyncExternalStore 훅이었네요!
잘보고갑니다~