
지난 1편에서는 TanStack Query를 직접 구현하게 된 배경을 정리했다.
이번 글에서는 주요 기능을 직접 구현하면서, 내부 구조가 어떻게 구성되어 있는지도 함께 살펴보려고 한다.
useQueryisLoading, isError, data 제공useSyncExternalStore를 이용한 자동 리패칭useMutation: 데이터를 생성, 수정, 삭제하는 등의 작업에 사용되는 훅onSuccess / onErro 콜백먼저 구현할 기능들에 대한 개념과 사용법에 대해서 간단히 알아보고 넘어가자.
useQueryconst { data, isLoading, isError } = useQuery({ queryKey, queryFn })
queryKeyqueryFndata: 성공적으로 가져온 데이터isLoading: 데이터 가져오기가 진행 중인지 여부isError: 쿼리 함수에서의 오류 발생 여부useMutationconst { mutate } = useMutation({ mutationFn, onSuccess, onError })
Parameter (Options)mutationFnqueryFn와 같은 비동기 함수로, 실행할 비동기 변이 함수이다.onSuccessonErrorReturnsmutate: 변이 실행 함수useSyncExternalStoreconst snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
subscribegetSnapshot을 다시 실행해 컴포넌트를 리렌더링한다.useEffect의 clean-up 함수처럼 동작함)💡 useSyncExternalStore가 왜 필요할까?
useSyncExternalStore는 React 18에서 도입된 훅으로, 외부 상태 저장소(store)를 리액트 컴포넌트와 동기화하는 데 사용된다.export function useOnlineStatus() { const [isOnline, setIsOnline] = useState(navigator.onLine); const update = useCallback(() => { setIsOnline(navigator.onLine); }, []) useEffect(() => { window.addEventListener('online', update); window.addEventListener('offline', update); return () => { window.removeEventListener('online', update); window.removeEventListener('offline', update); } }, [update]) return isOnline; }공식 홈페이지 예시 코드를
useSyncExternalStore없이 구현한 코드이다.
위 코드를 보면 React 외부 상태 저장소를useState,useEffect를 활용하여 충분히 동기화하고 있는 것 같아 보인다.하지만 이 접근 방식에는 중요한 문제점이 있다.
useState와useEffect사이의 타이밍 차이 때문에, 초기 렌더링 직전 또는 직후 외부 상태가 변경되면 해당 변경을 놓칠 수 있다.
이처럼 React의 렌더링 상태와 외부 상태 간에 불일치가 발생하는 현상을 Tearing이라고 한다.
useSyncExternalStore는 이러한 문제를 해결하며, 외부 저장소와의 동기화를 정확하고 예측 가능하게 만들어준다.
Query: useQuery에서 반환되는 data, isLoading, isError 값을 하나의 객체로 묶은 타입이다.store: useQuery에 인자로 넣어주는 queryKey를 기준으로 데이터를 저장, 수정, 삭제하는 저장소이다.Map 자료구조를 사용했다.type Query<TData> = { data: TData; isLoading: boolean; isError: boolean };
const store = new Map<string, Query<unknown>>();
내부적으로는 단순한 Map 객체 하나로 전체 캐시 저장소가 구성된다. 처음 봤을 때는 이 구조가 의외로 단순해서 놀라웠다.
subscribe는 useSyncExternalStore의 첫 번째 인자로 전달되는 함수로, 외부 저장소에 특정 queryKey를 기준으로 컴포넌트를 구독하게 해준다.Record<string, Set<Listener>> 구조를 사용하여, queryKey마다 여러 컴포넌트가 구독될 수 있도록 한다.type Listener = () => void;
const listeners: Record<string, Set<Listener>> = {};
export function subscribe(key: string, callback: Listener) {
if (!listeners[key]) {
listeners[key] = new Set();
}
listeners[key].add(callback);
// cleanup function 반환
return () => {
listeners[key]?.delete(callback);
};
}
getSnapshot은 현재 queryKey에 해당하는 데이터를 저장소에서 가져오는 함수다.useSyncExternalStore가 컴포넌트를 리렌더링할지 결정하는 기준이 된다.export function getSnapshot<TData>(key: string) {
if (!store.get(key))
store.set(key, { data: null, isLoading: true, isError: false });
return store.get(key) as Query<TData>;
}
data, isLoading, isError 값을 갱신해주는 함수이다.export function updateQuery<TData>(key: string, newValue: Query<TData>) {
store.set(key, newValue);
// 해당 key에 연결된 listener만 실행
listeners[key]?.forEach((callback) => callback());
}
💡 listeners에 등록된 callback은 무슨 함수일까?
function subscribeToStore(fiber, inst, subscribe) { return subscribe(function () { checkIfSnapshotChanged(inst) && forceStoreRerender(fiber); }); }위 코드는
React내부 코드에서subscribe함수를 반환하는 코드를 가져온 것이다.간단히 함수 이름만 봤을 때,
callback함수의 역할은 다음과 같다.
1.snapshot이 변경되었는지 확인
2. 변경이 감지되면 해당 컴포넌트(fiber)를 리렌더링즉,
subscribe함수에서 인자로 넣어주는callback함수는 store의 변경을 감지해 리렌더링을 일으키는 함수이다.
결국 updateQuery의 내부 로직은 store의 데이터를 바꾸는 순간,
→ subscribe에서 등록된 콜백 실행
→ snapshot이 변경됐는지 확인 후, 필요한 컴포넌트만 리렌더링하는 것이다.
QueryStore 전체 코드// QueryStore.ts
type Query<TData> = { data: TData; isLoading: boolean; isError: boolean };
type Listener = () => void;
const store = new Map<string, Query<unknown>>();
const listeners: Record<string, Set<Listener>> = {};
export function subscribe(key: string, callback: Listener) {
if (!listeners[key]) {
listeners[key] = new Set();
}
listeners[key].add(callback);
return () => {
listeners[key]?.delete(callback);
};
}
export function getSnapshot<TData>(key: string) {
if (!store.get(key))
store.set(key, { data: null, isLoading: true, isError: false });
return store.get(key) as Query<TData>;
}
export function updateQuery<TData>(key: string, newValue: Query<TData>) {
store.set(key, newValue);
listeners[key]?.forEach((callback) => callback());
}
👉 여기까지 구현한
subscribe,getSnapshot,updateQuery는 모두useSyncExternalStore에서 동기화를 위한 핵심 도구이다.
useQuery 훅 구현useQuery ParametersTanStack Query에서는 queryKey를 배열로 관리한다.interface UseQueryOptions<TData> {
queryKey: string;
queryFn: () => Promise<TData>;
}
useSyncExternalStore로 외부 저장소 동기화const snapshot = useSyncExternalStore(
(onStoreChange) => subscribe(queryKey, onStoreChange),
() => getSnapshot<TData>(queryKey)
);
useEffect(() => {
const fetchQueryData = async () => {
try {
const result = await queryFn();
updateQuery(queryKey, {
data: result,
isLoading: false,
isError: false,
});
} catch {
updateQuery(queryKey, {
data: null,
isLoading: false,
isError: true,
});
}
};
if (!snapshot?.data) {
fetchQueryData();
}
}, [queryKey, snapshot, queryFn]);
useQuery 전체 코드// useQuery.ts
interface UseQueryOptions<TData> {
queryKey: string;
queryFn: () => Promise<TData>;
}
export const useQuery = <TData>({
queryKey,
queryFn,
}: UseQueryOptions<TData>) => {
const snapshot = useSyncExternalStore(
(onStoreChange) => subscribe(queryKey, onStoreChange),
() => getSnapshot<TData>(queryKey)
);
useEffect(() => {
const fetchQueryData = async () => {
try {
const result = await queryFn();
updateQuery(queryKey, {
data: result,
isLoading: false,
isError: false,
});
} catch {
updateQuery(queryKey, {
data: null,
isLoading: false,
isError: true,
});
}
};
if (!snapshot?.data) {
fetchQueryData();
}
}, [queryKey, snapshot, queryFn]);
return snapshot;
};
useSyncExternalStore로 구독을 시작한다.getSnapshot을 통해 외부 저장소에서 queryKey에 해당하는 데이터를 가져온다.snapshot을 반환한다. → 데이터가 있을 수도, 없을 수도 있다.snapshot?.data가 없을 경우, useEffect에서 fetchQueryData 함수를 실행한다.queryFn 실행 결과에 따라 updateQuery로 상태를 저장하고,queryKey를 구독 중인 컴포넌트들이 리렌더링된다.TVariable: mutation 함수에 전달할 인자의 타입TData: mutation 결과로 반환될 데이터의 타입interface UseMutationOptions<TVariable, TData> {
mutationFn: (variables: TVariable) => Promise<TData>;
onSuccess?: (result: TData) => void;
onError?: () => void;
}
mutate 함수const mutate = useCallback(
async (variables: TVariable) => {
try {
const result = await mutationFn(variables);
// 요청 성공 시 onSuccess 콜백 실행 (있을 경우)
onSuccess?.(result);
return result;
} catch (error) {
// 요청 실패 시 onError 콜백 실행 (있을 경우)
onError?.();
throw error;
}
},
[mutationFn, onSuccess, onError]
);
// useMutation.ts
interface UseMutationOptions<TVariable, TData> {
mutationFn: (variables: TVariable) => Promise<TData>;
onSuccess?: (result: TData) => void;
onError?: () => void;
}
export function useMutation<TVariable, TData>({
mutationFn,
onSuccess,
onError,
}: UseMutationOptions<TVariable, TData>) {
const mutate = useCallback(
async (variables: TVariable) => {
try {
const result = await mutationFn(variables);
onSuccess?.(result);
return result;
} catch (error) {
onError?.();
throw error;
}
},
[mutationFn, onSuccess, onError]
);
return { mutate };
}
useQuery에 있는 useEffect를 없애기useEffect는 의존성 배열에 있는 queryKey, snapshot, queryFn 값들이 변경되면 실행된다.useSyncExternalStore의 첫 번째 인자로 넣은 subscribe 함수는 리렌더링이 될 때마다 호출된다.subscribe함수에 useCallback을 감싸면 특정 값이 변경될 때만 다시 구독하도록 가능하다.export const useQuery = <TData>({
queryKey,
queryFn,
}: UseQueryOptions<TData>) => {
const snapshot = useSyncExternalStore(
useCallback(
(onStoreChange) => {
const unsubscribe = subscribe(queryKey, onStoreChange);
const fetchQueryData = async () => {
try {
const result = await queryFn();
updateQuery(queryKey, {
data: result,
isLoading: false,
isError: false,
});
} catch {
updateQuery(queryKey, {
data: null,
isLoading: false,
isError: true,
});
}
};
const snapshot = getSnapshot<TData>(queryKey);
if (!snapshot?.data) {
fetchQueryData();
}
return unsubscribe;
},
[queryKey, queryFn]
),
() => getSnapshot<TData>(queryKey)
);
return snapshot;
};
fetchQueryData 함수 분리fetchQueryData 함수가 구독할 때마다 새로 생성되는 것을 방지하기 위해 외부로 분리한다.useQuery 내부의 가독성을 높인다.export const useQuery = <TData>({
queryKey,
queryFn,
}: UseQueryOptions<TData>) => {
const snapshot = useSyncExternalStore(
useCallback(
(onStoreChange) => {
const unsubscribe = subscribe(queryKey, onStoreChange);
loadQueryData(queryKey, queryFn);
return unsubscribe;
},
[queryKey, queryFn]
),
() => getSnapshot<TData>(queryKey)
);
return snapshot;
};
export async function loadQueryData<TData>(
key: string,
queryFn: () => Promise<TData>
) {
const snapshot = getSnapshot(key);
if (snapshot.data) return;
await fetchAndUpdateQueryData(key, queryFn);
}
async function fetchAndUpdateQueryData<TData>(
key: string,
queryFn: () => Promise<TData>
) {
try {
const result = await queryFn();
updateQuery(key, {
data: result,
isLoading: false,
isError: false,
});
} catch {
updateQuery(key, {
data: null,
isLoading: false,
isError: true,
});
}
}
💡 useEffect 안에 있던 로직을 subscribe의 인자로 전달되는 함수 내부로 옮겨도 괜찮을까?
// react/packages/use-sync-external-store/src/useSyncExternalStoreShimClient.js useEffect(() => { // Check for changes right before subscribing. Subsequent changes will > be // detected in the subscription handler. if (checkIfSnapshotChanged(inst)) { // Force a re-render. forceUpdate({inst}); } const handleStoreChange = () => { // TODO: Because there is no cross-renderer API for batching updates, it's // up to the consumer of this library to wrap their subscription event // with unstable_batchedUpdates. Should we try to detect when this isn't // the case and print a warning in development? // The store changed. Check if the snapshot changed since the last time we // read from the store. if (checkIfSnapshotChanged(inst)) { // Force a re-render. forceUpdate({inst}); } }; // Subscribe to the store and return a clean-up function. return subscribe(handleStoreChange); }, [subscribe]);
- useSyncExternalStore 내부 구현 코드를 보면, 실제로 subscribe 함수는 useEffect 안에서 실행된다.
- 즉, subscribe 함수 내부에서 side effect를 수행하는 것은 React의 사용 규칙에 어긋나지 않는다.
- useEffect와 동일한 타이밍에 실행되므로, 데이터를 요청하는 로직을 subscribe 내부에 넣어도 부작용 없이 동작한다.
이번 글에서는 TanStack Query의 핵심 기능인 useQuery와 useMutation을 직접 구현해보며,
React의 외부 상태와 동기화하는 방식(useSyncExternalStore)까지 함께 살펴보았다.
다음 글에서는 staleTime, gcTime, refetch 등의 고급 캐싱 전략과 Request Coalescing 등 실제 라이브러리 수준에서 필요한 기능들을 구현해볼 예정이다.
엄마 난 커서 재오가 될래