useQuery에 staleTime 구현하기: stale-while-revalidate 패턴 톺아보기

date
Feb 23, 2025
slug
usequery-staletime-and-swr
author
status
Public
tags
TanStackQuery
summary
type
Post
thumbnail
thumbnail.jpg
category
updatedAt
Mar 1, 2025 06:04 AM
 
클라이언트에서 데이터를 가져올 때 매번 새 요청을 보내는 것은 불필요한 네트워크 트래픽을 증가시키고, 사용자 경험(UX)을 저하시킬 수 있습니다. 이를 개선하기 위해, 우리는 이전 글에서 useQuery를 만들면서 캐시를 활용해 불필요한 요청을 줄이는 방법을 살펴봤습니다.
 
하지만, 단순히 cacheTime만을 적용하는 것만으로는 부족한 점이 있습니다. 캐시가 만료되기 전까지는 데이터를 계속 사용할 수 있지만, 캐시가 만료되는 순간 새로운 데이터를 가져올 때까지 로딩 상태가 발생하기 때문이죠.
 
이 문제를 해결하기 위해, 이번 글에서는 staleTime을 추가하여 데이터를 더 효율적으로 관리하는 방법을 살펴보겠습니다. 또한, 이를 제대로 이해하기 위해 staleTime이 활용하는 SWR(stale-while-revalidate) 패턴이 무엇인지도 함께 알아보겠습니다.
 
 

이번 글에서 다룰 내용


✔️ useQuery 훅을 직접 구현하며 staleTime을 추가하는 과정
✔️ cacheTimestaleTime의 차이점 및 역할
✔️ 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
  1. 사용자가 데이터를 요청하면 useQuery는 먼저 캐시에서 확인
  1. cacheTime이 지나지 않았으면 기존 데이터를 그대로 반환
  1. staleTime이 지나면 백그라운드에서 새 데이터를 요청하여 캐시 갱신
  1. cacheTime이 지나면 캐시 삭제 후, API에서 새 데이터를 받아옴
 

 

3. 자체제작 useQuery에 staleTime 옵션 직접 추가하기

3.1 staleTimestamp 추가

이제 데이터가 stale한지 판단하기 위한 staleTimestamp를 추가합니다.
const queryCache = new Map<string, CacheData<unknown>>();
→ 기존 queryCachestaleTimestamp 값을 추가하여 관리
 

3.2 staleTime이 지나면 데이터가 stale 상태로 변경

const isStale = cachedData ? now - cachedData.staleTimestamp > staleTime : true;
staleTime이 지나면 isStaletrue로 설정
 

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 패턴이란?

notion image
웹 애플리케이션에서 데이터를 가져올 때, 항상 새로운 데이터를 요청하면 불필요한 네트워크 트래픽이 발생하고,
반대로 오래된 캐시 데이터를 그대로 사용하면 최신성이 보장되지 않는 문제가 생깁니다.
이를 해결하기 위한 전략이 바로 SWR(stale-while-revalidate) 패턴입니다.
 
SWR 패턴의 핵심 원리
  1. 캐시된 데이터를 즉시 반환하여 빠른 응답을 제공
  1. 백그라운드에서 새로운 데이터를 요청하여 최신 상태 유지
  1. 새로운 데이터가 도착하면 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=6060초 동안 fresh 상태 유지
  • stale-while-revalidate=3060초가 지나도 30초 동안 기존 캐시 데이터를 즉시 제공하며, 백그라운드에서 새 데이터를 요청
 
동작 방식
  1. 클라이언트가 요청을 보냈을 때, 캐시가 fresh 상태라면 즉시 반환
  1. max-age=60이 지나면 stale 상태가 되지만,
  1. stale-while-revalidate=30 동안에는 기존 데이터를 반환하면서도 백그라운드에서 새로운 요청을 보냄
  1. 새 데이터가 도착하면 캐시를 갱신
즉, 사용자는 로딩 없이 데이터를 받아볼 수 있고, 최신 데이터가 준비되면 업데이트됨
 

 

2. stale-if-error directive

캐시가 만료되었더라도, 네트워크 요청이 실패하면 기존 캐시를 반환하는 방식
 
사용 예시 (HTTP 응답 헤더)
Cache-Control: max-age=60, stale-if-error=120
 
이 설정의 의미
  • max-age=6060초 동안 fresh 상태 유지
  • stale-if-error=12060초 이후에도 네트워크 요청이 실패하면 기존 데이터를 최대 120초 동안 계속 제공
 
동작 방식
  1. 캐시된 데이터의 max-age=60이 지나면 stale 상태
  1. 만약 이 상태에서 새로운 요청이 실패하면, 기존 stale 데이터를 그대로 반환
  1. 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를 고민하는 과정이 모여 더 나은 서비스를 만든다는 사실을 다시금 되새기며,
저 역시 이런 고민을 이어가야겠다는 다짐을 해봅니다. 😊