TanStack Query, 직접 구현해보기(3) - 캐시 정책과 최적화 기능 구현

재영·2025년 8월 29일
post-thumbnail

들어가며

1편2편에서는 QueryStoreuseSyncExternalStore를 기반으로 캐시 생성 → 구독 → 갱신의 최소 골격을 구축하고, useQuery/useMutation의 기본 흐름을 정리했다.
그러나 현 구조에는 캐시를 언제 갱신하고 언제 폐기할지에 대한 정책이 없으며, 동일 데이터를 여러 곳에서 동시에 요청하는 상황이나 일시적 네트워크 오류 같은 운영 이슈에도 취약하다.
3편부터는 이 빈틈을 메우며 시스템의 완성도를 끌어올려보자.

이번 편의 주요 추가 기능

  • staleTime
    • SWR에 나오는 stale을 뜻하며,
    • 캐시가 freshstale로 바뀌는 데 걸리는 시간이다.
    • 즉, staleTime이 지나면 새로운 데이터를 불러오게 된다.
  • gcTime
    • gcTimeGarbage Collect Time의 약자로,
    • 구독이 끊긴 캐시 데이터를 얼마나 오래 보존할지를 정하는 수명 개념이다.
  • retry
    • 일시적 네트워크/서버 오류 시 해당 쿼리를 자동 재시도하는 횟수를 뜻한다.
  • requestCoalescing
    • 동일 queryKey에 대한 동시 요청을 하나의 요청으로 합친다.
    • 첫 요청의 Promise를 공유해 중복 트래픽을 제거하고, 완료 결과를 모든 구독자에 반영하는 기능이다.

기능 구현 전 리팩토링: Client/Store 분리로 응집도·확장성 높이기

이번 리팩토링에서는 QueryStore와 이를 사용하는 로직을 client / store로 분리했다.
목적은 간단하다.

  1. 관심사 분리(SoC)
  2. 전역 상태 캡슐화
  3. 응집도 향상
  4. 향후 stale/gc/retry/coalescing 정책을 자연스럽게 끼워 넣기 위함

현재 QueryStore 구조의 문제점

  • 전역 store/listeners 노출
  • 상태 보관/구독과 네트워크 트리거 혼재 → 관심사 불명확
  • getSnapshot/subscribe/updateData가 흩어진 함수로 존재 → 응집도 낮음, 객체(클라이언트/스토어)로 묶어 캡슐화 필요

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();
  • 전역 객체 제거 → 모듈 내부 캡슐화
  • getSnapshot/subscribe/setSnapshot을 한 객체로 묶어 응집도↑

Client: 정책·행위의 진입점

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);
  },
};
  • 네트워크와 정책의 책임은 client가 일관되게 담당

기능 구현

staleTime

[변경 로직]
1. useQuery 훅 옵션으로 staleTime 전달
2. updatedAt 타임스탬프 저장
3. “스테일인지” 판정 로직

1) 훅에서 staleTime 받기 + 로드 시 전달

// 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 인자로 추가
  // ...
};

2) 데이터가 언제 갱신됐는지 기록: updatedAt

export type Query<TData> = {
  // ...
  updatedAt?: number;
};

3) “지금 stale 인가?”를 판단

// 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);
  }
}

gcTime

[변경 로직]
1. useQuery 훅 옵션으로 gcTime 전달
2. 마지막 구독 해제 시 GC 타이머를 걸고, 재구독 시 타이머를 해제

1) 훅에서 gcTime 받기 + 구독 시 전달

// 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 인자로 추가
  // ...
};

2) 재구독 시 GC 타이머 해제 (캐시 보존)

// 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);
    }
  };
}

retry: 간단한 재시도 로직을 붙이기

[변경 로직]
1. useQuery 훅 옵션으로 retry 전달
2. loadQueryData에서 실패 시 짧게 대기 후 재시도
3. fetchQuery에서 실패를 rethrow하여 상위(루프)가 감지

1) 훅에서 retry 받기 + 로드 시 전달

// 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);
  // ...
};

2) 실패하면 잠깐 쉰 뒤 다시: loadQueryData 재시도 루프

// 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);
    }
  },
};

3) 실패를 위로 올려주기: fetchQuery에서 rethrow

// 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)));
  }
},

requestCoalescing

[변경 로직]
1. inFlightFetchFns로 진행 중 요청을 키별로 기록
2. fetchQuery 내에 try/await → then–catch–finally 체인으로 변경
3. 같은 키로 들어오는 후속 호출은 기존 Promise를 그대로 반환하여 중복 요청 방지

1) in-flight 맵 추가 + 첫 호출/후속 호출 분기

// 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();

전체 코드 보러가기

👉 전체 코드 보기 (GitHub)

마치며

처음에는 단순히 데이터를 캐싱하는 기능만 있어도 충분하다고 생각했지만, 실제로 구현을 이어가다 보니 운영 환경에서 필요한 정책들이 훨씬 많다는 걸 깨달았다.
이번 글에서는 그 빈틈을 메우는 네 가지 기능을 구현하며 시스템이 점점 '진짜' 쿼리 라이브러리답게 다듬어지는 과정을 공유했다.

다음 글에서는 이 기반 위에서 조금 더 실전적인 패턴과 최적화 포인트들을 탐구해 볼 예정이다.

1개의 댓글

comment-user-thumbnail
2025년 8월 29일

wo_ow

답글 달기