대시보드 페이지가 느리다는 CS가 들어왔다.
크롬 DevTools의 Network 탭을 열고 탭 하나를 클릭했다. API 호출이 47개 찍혔다. 다른 탭으로 갔다가 돌아왔다. 또 47개. 같은 데이터를, 캐시에 이미 있는 데이터를, 탭을 왔다갔다할 때마다 매번 새로 요청하고 있었다. 이게 React Query를 쓰면서 staleTime을 한 번도 설정하지 않은 결과였다.
staleTime: 0이라는 기본값의 의미
React Query(지금은 TanStack Query)의 staleTime 기본값은 0이다. 숫자 0. 밀리초 단위니까 0ms. 이게 뭘 뜻하냐면, 데이터를 fetch해서 캐시에 넣는 순간 그 데이터는 **즉시 stale(오래된 것)**로 표시된다.
const { data } = useQuery({
queryKey: ['dashboard', 'summary'],
queryFn: fetchDashboardSummary,
// staleTime을 안 썼다 → 기본값 0
});이 코드가 있는 컴포넌트가 마운트될 때마다 React Query는 이렇게 판단한다: "캐시에 데이터 있네. 근데 stale이네. 일단 캐시 데이터 보여주고 백그라운드에서 새로 fetch하자." 탭 전환? 리마운트. 다른 페이지 갔다 돌아옴? 리마운트. 브라우저 탭 포커스? refetch. 전부 다 새 API 호출이 된다.
TkDodo가 블로그에서 이걸 두고 "practical default"라고 했다. 맞는 말이다. 항상 최신 데이터를 보여주겠다는 보수적인 선택. 하지만 이 기본값이 실제 프로덕션에서 어떤 결과를 만드는지는 직접 겪어봐야 안다.
fresh, stale, inactive — 세 가지 상태
React Query의 캐시 데이터는 세 가지 상태를 가진다. 이걸 제대로 이해하지 않으면 staleTime이든 gcTime이든 설정값이 뭘 하는 건지 감이 안 온다.
Fresh: 데이터가 최신이라고 간주되는 상태. 이 상태에서는 컴포넌트가 마운트되든 윈도우 포커스가 돌아오든 refetch를 하지 않는다. 캐시 데이터를 그대로 쓴다.
Stale: 데이터가 오래되었다고 간주되는 상태. 캐시 데이터를 일단 보여주지만, 특정 트리거(마운트, 윈도우 포커스, 네트워크 재연결 등)가 발생하면 백그라운드에서 refetch한다.
Inactive: 이 query를 구독하는 컴포넌트가 하나도 없는 상태. 화면에서 사라진 query다. gcTime(과거의 cacheTime) 시간이 지나면 가비지 컬렉팅된다.
fetch 완료 → Fresh (staleTime 동안)
↓
staleTime 경과 → Stale (트리거 시 refetch)
↓
컴포넌트 언마운트 → Inactive
↓
gcTime 경과 → 캐시에서 삭제
핵심은 이거다. staleTime이 0이면 Fresh 구간이 존재하지 않는다. fetch가 끝나자마자 바로 Stale이다. 그래서 컴포넌트가 마운트될 때마다, 윈도우 포커스가 돌아올 때마다 refetch가 일어나는 것이다.
우리 대시보드에서 벌어진 일
대시보드 페이지 구조가 이랬다. 상단에 요약 카드 5개, 중간에 차트 3개, 하단에 테이블 2개. 각각이 별도의 useQuery를 쓰고 있었다. 거기에 탭이 4개 있었는데, 탭 전환을 React Router의 중첩 라우트로 처리하고 있어서 탭을 바꿀 때마다 컴포넌트가 언마운트됐다가 다시 마운트됐다.
// DashboardSummary.tsx
const { data: revenue } = useQuery({ queryKey: ['revenue'], queryFn: fetchRevenue });
const { data: users } = useQuery({ queryKey: ['users-count'], queryFn: fetchUsersCount });
const { data: orders } = useQuery({ queryKey: ['orders-today'], queryFn: fetchOrdersToday });
const { data: conversion } = useQuery({ queryKey: ['conversion'], queryFn: fetchConversion });
const { data: retention } = useQuery({ queryKey: ['retention'], queryFn: fetchRetention });
// DashboardCharts.tsx
const { data: revenueChart } = useQuery({ queryKey: ['chart', 'revenue'], queryFn: fetchRevenueChart });
const { data: trafficChart } = useQuery({ queryKey: ['chart', 'traffic'], queryFn: fetchTrafficChart });
const { data: funnelChart } = useQuery({ queryKey: ['chart', 'funnel'], queryFn: fetchFunnelChart });
// ... 이런 식으로 10개 이상의 useQuery
staleTime이 전부 0이니까, 탭을 한 번 왔다갔다하면 마운트 → 전부 stale → 전부 refetch. 10개가 넘는 query가 동시에 날아갔다. 거기에 refetchOnWindowFocus가 기본값 true니까, 슬랙 보다가 브라우저로 돌아와도 전부 다시 fetch. PM이 다른 앱이랑 왔다갔다하면서 대시보드를 쓰는데, 그때마다 API가 폭격당하고 있었다.
백엔드 팀에서 먼저 알아챘다. "대시보드 API 호출량이 비정상인데 봐줄 수 있어?" 그때서야 Network 탭을 열어본 거다.
staleTime 설정의 실전 패턴
패턴 1: 글로벌 기본값 설정
가장 먼저 할 일. QueryClient를 만들 때 기본 staleTime을 설정한다.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1분
},
},
});이 한 줄이 모든 query의 기본 동작을 바꾼다. 데이터를 fetch한 뒤 1분 동안은 fresh 상태를 유지한다. 그 1분 동안은 컴포넌트가 마운트되든 윈도우 포커스가 돌아오든 캐시 데이터를 그대로 쓴다. API 호출이 발생하지 않는다.
TkDodo는 블로그에서 이걸 "sane default"라고 부른다. 나도 동의한다. 대부분의 데이터는 1분 전이나 지금이나 같다.
패턴 2: query별 오버라이드
글로벌 기본값을 설정했더라도, 특정 query는 다른 staleTime이 필요하다. 실시간성이 중요한 데이터가 있고, 거의 안 바뀌는 데이터가 있으니까.
// 사용자 프로필 — 자주 안 바뀐다
const { data: profile } = useQuery({
queryKey: ['profile', userId],
queryFn: () => fetchProfile(userId),
staleTime: 1000 * 60 * 5, // 5분
});
// 알림 카운트 — 실시간에 가까워야 한다
const { data: notificationCount } = useQuery({
queryKey: ['notifications', 'count'],
queryFn: fetchNotificationCount,
staleTime: 1000 * 10, // 10초
});
// 앱 설정 — 배포 전까지 안 바뀐다
const { data: appConfig } = useQuery({
queryKey: ['app-config'],
queryFn: fetchAppConfig,
staleTime: Infinity,
});staleTime: Infinity는 "이 데이터는 절대 stale하지 않다"는 뜻이다. 수동으로 invalidateQueries를 호출하기 전까지는 refetch가 일어나지 않는다.
패턴 3: Infinity가 맞는 경우
어떤 데이터에 Infinity를 써야 할까? 내가 쓰는 기준은 간단하다.
- 서버에서 내려주는 설정값 (feature flags, 앱 설정)
- 한번 fetch하면 세션 동안 바뀔 일이 없는 데이터 (사용자의 권한, 플랜 정보)
- 드롭다운에 들어가는 코드 테이블 (국가 목록, 카테고리 목록)
// feature flags는 배포할 때만 바뀐다
const { data: flags } = useQuery({
queryKey: ['feature-flags'],
queryFn: fetchFeatureFlags,
staleTime: Infinity,
});
// 카테고리 목록은 관리자가 추가하지 않는 한 안 바뀐다
const { data: categories } = useQuery({
queryKey: ['categories'],
queryFn: fetchCategories,
staleTime: Infinity,
});이런 query들이 탭 전환할 때마다 refetch되는 건 순수한 낭비다.
staleTime과 gcTime의 관계
여기서 많이 헷갈리는 게 gcTime(v4까지는 cacheTime)이다. 둘 다 시간인데 뭐가 다른 걸까.
staleTime은 "데이터가 얼마나 오래 fresh한가"를 결정한다. fresh 상태에서는 refetch를 안 한다.
gcTime은 "inactive 상태의 데이터가 캐시에 얼마나 오래 남아 있는가"를 결정한다. 기본값은 5분이다. 컴포넌트가 언마운트되어 query가 inactive가 된 후, 5분이 지나면 캐시에서 삭제된다.
이 두 값의 조합이 사용자 경험을 결정한다.
// 케이스 1: staleTime 0, gcTime 5분 (기본값)
// → 페이지 이동 후 돌아오면: 캐시 데이터 즉시 표시 + 백그라운드 refetch
// → 5분 뒤에 돌아오면: 캐시 없음 → 로딩 스피너 → fetch
// 케이스 2: staleTime 1분, gcTime 5분
// → 1분 내 돌아오면: 캐시 데이터 표시, refetch 없음
// → 1~5분 사이 돌아오면: 캐시 데이터 표시 + 백그라운드 refetch
// → 5분 뒤 돌아오면: 캐시 없음 → 로딩 스피너 → fetch
// 케이스 3: staleTime 10분, gcTime 5분 → 이러면 안 된다
// gcTime이 staleTime보다 짧으면 의미 없는 조합이 된다.
// 데이터가 아직 fresh인데 캐시에서 사라질 수 있다.
세 번째 케이스가 중요하다. staleTime을 gcTime보다 길게 설정하면, 데이터가 아직 "신선하다"고 판단되는 시점에 이미 캐시에서 삭제되어 있을 수 있다. React Query v5부터는 이런 경우 gcTime을 자동으로 staleTime에 맞춰 올려주지만, 명시적으로 설정하는 게 좋다.
// staleTime이 길면 gcTime도 맞춰서 설정
const { data } = useQuery({
queryKey: ['heavy-report'],
queryFn: fetchHeavyReport,
staleTime: 1000 * 60 * 10, // 10분
gcTime: 1000 * 60 * 15, // 15분
});queryClient 기본값 패턴 — 팀을 구한 설정
우리 팀에서 최종적으로 적용한 패턴이다. QueryClient의 기본값을 데이터 특성에 맞게 잡고, 개별 query에서 필요할 때만 오버라이드한다.
// lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 기본 1분
gcTime: 1000 * 60 * 5, // 기본 5분 (변경 없음)
refetchOnWindowFocus: false, // 윈도우 포커스 refetch 끄기
retry: 1, // 실패 시 1번만 재시도
},
},
});refetchOnWindowFocus: false를 넣은 이유가 있다. 우리 PM은 대시보드를 켜놓고 슬랙, 노션, 구글 시트를 왔다갔다한다. 포커스가 돌아올 때마다 10개 넘는 query가 refetch되면 브라우저가 순간적으로 버벅인다. 데이터가 1분 이내로 fresh하면 어차피 refetch 안 하지만, stale 상태일 때 포커스만으로 refetch가 터지는 건 우리 서비스에서는 과했다.
이 설정을 적용한 뒤에, query factory 패턴도 같이 도입했다.
// queries/dashboard.ts
export const dashboardQueries = {
all: () => ['dashboard'] as const,
summary: () => ({
queryKey: [...dashboardQueries.all(), 'summary'] as const,
queryFn: fetchDashboardSummary,
staleTime: 1000 * 60 * 2, // 요약은 2분
}),
chart: (type: string) => ({
queryKey: [...dashboardQueries.all(), 'chart', type] as const,
queryFn: () => fetchChart(type),
staleTime: 1000 * 60 * 5, // 차트는 5분 — 그려지는 데 시간이 걸리니까
}),
realtimeMetrics: () => ({
queryKey: [...dashboardQueries.all(), 'realtime'] as const,
queryFn: fetchRealtimeMetrics,
staleTime: 1000 * 10, // 실시간 지표만 10초
}),
};컴포넌트에서는 이렇게 쓴다.
// DashboardSummary.tsx
import { dashboardQueries } from '@/queries/dashboard';
function DashboardSummary() {
const { data } = useQuery(dashboardQueries.summary());
// ...
}query 옵션이 한 곳에 모여 있으니까, 나중에 "대시보드 API의 staleTime을 전부 늘리자"는 결정이 나왔을 때 파일 하나만 수정하면 됐다.
적용 후 숫자
변경 전: 탭 전환 시 평균 47 API 호출, 윈도우 포커스 복귀 시 평균 12 API 호출.
변경 후: 탭 전환 시 평균 3 API 호출 (stale 상태인 query만), 윈도우 포커스 복귀 시 0 API 호출.
백엔드 팀에서 대시보드 관련 API의 일일 호출량이 60% 넘게 줄었다고 알려줬다. 코드 변경은 queryClient.ts 하나와 query factory 파일 몇 개였다. 비즈니스 로직은 한 줄도 안 건드렸다.
판단 기준
모든 query에 같은 staleTime을 적용하라는 게 아니다. 데이터마다 "얼마나 빨리 바뀌는가"와 "바뀐 걸 얼마나 빨리 사용자에게 보여줘야 하는가"가 다르다.
주문 상태처럼 사용자가 지금 보고 있고 바뀌면 바로 알아야 하는 데이터는 staleTime을 짧게 잡거나 polling을 쓴다. 사용자 프로필이나 앱 설정처럼 세션 중에 거의 안 바뀌는 데이터는 길게 잡는다. "이 데이터가 30초 전 것이면 사용자가 불편할까?"라고 자문하면 대부분 답이 나온다.
그리고 staleTime을 설정했다고 해서 데이터가 영원히 안 바뀌는 게 아니다. queryClient.invalidateQueries를 호출하면 수동으로 stale 처리할 수 있다. mutation 성공 후에 관련 query를 invalidate하는 패턴은 거의 모든 프로젝트에서 쓴다.
const mutation = useMutation({
mutationFn: updateOrder,
onSuccess: () => {
// 주문 관련 query를 전부 stale 처리 → 다음 마운트나 포커스에서 refetch
queryClient.invalidateQueries({ queryKey: ['orders'] });
},
});staleTime은 "자동 refetch의 빈도를 조절하는 밸브"라고 생각하면 편하다. 밸브를 완전히 열어두면(0ms) 항상 최신이지만 네트워크 비용이 크고, 잠그면(Infinity) 수동으로만 갱신되지만 불필요한 호출이 사라진다. 대부분의 데이터는 그 사이 어딘가에 적절한 값이 있다.
React Query 공식 문서를 읽으면 staleTime 관련 내용이 짧게 나온다. TkDodo의 블로그에서 훨씬 자세히 다루고 있으니, 시간이 되면 "Practical React Query" 시리즈를 읽어보는 걸 권한다. 특히 "#1: Practical React Query"와 "#18: Inside React Query" 글이 캐시 동작을 이해하는 데 많이 도움된다.
47번의 API 호출을 3번으로 줄인 건, 아키텍처를 뜯어고친 게 아니라 숫자 하나를 0에서 60000으로 바꾼 것에서 시작했다.
