
1편과 2편에서는 QueryStore와 useSyncExternalStore를 기반으로 캐시 생성 → 구독 → 갱신의 최소 골격을 구축하고, useQuery/useMutation의 기본 흐름을 정리했다.
그러나 현 구조에는 캐시를 언제 갱신하고 언제 폐기할지에 대한 정책이 없으며, 동일 데이터를 여러 곳에서 동시에 요청하는 상황이나 일시적 네트워크 오류 같은 운영 이슈에도 취약하다.
3편부터는 이 빈틈을 메우며 시스템의 완성도를 끌어올려보자.
stale을 뜻하며,fresh → stale로 바뀌는 데 걸리는 시간이다.staleTime이 지나면 새로운 데이터를 불러오게 된다.gcTime은 Garbage Collect Time의 약자로,이번 리팩토링에서는 QueryStore와 이를 사용하는 로직을 client / store로 분리했다.
목적은 간단하다.
export const createQueryStore = (): QueryStore => {
const store: Store<unknown> = new Map<string, Query<unknown>>();
const listeners: Record<string, Set<Listener>> = {};
const getOrInit = <TData>(key: string): Query<TData> => {
const cur = store.get(key) as Query<TData> | undefined;
if (cur) return cur;
const next = initState();
store.set(key, next);
return next as Query<TData>;
};
return {
getSnapshot: <TData>(key: string) => getOrInit<TData>(key),
setSnapshot: <TData>(
key: string,
next: Query<TData> | ((prev: Query<TData>) => Query<TData>),
) => {
const prev = getOrInit<TData>(key);
store.set(
key,
typeof next === 'function'
? (next as (p: Query<TData>) => Query<TData>)(prev)
: next,
);
listeners[key]?.forEach((cb) => cb());
},
subscribe: (key, callback) => {
if (!listeners[key]) listeners[key] = new Set();
listeners[key].add(callback);
return () => listeners[key]?.delete(callback);
},
};
};
export const queryStore = createQueryStore();
export const queryClient = {
patchQuery: <TData>(key: string, partial: Partial<Query<TData>>) => {
queryStore.setSnapshot(key, (prev) => ({ ...prev, ...partial }));
},
fetchQuery: async <TData>(key: string, queryFn: () => Promise<TData>) => {
queryClient.patchQuery<TData>(key, { isLoading: true, isError: false });
try {
const result = await queryFn();
queryClient.patchQuery(key, {
data: result,
isLoading: false,
isError: false,
});
} catch {
queryClient.patchQuery(key, {
isLoading: false,
isError: true,
});
}
},
loadQueryData: <TData>(key: string, queryFn: () => Promise<TData>) => {
const snapshot = queryStore.getSnapshot(key);
if (snapshot.data) return;
queryClient.fetchQuery(key, queryFn);
},
};
[변경 로직]
1. useQuery 훅 옵션으로 staleTime 전달
2. updatedAt 타임스탬프 저장
3. “스테일인지” 판정 로직
// useQuery.ts
interface UseQueryOptions<TData> {
// ...
staleTime?: number; // fresh 상태 유지 시간(ms)
}
export const useQuery = <TData>({
//...
staleTime = 0 // 기본 0분
}: UseQueryOptions<TData>) => {
// ...
queryClient.loadQueryData(queryKey, queryFnRef.current, staleTime); // staleTime 인자로 추가
// ...
};
export type Query<TData> = {
// ...
updatedAt?: number;
};
// queryClient.ts
loadQueryData<TData>(key, queryFn, staleTime) { // staleTime 인자 추가
const snapshot = queryStore.getSnapshot(key);
const isStale =
!snapshot.updatedAt || Date.now() - snapshot.updatedAt > staleTime; // 추가
if (snapshot.data == null || isStale) { // 패칭 조건 추가
queryClient.fetchQuery<TData>(key, queryFn);
}
}
[변경 로직]
1. useQuery 훅 옵션으로 gcTime 전달
2. 마지막 구독 해제 시 GC 타이머를 걸고, 재구독 시 타이머를 해제
// useQuery.ts
interface UseQueryOptions<TData> {
// ...
gcTime?: number; // 캐시 보존 시간(ms)
}
export const useQuery = <TData>({
// ...
gcTime = 5 * 60 * 1000, // 기본 5분
}: UseQueryOptions<TData>) => {
// ...
const unsubscribe = queryStore.subscribe(queryKey, onStoreChange, gcTime); // subscribe 인자로 추가
// ...
};
// queryStore.ts
const gcTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
// gcTime 인자 추가
subscribe: (key, callback, gcTime) => {
// ...
// 재구독 시, 기존 GC 타이머가 걸려 있었다면 해제
const timeout = gcTimeouts.get(key);
if (timeout) {
clearTimeout(timeout);
gcTimeouts.delete(key);
}
// ...
return () => {
listeners[key]?.delete(callback);
// 구독 해제 시, 구독 수가 0일 경우 GC 타이머 가동
if (listeners[key]?.size === 0) {
delete listeners[key];
const timeout = setTimeout(() => {
store.delete(key);
gcTimeouts.delete(key);
}, gcTime);
gcTimeouts.set(key, timeout);
}
};
}
[변경 로직]
1. useQuery 훅 옵션으로 retry 전달
2. loadQueryData에서 실패 시 짧게 대기 후 재시도
3. fetchQuery에서 실패를 rethrow하여 상위(루프)가 감지
// useQuery.ts
interface UseQueryOptions<TData> {
// ...
retry?: number; // 실패 시 재시도 횟수
}
export const useQuery = <TData>({
// ...
retry = 3, // 기본 3회
}: UseQueryOptions<TData>) => {
// ...
// staleTime / retry를 함께 전달
queryClient.loadQueryData(queryKey, queryFnRef.current, staleTime, retry);
// ...
};
// queryClient.ts
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); // 간단 대기 유틸
export const queryClient = {
// ...
// 재시도 횟수 인자 추가
loadQueryData: async <TData>(key, queryFn, staleTime, retryCount) => {
// ...
if (snapshot.data == null || isStale) {
let attempt = 0;
// 실패 시 sleep(1000) 후 다시 시도
while (attempt < retryCount) {
try {
await queryClient.fetchQuery(key, queryFn);
return; // 성공하면 종료
} catch {
await sleep(1000);
attempt++;
}
}
// (선택) 마지막 한 번 더 시도하고 싶다면 아래 한 줄:
// await queryClient.fetchQuery(key, queryFn);
}
},
};
// queryClient.ts
fetchQuery: async <TData>(key: string, queryFn: () => Promise<TData>) => {
queryClient.patchQuery<TData>(key, { isLoading: true, isError: false });
try {
const result = await queryFn();
queryClient.patchQuery(key, {
data: result,
isLoading: false,
isError: false,
updatedAt: Date.now(),
});
} catch (e) {
queryClient.patchQuery<TData>(key, { isLoading: false, isError: true });
// 실패를 상위(loadQueryData)로 전달해 재시도 루프가 감지하도록
throw (e instanceof Error ? e : new Error(String(e)));
}
},
[변경 로직]
1. inFlightFetchFns로 진행 중 요청을 키별로 기록
2. fetchQuery 내에 try/await → then–catch–finally 체인으로 변경
3. 같은 키로 들어오는 후속 호출은 기존 Promise를 그대로 반환하여 중복 요청 방지
// queryClient.ts
const createQueryClient = () => {
const inFlightFetchFns = new Map<string, Promise<void>>(); // in-flight 기록
return {
async fetchQuery<TData>(key: string, queryFn: () => Promise<TData>) {
// 이미 요청 중이면 해당 Promise를 반환
if (inFlightFetchFns.has(key)) {
return inFlightFetchFns.get(key); // request coalescing 핵심
}
// ...
// 동기 예외도 Promise 거부로 표준화
const fetchPromise = Promise.resolve()
.then(() => queryFn())
.then((result) => {
queryClient.patchQuery(key, {
data: result,
isLoading: false,
isError: false,
updatedAt: Date.now(),
});
})
.catch((error) => {
queryClient.patchQuery(key, { isLoading: false, isError: true });
throw (error instanceof Error ? error : new Error(String(error)));
})
.finally(() => {
inFlightFetchFns.delete(key); // 완료 시 정리
});
inFlightFetchFns.set(key, fetchPromise);
return fetchPromise; // ← 첫 호출도 Promise 반환
},
};
};
export const queryClient = createQueryClient();
처음에는 단순히 데이터를 캐싱하는 기능만 있어도 충분하다고 생각했지만, 실제로 구현을 이어가다 보니 운영 환경에서 필요한 정책들이 훨씬 많다는 걸 깨달았다.
이번 글에서는 그 빈틈을 메우는 네 가지 기능을 구현하며 시스템이 점점 '진짜' 쿼리 라이브러리답게 다듬어지는 과정을 공유했다.
다음 글에서는 이 기반 위에서 조금 더 실전적인 패턴과 최적화 포인트들을 탐구해 볼 예정이다.
wo_ow