useQuery에 staleTime 구현하기: stale-while-revalidate 패턴 톺아보기
클라이언트에서 데이터를 가져올 때 매번 새 요청을 보내는 것은 불필요한 네트워크 트래픽을 증가시키고, 사용자 경험(UX)을 저하시킬 수 있습니다. 이를 개선하기 위해, 우리는 이전 글에서 useQuery를 만들면서 캐시를 활용해 불필요한 요청을 줄이는 방법을 살펴봤습니다.
하지만, 단순히
cacheTime
만을 적용하는 것만으로는 부족한 점이 있습니다. 캐시가 만료되기 전까지는 데이터를 계속 사용할 수 있지만, 캐시가 만료되는 순간 새로운 데이터를 가져올 때까지 로딩 상태가 발생하기 때문이죠.이 문제를 해결하기 위해, 이번 글에서는
staleTime
을 추가하여 데이터를 더 효율적으로 관리하는 방법을 살펴보겠습니다. 또한, 이를 제대로 이해하기 위해 staleTime이 활용하는 SWR(stale-while-revalidate
) 패턴이 무엇인지도 함께 알아보겠습니다.이번 글에서 다룰 내용
✔️
useQuery
훅을 직접 구현하며 staleTime
을 추가하는 과정✔️
cacheTime
과 staleTime
의 차이점 및 역할✔️ SWR 패턴을 활용해 최신 데이터를 유지하면서도 빠른 응답을 제공하는 방법
이제
staleTime
이 어떻게 동작하는지 하나씩 살펴보겠습니다. 1. cacheTime
만 쓰면 어떤 점이 아쉬운가요?
1.1 cacheTime
은 어떻게 구현되었더라
const isExpired = cachedData ? now - cachedData.timestamp > cacheTime : true;
→ 캐시된 데이터는
cacheTime
이 지나기 전까지 유지되며, 시간이 초과되면 삭제됩니다. 이후 새로운 요청이 발생하면 다시 데이터를 가져오죠.1.2 cacheTime
만 쓰면 아쉬운 점
1. 최신 데이터가 필요한데 갱신되지 않을 수 있음
cacheTime
이 만료되기 전까지는 이전 데이터를 계속 사용하므로 → 사용자가 최신 데이터를 확인할 방법이 없음.
- 만약
cacheTime
을 길게 설정하면? → 최신성이 떨어짐 😕
- 반대로 짧게 설정하면? → 불필요한 네트워크 요청이 많아짐 😵
2. cacheTime이 지나면 데이터가 삭제되므로, 새 요청이 오기까지 로딩이 발생함
cacheTime
이 초과되면 캐시된 데이터를 즉시 삭제함.
- 사용자가 페이지를 열었을 때, 캐시가 만료되었으면? → 데이터가 사라지고, 새 데이터를 가져오는 동안 로딩 상태가 발생함.
- 즉, "이전 데이터를 유지하면서도 최신 데이터로 갱신하는 방법"이 필요함.
1.3 해결 방법 → staleTime
추가
staleTime
을 도입하면?✔️ 이전 데이터를 유지하면서도, 백그라운드에서 최신 데이터를 요청 가능
✔️
staleTime
이 지나기 전까지는 기존 데이터를 그대로 표시 → 로딩 없이 즉시 응답✔️
staleTime
이 지나면, 데이터를 stale 상태로 간주하고 백그라운드에서 새 요청을 보내 최신 데이터로 갱신2. staleTime
이해하기
2.1 staleTime
이란?
staleTime
은 데이터가 "신선한(fresh)" 상태로 유지되는 시간을 의미합니다.
staleTime
이 지나면 데이터가 "stale(오래된)" 상태로 간주되며 → 화면에는 기존 데이터를 유지하되, 백그라운드에서 새로운 데이터를 요청
2.2 staleTime
vs cacheTime
무슨 차이점이 있나요?
옵션 | 동작 방식 |
cacheTime | 캐시 데이터를 유지하는 최대 시간 (이 시간이 지나면 삭제됨) |
staleTime | 데이터가 "신선한" 상태를 유지하는 시간 (지나면 백그라운드에서 새 요청) |
2.3 staleTime
을 적용하면 어떻게 달라질까?
✔️ 캐시된 데이터를 즉시 제공하여 빠른 응답 가능
✔️
staleTime
이후에는 백그라운드에서 새 데이터를 가져와 UI 업데이트✔️ 최신 데이터 요청 시에도 로딩 상태가 발생하지 않음
cacheTime와 staleTime이 동작하는 흐름 살펴보기
sequenceDiagram participant User participant useQuery participant Cache participant API User->>useQuery: 데이터 요청 useQuery->>Cache: 캐시된 데이터 확인 alt 캐시 유효 (cacheTime 내) alt 데이터 신선함 (staleTime 내) useQuery-->>User: 캐시된 데이터 반환 ✅ else 데이터 Stale useQuery-->>User: 캐시된 데이터 반환 ✅ useQuery->>API: 백그라운드에서 새 데이터 요청 🔄 API-->>useQuery: 새로운 데이터 응답 📡 useQuery->>Cache: 캐시 업데이트 💾 end else 캐시 만료 (cacheTime 초과) useQuery->>User: 로딩 상태 표시 ⏳ useQuery->>API: 새로운 데이터 요청 🔄 API-->>useQuery: 새로운 데이터 응답 📡 useQuery->>Cache: 새로운 데이터 저장 💾 useQuery-->>User: 새로운 데이터 반환 ✅ end
- 사용자가 데이터를 요청하면
useQuery
는 먼저 캐시에서 확인
cacheTime
이 지나지 않았으면 기존 데이터를 그대로 반환
staleTime
이 지나면 백그라운드에서 새 데이터를 요청하여 캐시 갱신
cacheTime
이 지나면 캐시 삭제 후, API에서 새 데이터를 받아옴
3. 자체제작 useQuery에 staleTime
옵션 직접 추가하기
3.1 staleTimestamp
추가
이제 데이터가 stale한지 판단하기 위한
staleTimestamp
를 추가합니다.const queryCache = new Map<string, CacheData<unknown>>();
→ 기존
queryCache
에 staleTimestamp
값을 추가하여 관리3.2 staleTime
이 지나면 데이터가 stale 상태로 변경
const isStale = cachedData ? now - cachedData.staleTimestamp > staleTime : true;
→
staleTime
이 지나면 isStale
을 true
로 설정3.3 캐시된 데이터가 stale한 경우 백그라운드에서 새 요청
if (cache && cachedData && !isExpired && isStale) { setData(cachedData.data); // 기존 데이터 유지 setIsPending(false); }
→ 오래된 데이터(stale)라도 화면에는 표시하지만, 새 데이터를 백그라운드에서 요청
3.4 백그라운드에서 데이터 업데이트 실행
if (isStale || !cachedData) { if (!cachedData) setIsPending(true); executeQuery(); // 새 데이터 요청 }
→
staleTime
이 지나면 새 요청을 백그라운드에서 실행하여 최신 데이터로 업데이트전체 코드
import { useEffect, useState } from 'react'; type CacheData<T> = { data: T; timestamp: number; staleTimestamp: number; }; const queryCache = new Map<string, CacheData<unknown>>(); type UseQueryProps<T = unknown> = { queryKey: string; queryFn: () => T | Promise<T>; retry?: boolean | number; cache?: boolean; cacheTime?: number; staleTime?: number; }; export function useQuery<TData = unknown, TError = unknown>({ queryKey, queryFn, retry = 3, cache = true, cacheTime = 5 * 60 * 1000, staleTime = 0, }: UseQueryProps<TData>) { const [isPending, setIsPending] = useState(true); const [error, setError] = useState<TError | null>(null); const [data, setData] = useState<TData | null>(() => { if (!cache) return null; const cachedData = queryCache.get(queryKey) as CacheData<TData> | undefined; if (!cachedData) { console.log(`[${queryKey}] 캐시된 데이터가 없습니다.`); return null; } const now = Date.now(); const timeSinceCache = now - cachedData.timestamp; const isExpired = timeSinceCache > cacheTime; if (isExpired) { console.log(`[${queryKey}] 캐시가 만료되었습니다. (${timeSinceCache}ms)`); return null; } else { console.log(`[${queryKey}] 캐시된 데이터를 사용합니다.`); return cachedData.data; } }); useEffect(() => { let isActive = true; let failureCount = 0; if (!cache) { queryCache.delete(queryKey); } const cachedData = queryCache.get(queryKey) as CacheData<TData> | undefined; const now = Date.now(); const isExpired = cachedData ? now - cachedData.timestamp > cacheTime : true; const isStale = cachedData ? now - cachedData.staleTimestamp > staleTime : true; if (cache && cachedData && !isExpired && !isStale) { console.log(`[${queryKey}] 데이터가 신선합니다. 재요청하지 않습니다.`); setData(cachedData.data); setIsPending(false); return; } if (cache && cachedData && !isExpired && isStale) { console.log(`[${queryKey}] 데이터가 오래되었습니다. 백그라운드에서 갱신합니다.`); setData(cachedData.data); setIsPending(false); } const executeQuery = async () => { try { console.log(`[${queryKey}] 데이터를 가져오는 중...`); const result = await queryFn(); if (!isActive) return; if (cache) { console.log(`[${queryKey}] 새로운 데이터를 캐시에 저장합니다.`); queryCache.set(queryKey, { data: result, timestamp: Date.now(), staleTimestamp: Date.now(), }); } setData(result); setIsPending(false); } catch (err) { if (!isActive) return; const shouldRetry = retry === true || (typeof retry === 'number' && failureCount < retry); if (shouldRetry) { console.log(`[${queryKey}] 요청 실패. 재시도 중... (${failureCount + 1}/${retry})`); failureCount++; executeQuery(); } else { console.error(`[${queryKey}] 최종 실패:`, err); setError(err as TError); setIsPending(false); } } }; if (isStale || !cachedData) { if (!cachedData) setIsPending(true); executeQuery(); } return () => { isActive = false; }; }, [queryKey, retry, cache, cacheTime, staleTime]); return { isPending, error, data }; }
실제 코드가 궁금하다면 ⇒ GitHub가서 코드 보기
화면으로 직접 보고싶다면 ⇒ 이동하기 *console창을 켜고 직접 확인해보세요!
시간 | 캐시 상태 | 동작 방식 |
0초 | ❇️ fresh | API 요청 후 데이터 저장 ( staleTimestamp 설정) |
10초 | 🔄 stale | 기존 데이터를 유지하면서 백그라운드에서 새로운 데이터 요청 → 성공 시 캐시 교체 |
20초 | 🔄 stale | 또다시 백그라운드에서 새 요청 → 성공 시 캐시 교체 |
30초 | 🔄 stale | 다시 백그라운드 요청 및 갱신 |
40초 | 🔄 stale | 다시 백그라운드 요청 및 갱신 |
... | 🔄 stale | 계속해서 10초마다 stale 데이터가 갱신됨 |
5분 (300초) | ❌ expired | cacheTime 이 만료되어 캐시 삭제 → 새로운 요청 시 로딩 화면 발생 |
4. stale-while-revalidate
패턴과의 연관성
지금까지 살펴본
staleTime
을 적용한 데이터 패칭 흐름을 보면, 어디선가 본 듯한 익숙한 패턴이 떠오르지 않나요? 바로 SWR(stale-while-revalidate
) 패턴입니다.4.1 SWR 패턴이란?

웹 애플리케이션에서 데이터를 가져올 때, 항상 새로운 데이터를 요청하면 불필요한 네트워크 트래픽이 발생하고,
반대로 오래된 캐시 데이터를 그대로 사용하면 최신성이 보장되지 않는 문제가 생깁니다.
이를 해결하기 위한 전략이 바로 SWR(stale-while-revalidate) 패턴입니다.
SWR 패턴의 핵심 원리
- 캐시된 데이터를 즉시 반환하여 빠른 응답을 제공
- 백그라운드에서 새로운 데이터를 요청하여 최신 상태 유지
- 새로운 데이터가 도착하면 UI를 업데이트하여 최신 데이터로 갱신
즉, 사용자는 즉각적인 응답을 받을 수 있고, 백그라운드에서 새로운 데이터를 가져오면서 최신 상태를 유지할 수 있습니다.
4.2 SWR를 코드로 이해하기
우리가 만든
useQuery
훅의 동작 방식도 SWR 패턴과 거의 동일합니다.SWR 개념을 코드로 표현하면 다음과 같습니다.
if (cache && cachedData && !isExpired) { setData(cachedData.data); // Stale 데이터 즉시 반환 if (isStale) executeQuery(); // 백그라운드에서 최신 데이터 요청 (Revalidate 진행) }
✔️
cacheTime
이 지나지 않았다면? → 캐시된 데이터를 즉시 반환 (빠른 응답)✔️
staleTime
이 지나지 않았다면? → 추가 요청 없이 기존 데이터를 유지✔️
staleTime
이 지났다면? → 기존 데이터를 유지하면서 백그라운드에서 최신 데이터를 요청이제 SWR 패턴이 어디에서 왔는지, 그리고 프런트엔드 생태계에서 어떻게 쓰이고 있는지 살펴보겠습니다.
5. SWR 패턴의 기원: HTTP 캐시 전략
SWR(
stale-while-revalidate
) 전략은 원래 HTTP Cache-Control
헤더에서 유래한 개념입니다.이 개념은 RFC 5861(HTTP Cache-Control 확장)에서 처음 등장했으며, 이를 기반으로 프런트엔드 생태계에서도 SWR 패턴이 발전하게 되었습니다.
5.1 RFC란?
RFC(Request for Comments, 의견 요청 문서) 는 인터넷 프로토콜 및 표준을 문서화한 공식 문서입니다.
IETF(Internet Engineering Task Force, 인터넷 엔지니어링 태스크 포스)에서 관리하며,
인터넷 기술 및 표준이 되는 프로토콜(TCP/IP, HTTP 등)에 대한 세부 명세를 정의합니다.
→ 즉, RFC는 인터넷 기술과 프로토콜의 공식적인 가이드라인 문서라고 볼 수 있습니다.
문서는 IETF의 공식 웹사이트인 Datatracker에서 관리되며, 일반적으로 인터넷 및 웹 기술의 발전 과정에서 표준으로 자리 잡는 경우가 많습니다.
5.2 RFC 5861 명세와 핵심 내용
RFC 5861 – HTTP Cache-Control Extensions for Stale Content
발행일: 2010년 5월
저자: Mark Nottingham (HTTP WG & W3C 기여자)
문서: RFC 5861
이 문서는 HTTP 캐시 시스템을 개선하기 위해
stale-while-revalidate(SWR)
/ stale-if-error
라는 Cache-Control 확장 디렉티브를 정의합니다.1. stale-while-revalidate(SWR) directive
→ 캐시된 데이터가 stale(오래됨) 상태라도 즉시 반환하고, 백그라운드에서 새로운 데이터를 요청하는 방식
HTTP 응답 헤더에 확장된 옵션
Cache-Control: max-age=60, stale-while-revalidate=30
이 설정의 의미
max-age=60
→ 60초 동안 fresh 상태 유지
stale-while-revalidate=30
→ 60초가 지나도 30초 동안 기존 캐시 데이터를 즉시 제공하며, 백그라운드에서 새 데이터를 요청
동작 방식
- 클라이언트가 요청을 보냈을 때, 캐시가 fresh 상태라면 즉시 반환
max-age=60
이 지나면 stale 상태가 되지만,
stale-while-revalidate=30
동안에는 기존 데이터를 반환하면서도 백그라운드에서 새로운 요청을 보냄
- 새 데이터가 도착하면 캐시를 갱신
즉, 사용자는 로딩 없이 데이터를 받아볼 수 있고, 최신 데이터가 준비되면 업데이트됨
2. stale-if-error directive
캐시가 만료되었더라도, 네트워크 요청이 실패하면 기존 캐시를 반환하는 방식
사용 예시 (HTTP 응답 헤더)
Cache-Control: max-age=60, stale-if-error=120
이 설정의 의미
max-age=60
→ 60초 동안 fresh 상태 유지
stale-if-error=120
→ 60초 이후에도 네트워크 요청이 실패하면 기존 데이터를 최대 120초 동안 계속 제공
동작 방식
- 캐시된 데이터의
max-age=60
이 지나면 stale 상태
- 만약 이 상태에서 새로운 요청이 실패하면, 기존 stale 데이터를 그대로 반환
stale-if-error=120
이 끝나기 전까지는 네트워크 오류 시 캐시 데이터를 유지
→ 즉, 서버 오류나 네트워크 장애가 발생해도, 사용자는 기존 데이터를 유지하면서 서비스 이용 가능!
TanStack Query - useQuery의
retry
옵션처럼, 요청이 실패했을 때 캐시 데이터를 사용하는 개념과 유사합니다.5.3 RFC 5861명세가 프런트엔드 생태계에서 어떻게 활용되고 있을까?
RFC 5861에서 정의한
stale-while-revalidate(SWR)
개념은, 오늘날 SWR 패턴 (stale 데이터를 즉시 제공하면서도 백그라운드에서 최신 데이터 요청) 의 기반이 되었습니다.RFC 5861 개념 | 적용된 라이브러리 / 프레임워크 |
stale-while-revalidate | SWR 라이브러리 ( useSWR ) |
stale-while-revalidate | TanStack Query ( staleTime ) |
stale-while-revalidate | Next.js fetch() 의 revalidate |
stale-if-error | TanStack Query ( retry ) |
stale-if-error | SWR 라이브러리의 fallbackData |
즉, 우리가 사용하는 SWR 데이터 패칭 전략은 RFC 5861의 HTTP 캐시 확장 디렉티브에서 유래한 것임을 알 수 있습니다.
결론
이번 글에서는 TanStack Query - useQuery의
staleTime
옵션을 직접 구현하며, 이를 이해하는 핵심 개념인 SWR(stale-while-revalidate
) 패턴까지 살펴봤습니다.프론트엔드에서 데이터를 패칭할 때 가장 중요한 고민은 최신 데이터를 유지하면서도 불필요한 API 요청을 최소화하는 것입니다.
이 글을 통해
staleTime
을 직접 구현하면서, SWR 전략의 개념과 유래, 그리고 프론트엔드 생태계에서 어떻게 활용되고 있는지 깊이 이해할 수 있었습니다.특히, 웹 전반에서 사용되는 HTTP 캐싱 전략이 TanStack Query, SWR 등 프론트엔드 라이브러리에서도 효과적으로 활용되고 있다는 점이 인상적이었습니다.
하지만, 서비스마다 요구사항과 데이터 특성이 다르므로 적절한 캐싱 전략을 선택하는 것이 중요하다는 점을 기억해주세요!
이번 글을 작성하면서 이러한 개념들이 단순한 이론이 아니라 더 나은 UX를 제공하기 위해 선배 개발자들이 오랜 시간 고민하고 발전시켜 온 결과물이라는 점을 다시 한번 느낄 수 있었습니다.
성능 최적화와 사용자 경험 향상을 위한 노력들이 쌓여, 지금의 훌륭한 라이브러리들이 만들어졌고, 앞으로도 계속 발전해 나가겠죠.
결국, 더 좋은 성능과 UX를 고민하는 과정이 모여 더 나은 서비스를 만든다는 사실을 다시금 되새기며,
저 역시 이런 고민을 이어가야겠다는 다짐을 해봅니다. 😊