목차
- 왜 기본값을 그대로 쓰면 안 됐는지
- 비교 기준을 먼저 정했다
- staleTime과 gcTime의 차이
- 데이터 성격별로 정책을 나눈다
- 전역 default도 같이 설정해야 한다
- 실제로 얼마나 줄었는지
- 빠뜨리기 쉬운 디테일
- 마이그레이션할 때 주의할 점
- 결론과 다음 단계
대시보드 페이지를 띄울 때마다 같은 /api/user/me 요청이 3번씩 찍히는 걸 네트워크 탭에서 발견했다. React Query staleTime cacheTime 설정을 기본값 그대로 두고 컴포넌트마다 useQuery를 부른 결과였고, 캐싱 정책을 데이터 성격별로 분리하고 나서 페이지 진입당 요청 수가 체감상 1/3 수준으로 줄었다.
이처럼, 문제 자체는 단순했다. staleTime: 0이 기본값이라 마운트되는 모든 useQuery가 일단 네트워크를 한 번씩 때린다. 캐시는 살아있어도 stale이면 백그라운드 refetch가 돈다. 이 동작이 잘못된 게 아니라, 우리 팀이 의도한 것과 달랐을 뿐이다.
왜 기본값을 그대로 쓰면 안 됐는지
그래서, React Query(v5 기준 TanStack Query)는 모든 쿼리를 일단 stale로 본다. 캐시에 데이터가 있어도 컴포넌트가 마운트되거나 윈도우가 포커스되면 백그라운드에서 다시 가져온다. 사용자에게는 "항상 최신" 인상을 주는 게 목적이다.
대시보드 한 화면에 5개 위젯이 있고, 각각 다른 useQuery를 쓴다고 해보자. 그중 3개가 /api/user/me를 의존성으로 들고 있다면, queryKey가 같으면 deduplication이 일어나서 요청은 1번만 나간다. 여기까지는 정상이다.
문제는 라우팅이다. 대시보드 → 설정 → 대시보드 순으로 이동하면 unmount/remount가 일어나고, staleTime: 0이라 다시 fetch한다. gcTime(예전 cacheTime) 5분 안에 다시 들어와도 캐시는 보여주되 백그라운드로 새로 가져온다. SPA에서 탭을 자주 옮기는 사용자라면 이게 누적된다.
예를 들어, 처음엔 refetchOnWindowFocus: false만 끄고 넘어가려 했다. 그런데 그건 증상 일부만 가리는 거고, 실제로는 데이터 종류별로 신선도 정책을 다르게 가져가야 했다.
비교 기준을 먼저 정했다
후보는 세 가지였다.
- 전역 default만 바꾸기:
QueryClient의 defaultOptions에서staleTime을 일괄 5분으로 설정 - 데이터 성격별 프리셋: 자주 변하는 것/거의 안 변하는 것/실시간 셋으로 나눠서 커스텀 훅으로 래핑
- 개별 useQuery에서 매번 명시: 호출하는 곳마다 정책을 직접 적기
반면, 각 안을 평가한 기준은 다음과 같다.
| 기준 | 가중치 | 설명 |
|---|---|---|
| 일관성 | 높음 | 같은 종류의 데이터는 같은 정책을 따라야 한다 |
| 학습 비용 | 중간 | 신규 합류자가 며칠 안에 익숙해질 수 있나 |
| 예외 처리 | 중간 | 특수 케이스를 깨지 않고 다룰 수 있나 |
| 디버깅 용이성 | 높음 | 왜 이 시점에 refetch가 났는지 추적 가능한가 |
특히, 결론부터 말하면 2번을 골랐다. 1번은 너무 거칠고 3번은 너무 흩어진다. 중간을 잡되, 정책 자체를 코드로 명명해두는 방식이 회고 시점에 가장 추적이 쉬웠다.
staleTime과 gcTime의 차이
실제로, v4에서 cacheTime이 gcTime으로 이름이 바뀌었다(TanStack Query v5.0.0, 2023년 10월 릴리즈). 이름이 바뀐 이유가 명확한데, 이 둘을 헷갈리는 사람이 많아서다. 동작이 완전히 다르다.
staleTime은 "이 데이터를 신선하다고 간주할 시간"이다. 이 시간 안에는 같은 queryKey를 부른 컴포넌트가 마운트돼도 네트워크 요청이 나가지 않는다. 캐시에서 그대로 꺼내준다.
gcTime(구 cacheTime)은 "쿼리를 구독하는 컴포넌트가 모두 unmount된 뒤, 캐시를 메모리에 남겨둘 시간"이다. 기본값 5분. 이 시간이 지나면 캐시가 정말로 삭제된다.
그런데, 이걸 표로 정리하면 이렇다.
| 옵션 | 기본값(v5) | 의미 | 영향 |
|---|---|---|---|
| staleTime | 0 | 신선도 유지 시간 | 네트워크 요청 발생 여부 |
| gcTime | 5분(300_000ms) | 캐시 보존 시간 | 메모리 사용량, 재진입 시 즉시 표시 여부 |
staleTime을 길게 잡으면 요청이 줄지만, 데이터가 오래된 상태로 보일 수 있다. gcTime을 길게 잡으면 메모리는 더 쓰지만 페이지 재진입이 빨라진다. 두 값을 같이 보면서 정책을 정해야 한다.
흔히 하는 오해
staleTime: Infinity로 잡으면 절대 refetch 안 된다고 생각하는 경우가 있는데, 정확히는 자동 refetch가 안 된다. refetch()를 명시적으로 호출하거나 invalidateQueries로 무효화하면 당연히 다시 가져온다. 변경 작업(mutation) 후 관련 쿼리를 무효화하는 패턴은 staleTime과 무관하게 동작한다.
또 하나, enabled: false로 막아둔 쿼리는 stale/gc 타이머가 어떻게 도는지 묻는 경우. enabled는 자동 실행 여부만 막을 뿐, 캐시에 데이터가 들어 있으면 staleTime/gcTime은 그대로 작동한다.
데이터 성격별로 정책을 나눈다
우리 팀에서 정한 3개 프리셋이다. 절대 기준은 아니고, 서비스 특성에 따라 다르겠지만 분류 방식 자체는 참고할 만하다고 본다.
// queries/presets.ts
export const QUERY_PRESETS = {
// 자주 바뀜: 알림, 채팅 미리보기, 진행 중 작업 상태
realtime: {
staleTime: 0,
gcTime: 1000 * 60, // 1분
refetchOnWindowFocus: true,
},
// 가끔 바뀜: 사용자 프로필, 대시보드 위젯, 설정값
standard: {
staleTime: 1000 * 60 * 5, // 5분 동안 fresh
gcTime: 1000 * 60 * 30, // 30분 캐시 보존
refetchOnWindowFocus: false,
},
// 거의 안 바뀜: 카테고리 목록, 국가 코드, 권한 메타
static: {
staleTime: 1000 * 60 * 60, // 1시간
gcTime: 1000 * 60 * 60 * 24, // 24시간
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
} as const;
이걸 그대로 useQuery에 펴면 호출부가 지저분해진다. 그래서 커스텀 훅으로 한 번 더 감쌌다.
// queries/useUser.ts
import { useQuery } from '@tanstack/react-query';
import { QUERY_PRESETS } from './presets';
export function useUser(userId: string) {
return useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
...QUERY_PRESETS.standard, // 사용자 정보는 standard
});
}
export function useCategories() {
return useQuery({
queryKey: ['categories'],
queryFn: fetchCategories,
...QUERY_PRESETS.static, // 카테고리는 거의 안 변함
});
}
호출하는 쪽은 useUser(id)만 부르면 된다. 정책은 한 곳에서 관리된다. 신규 합류자가 와도 presets.ts 하나만 보면 우리 팀이 데이터를 어떻게 분류하는지 파악된다.
예외 케이스 처리
가끔 같은 데이터인데 화면에 따라 다르게 다뤄야 할 때가 있다. 예를 들면 사용자 목록은 보통 standard로 충분한데, 관리자 페이지의 "방금 가입한 사용자" 위젯에서는 realtime이 필요하다. 이때 프리셋을 override할 수 있게 둔다.
export function useUserList(options?: { policy?: keyof typeof QUERY_PRESETS }) {
const policy = options?.policy ?? 'standard';
return useQuery({
queryKey: ['users', 'list'],
queryFn: fetchUserList,
...QUERY_PRESETS[policy],
});
}
게다가, policy 문자열로 받는 게 직접 staleTime 숫자를 넘기는 것보다 의도가 명확하다. 코드 리뷰에서 "왜 여기만 30초죠?"라는 질문이 안 나온다.
전역 default도 같이 설정해야 한다
반면, 프리셋만 만들고 끝내면, 프리셋을 안 쓰는 useQuery는 여전히 기본값(staleTime 0)으로 동작한다. 누락된 곳을 안전망으로 잡으려면 QueryClient 자체의 default도 보수적으로 잡아두는 게 낫다.
// queryClient.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 30, // 최소 30초는 fresh
gcTime: 1000 * 60 * 5,
refetchOnWindowFocus: false,
retry: 1,
},
},
});
staleTime: 30000이 정답인 건 아니다. 다만 0보다는 낫다는 입장이다. 프리셋을 깜빡 잊고 안 적용한 쿼리도 최소한 30초 안에 같은 페이지 안에서 두 번 호출되진 않는다.
실제로 얼마나 줄었는지
한편, 운영 환경에 적용하기 전, 로컬에서 시나리오 기반으로 비교했다. 측정 환경은 다음과 같다.
- Chrome 119, MacBook Pro M1, 같은 라우팅 시나리오 5회 반복 평균
- 대시보드 → 사용자 상세 → 설정 → 대시보드 순으로 이동
- React Query v5.17.0, React 18.2
| 항목 | 적용 전 | 적용 후 | 차이 |
|---|---|---|---|
| 총 네트워크 요청 수 | 38 | 11 | -71% |
/api/user/me 호출 횟수 |
6 | 1 | -83% |
| 카테고리 조회 횟수 | 4 | 1 | -75% |
| 초기 페인트 후 idle까지 | 약 1.2s | 약 0.4s | -67% |
즉, 벤치마크 환경이 운영과 완전히 같진 않지만, 추세는 신뢰할 만하다고 봤다. 특히 /api/user/me처럼 여러 위젯이 공통으로 의존하는 쿼리에서 효과가 컸다. 카테고리처럼 거의 변하지 않는 데이터를 static 프리셋으로 옮긴 것도 누적 효과가 있었다.
빠뜨리기 쉬운 디테일
refetchOnMount라는 옵션이 있다. 기본값은 true. 이게 켜져 있으면 컴포넌트가 마운트될 때 stale이면 다시 가져온다. staleTime을 길게 잡았다면 refetchOnMount: true여도 fresh 상태이므로 요청은 안 나간다. 그래서 보통은 따로 안 건드린다.
다만 staleTime: 0을 유지하면서 마운트 시 refetch만 막고 싶은 케이스가 있을 수 있다. 그럴 땐 refetchOnMount: false를 쓰면 된다. 이건 개인적으로 자주 쓰진 않는데, 모달 안에서 같은 쿼리를 다시 부르는 시나리오에서 가끔 유용했다.
networkMode도 한 번쯤 봐둘 만하다. 오프라인 상황에서 쿼리가 어떻게 행동할지 결정한다. 기본 'online'은 네트워크가 없으면 paused 상태로 들어간다. PWA나 모바일 환경 비중이 크다면 'always'나 'offlineFirst'로 바꿔야 할 수 있다. 이 부분은 아직 우리 서비스에서 적극적으로 안 써봐서 단정은 못 하겠다.
마이그레이션할 때 주의할 점
즉, v4 → v5로 올린 프로젝트라면 cacheTime이 deprecated 됐다는 경고를 콘솔에서 보게 된다. 단순 이름 변경이지만 일괄 치환할 때 grep 한 번 더 보길 권한다. 변수명에 우연히 cacheTime이 들어간 다른 도메인 코드를 같이 바꿔버리는 사고가 있었다. (출처: TanStack Query v5 migration guide)
useErrorBoundary도 throwOnError로 바뀌었고, onSuccess/onError/onSettled 콜백이 useQuery에서 제거됐다. 콜백을 쓰던 코드는 useEffect로 옮기거나 useMutation 쪽으로 책임을 넘기는 식으로 리팩토링이 필요하다. 이건 staleTime과는 별개 주제지만, 같이 올리는 김에 정리해두면 좋다.
결론과 다음 단계
특히, 핵심 액션은 셋이다.
QueryClientdefaultOptions에서staleTime을 최소 30초 이상으로 잡는다- 데이터 성격별 프리셋(realtime/standard/static)을 한 파일로 모은다
- 쿼리 훅을 도메인별로 래핑해서 호출부에서 정책 옵션이 안 보이게 한다
물론, 이 셋만 적용해도 불필요한 refetch가 눈에 띄게 줄어든다. 우리 팀 케이스에서는 70% 정도였다.
즉, 다음엔 prefetchQuery와 SSR 시 hydration boundary를 함께 묶어, 라우팅 전환 시점에 데이터를 미리 띄우는 패턴을 실험해볼 생각이다.
관련 글
- React Query Retry 재시도 전략: Exponential Backoff와 Jitter 적용기 – React Query 기본 retry로는 thundering herd를 막지 못한다. exponential backoff에 full jit…
- Elixir 점진적 타이핑 도입 이유 — 동적 언어가 타입을 받아들이는 흐름 – 동적 언어에 타입을 끼워넣는 건 늘 논란이다. Elixir 1.20이 set-theoretic types로 정식 전환하면서 무엇이 바뀌는지…
- Elixir v1.20 점진적 타입 시스템, TypeScript 전철 밟나 – Elixir v1.20에서 점진적 타입이 본격적으로 들어왔다. set-theoretic 기반이라 TypeScript와 접근이 다르다. 오늘…