캐시를 도입하면 응답 속도는 빨라집니다. 그런데 운영을 조금만 해보면 곧바로 다른 문제가 생깁니다. TTL을 길게 두면 오래된 데이터가 남고, TTL을 짧게 두면 캐시 miss가 늘어 원본 저장소가 다시 힘들어집니다. 특히 트래픽이 몰리는 서비스에서는 캐시가 만료되는 순간 DB나 외부 API로 요청이 한꺼번에 몰리면서 latency가 튀는 일이 자주 벌어집니다. 이럴 때 유용한 전략이 stale-while-revalidate(SWR) 입니다. 일단 약간 오래된 캐시를 빠르게 반환하고, 백그라운드에서 최신 데이터를 다시 가져와 캐시를 갱신하는 방식입니다. 사용자는 빠른 응답을 받고, 시스템은 원본 저장소에 갑작스러운 부하를 덜 주게 됩니다. 이 글에서는 Node.js에서 stale-while-revalidate 캐시 전략이 왜 필요한지, 어떤 상황에 잘 맞는지, Redis 기반으로 어떻게 구현하면 좋은지 실무 기준으로 정리합니다.

Node.js stale-while-revalidate 전략은 왜 필요할까

H3. TTL만으로는 속도와 최신성을 동시에 맞추기 어렵다

가장 단순한 캐시 정책은 TTL 하나로 모든 것을 해결하려는 방식입니다. 예를 들어 상품 목록을 60초 동안 캐시하면 그 60초 안에는 빠른 응답을 줄 수 있습니다. 하지만 61초가 되는 순간 첫 요청은 다시 원본 데이터를 읽어야 하고, 그 시점에 트래픽이 겹치면 DB나 외부 API가 갑자기 바빠집니다.

문제는 여기서 끝나지 않습니다. TTL을 짧게 줄이면 최신성은 좋아지지만 캐시 효율이 떨어지고, TTL을 길게 늘리면 성능은 좋아져도 정보가 낡습니다. 실제 운영에서는 이 둘을 동시에 만족시키고 싶은 경우가 많습니다.

stale-while-revalidate는 이 딜레마를 완화합니다.

  • 캐시가 신선하면 그대로 반환
  • 캐시가 약간 오래됐어도 일단 빠르게 반환
  • 동시에 백그라운드에서 재검증 또는 재생성 수행
  • 새 데이터가 준비되면 다음 요청부터 최신값 반환

사용자 체감 속도와 시스템 안정성을 우선 확보하면서 최신성도 뒤늦게 따라오게 만드는 전략입니다.

H3. 캐시 만료 순간의 부하 집중을 줄이는 데 특히 효과적이다

운영에서 진짜 무서운 건 평균 부하가 아니라 특정 순간의 집중입니다. 캐시 키 하나가 만료됐을 때 같은 데이터를 찾는 요청 수백 개가 동시에 들어오면, 사실상 캐시가 없는 것과 비슷한 상황이 생길 수 있습니다.

이 문제는 이전에 다룬 Node.js Cache Stampede 방지 가이드와도 맞닿아 있습니다. cache stampede 방지가 “동시에 같은 재생성을 하지 않게 막는 것”이라면, stale-while-revalidate는 “굳이 모두가 새 값을 기다리지 않게 만드는 것”에 가깝습니다. 둘은 경쟁 관계가 아니라 같이 써야 더 강해집니다.

stale-while-revalidate는 어떤 상황에 잘 맞을까

H3. 약간의 지연 최신성이 허용되는 읽기 중심 데이터에 잘 맞는다

SWR은 모든 데이터에 적합하지는 않습니다. 몇 초 또는 몇 분 정도의 오래된 데이터를 보여줘도 큰 문제가 없는 경우에 특히 잘 맞습니다.

대표적인 예시는 아래와 같습니다.

  • 메인 페이지 랭킹 목록
  • 상품/콘텐츠 리스트
  • 대시보드 통계 요약
  • 공지 목록이나 추천 영역
  • 프로필 카드, 비핵심 설정 정보

반대로 아래처럼 강한 최신성이 필요한 데이터에는 주의해야 합니다.

  • 결제 상태
  • 재고 수량
  • 주문 직후 상세 상태
  • 권한/인증 관련 정보
  • 보안 정책이나 접근 제어 데이터

핵심은 간단합니다. 늦어도 되는 데이터에는 SWR이 매우 강력하지만, 틀리면 안 되는 데이터에는 보수적으로 접근해야 합니다.

H3. 외부 API나 무거운 집계 쿼리 앞단에서 효과가 크다

원본 데이터가 비싸게 만들어질수록 SWR의 가치가 커집니다. 예를 들어 아래 상황에서는 체감 효과가 큽니다.

  • 외부 환율 API처럼 호출 비용과 지연이 큰 경우
  • 여러 테이블을 묶는 무거운 집계 쿼리
  • 추천 결과처럼 계산 비용이 큰 응답
  • 캐시 miss 시 DB 부담이 급증하는 조회 API

이런 경우 stale 응답을 먼저 주고 재생성을 뒤로 미루면, 사용자 대기 시간과 원본 시스템 부하를 함께 낮출 수 있습니다.

Node.js에서 stale-while-revalidate를 어떻게 설계할까

H3. fresh TTL과 stale TTL을 분리해서 생각하는 편이 좋다

SWR을 구현할 때 가장 중요한 포인트는 TTL을 하나로 보지 않는 것입니다. 보통은 아래 두 구간으로 나눠 생각합니다.

  1. fresh 구간: 바로 반환해도 되는 신선한 상태
  2. stale 구간: 사용자에게는 반환 가능하지만 백그라운드 갱신이 필요한 상태

예를 들어 이렇게 둘 수 있습니다.

  • fresh TTL: 60초
  • stale 허용 구간: 추가 240초

이 경우 캐시 생성 후 60초까지는 완전 신선한 데이터입니다. 61초부터 300초까지는 stale 상태지만 응답으로는 반환할 수 있습니다. 다만 이 구간에서는 백그라운드 재검증을 트리거합니다. 300초를 넘기면 너무 오래됐다고 판단해 새 값을 직접 생성하거나, 상황에 따라 fallback을 사용합니다.

이 구조를 쓰면 최신성과 성능의 균형을 더 세밀하게 조절할 수 있습니다.

H3. 메타데이터를 함께 저장해야 제어가 쉬워진다

단순히 값만 Redis에 넣으면 fresh인지 stale인지 판단하기 어렵습니다. 그래서 보통은 아래 메타데이터를 함께 저장합니다.

  • 실제 payload
  • 생성 시각(createdAt)
  • fresh 만료 시각(freshUntil)
  • 최종 만료 시각(expiresAt)
  • 버전 또는 스키마 버전

예를 들면 Redis에는 JSON 형태로 아래처럼 저장할 수 있습니다.

{
  "data": { "items": [] },
  "createdAt": 1774522800000,
  "freshUntil": 1774522860000,
  "expiresAt": 1774523100000,
  "version": 1
}

이렇게 해두면 요청을 받았을 때 다음처럼 분기할 수 있습니다.

  • now <= freshUntil 이면 fresh hit
  • freshUntil < now <= expiresAt 이면 stale hit + background refresh
  • now > expiresAt 이면 hard miss

구조가 분명해지면 모니터링도 쉬워집니다.

Redis 기반 stale-while-revalidate 구현 예시

H3. 요청 경로에서는 stale 데이터를 빠르게 반환하고 갱신은 비동기로 넘긴다

아래 예시는 개념을 단순화한 Node.js 코드입니다. 실제 서비스에서는 로깅, 메트릭, 에러 처리, 분산 락을 더 보완하는 편이 좋습니다.

interface CacheEnvelope<T> {
  data: T;
  createdAt: number;
  freshUntil: number;
  expiresAt: number;
}

class SwrCacheService {
  constructor(
    private readonly redis: { get(key: string): Promise<string | null>; set(key: string, value: string, mode?: string, ttl?: number): Promise<void> },
  ) {}

  async getOrRefresh<T>(
    key: string,
    fetcher: () => Promise<T>,
    freshTtlMs: number,
    staleTtlMs: number,
  ): Promise<T> {
    const now = Date.now();
    const raw = await this.redis.get(key);

    if (raw) {
      const cached = JSON.parse(raw) as CacheEnvelope<T>;

      if (now <= cached.freshUntil) {
        return cached.data;
      }

      if (now <= cached.expiresAt) {
        void this.refreshInBackground(key, fetcher, freshTtlMs, staleTtlMs);
        return cached.data;
      }
    }

    const fresh = await fetcher();
    await this.writeEnvelope(key, fresh, freshTtlMs, staleTtlMs);
    return fresh;
  }

  private async refreshInBackground<T>(
    key: string,
    fetcher: () => Promise<T>,
    freshTtlMs: number,
    staleTtlMs: number,
  ) {
    try {
      const fresh = await fetcher();
      await this.writeEnvelope(key, fresh, freshTtlMs, staleTtlMs);
    } catch (error) {
      console.error('SWR background refresh failed', { key, error });
    }
  }

  private async writeEnvelope<T>(
    key: string,
    data: T,
    freshTtlMs: number,
    staleTtlMs: number,
  ) {
    const now = Date.now();
    const payload: CacheEnvelope<T> = {
      data,
      createdAt: now,
      freshUntil: now + freshTtlMs,
      expiresAt: now + freshTtlMs + staleTtlMs,
    };

    const ttlSeconds = Math.ceil((freshTtlMs + staleTtlMs) / 1000);
    await this.redis.set(key, JSON.stringify(payload), 'EX', ttlSeconds);
  }
}

이 구현의 핵심은 요청 경로가 가능한 한 빨리 끝난다는 점입니다. stale 상태에서는 사용자 요청이 비싼 fetcher를 기다리지 않고, 캐시된 응답을 먼저 돌려받습니다.

H3. background refresh는 반드시 중복 실행을 막아야 한다

위 예제만 그대로 쓰면, stale 구간에 들어온 요청마다 모두 background refresh를 시작할 수 있습니다. 그러면 원래 줄이려던 부하를 다시 만들어버립니다. 그래서 refresh 작업에는 보통 짧은 분산 락이 필요합니다.

예를 들어 Redis SET lockKey value NX EX 10 같은 방식으로 10초 락을 잡고, 락을 잡은 한 요청만 실제 재생성을 수행하도록 설계합니다. 이 패턴은 singleflight 기반 stampede 방지와 함께 적용하는 편이 가장 안전합니다.

개념적으로는 이렇게 분리할 수 있습니다.

  • SWR: 사용자는 stale 값을 받고 기다리지 않음
  • singleflight/lock: 재생성은 한 번만 수행

둘을 함께 써야 “빠르면서도 안전한 캐시”에 가까워집니다.

stale-while-revalidate를 운영에 붙일 때 자주 하는 실수

H3. stale 허용 구간을 너무 길게 둬서 사실상 낡은 데이터를 상시 제공하는 경우

SWR이 편하다고 stale 기간을 과도하게 늘리면, 사용자 경험이 좋아지는 것이 아니라 단지 오래된 데이터를 빨리 보여주는 시스템이 됩니다. 특히 랭킹, 가격, 노출 순서처럼 시간이 지나면 의미가 크게 달라지는 데이터는 stale 허용 시간을 보수적으로 잡아야 합니다.

실무에서는 아래 질문으로 기준을 잡는 편이 좋습니다.

  • 이 데이터가 30초 늦어도 괜찮은가
  • 3분 늦어도 괜찮은가
  • 10분 늦으면 사용자 판단이 달라지는가

허용 가능한 지연 최신성을 먼저 정하고, 그 안에서 fresh/stale 구간을 나누는 것이 안전합니다.

H3. refresh 실패를 조용히 삼켜서 캐시가 계속 낡아지는 경우

background refresh는 요청 경로 바깥에서 일어나기 때문에, 실패가 눈에 잘 띄지 않습니다. 그래서 로그를 남기지 않으면 실제로는 몇 시간째 stale 응답만 나가고 있어도 운영자가 모를 수 있습니다.

최소한 아래 지표는 반드시 수집하는 편이 좋습니다.

  • fresh hit 비율
  • stale hit 비율
  • hard miss 비율
  • background refresh 성공/실패 횟수
  • refresh 평균 소요 시간
  • 캐시 키별 재생성 빈도

이 데이터를 보면 “속도는 빠른데 왜 데이터가 자주 오래되나” 같은 문제를 훨씬 빨리 파악할 수 있습니다.

SWR과 다른 안정화 패턴은 어떻게 같이 써야 할까

H3. request hedging, load shedding, adaptive concurrency와 역할이 다르다

SWR은 캐시 계층의 전략입니다. 느린 원본 접근을 매 요청마다 직접 겪지 않게 해주는 역할을 합니다. 하지만 이것만으로 과부하가 사라지는 것은 아닙니다.

예를 들어 다음 조합이 현실적입니다.

  • 캐시가 있으면 SWR로 빠른 응답 제공
  • 캐시가 완전히 만료됐고 재생성이 느리면 request hedging 대신 신중한 fallback 사용
  • 원본 시스템이 과부하이면 load shedding으로 일부 요청 보호
  • 다운스트림 동시 호출이 급증하면 adaptive concurrency limit로 압력 제어

즉 SWR은 빠른 응답을 위한 첫 번째 방어선이고, 나머지 패턴은 장애 반경을 줄이는 후속 장치에 가깝습니다.

H3. 쓰기 이벤트와 연결하면 최신성 문제를 더 줄일 수 있다

TTL 기반 SWR만 쓰면 결국 시간 기준으로만 갱신됩니다. 하지만 실제 서비스에서는 데이터 변경 이벤트가 이미 존재하는 경우가 많습니다. 예를 들어 상품 정보가 수정되거나 공지가 등록되면 해당 캐시 키를 즉시 삭제하거나 비동기 재생성을 예약할 수 있습니다.

이렇게 하면 평소에는 SWR로 빠른 응답을 유지하면서도, 중요한 변경이 생긴 직후에는 stale 시간을 훨씬 짧게 만들 수 있습니다. 즉 시간 기반 갱신과 이벤트 기반 무효화를 함께 쓰는 구조가 실무에서는 가장 균형이 좋습니다.

실무 체크리스트

H3. 도입 전 최소한 이 항목은 정리해두는 편이 좋다

  • 이 데이터가 어느 정도까지 오래돼도 허용되는가
  • fresh TTL과 stale TTL을 분리해 설계했는가
  • stale 구간 refresh에 중복 방지 락이 있는가
  • refresh 실패 로그와 메트릭을 남기는가
  • hard miss 시 fallback 전략이 정의돼 있는가
  • 이벤트 기반 무효화가 필요한 키를 구분했는가
  • fresh hit, stale hit, hard miss 비율을 관측하는가
  • 민감하거나 정합성이 중요한 데이터에 무분별하게 적용하지 않는가

FAQ

H3. stale-while-revalidate와 cache-aside는 같은가

같지 않습니다. cache-aside는 애플리케이션이 miss 시 원본에서 읽어 캐시에 채우는 일반적인 패턴이고, SWR은 그 위에서 stale 응답을 허용하면서 비동기 갱신을 결합한 운영 전략에 가깝습니다. 즉 SWR은 cache-aside의 확장형으로 이해하는 편이 더 정확합니다.

H3. Redis 없이도 구현할 수 있을까

가능합니다. 프로세스 메모리 캐시로도 개념 구현은 할 수 있습니다. 다만 다중 인스턴스 환경에서는 캐시 공유, 락, 일관성 관리가 어려워지므로 실무에서는 Redis 같은 공용 캐시 계층이 훨씬 유리합니다.

마무리

Node.js에서 stale-while-revalidate 캐시 전략은 단순한 캐시 최적화 기법이 아닙니다. 사용자에게는 빠른 응답을 유지하고, 시스템에는 원본 데이터 소스의 숨 쉴 틈을 만들어주는 운영 방식에 가깝습니다.

TTL 하나만 조절하면서 속도와 최신성을 동시에 맞추려 하면 결국 어느 한쪽이 무너집니다. 반면 SWR을 도입하면 “조금 오래됐지만 바로 보여줄 수 있는 값”과 “지금 당장 새로 만들어야 하는 값”을 분리해서 다룰 수 있습니다. 이 구분이 생기면 tail latency도 줄고, 원본 저장소 부하도 훨씬 부드럽게 관리할 수 있습니다.

다만 아무 데이터에나 적용하면 안 됩니다. 정합성이 중요한 데이터는 보수적으로 다뤄야 하고, stale 허용 시간과 refresh 실패 관측을 함께 설계해야 합니다. 그 기준만 잘 잡으면 SWR은 읽기 중심 Node.js 서비스에서 꽤 오래 쓰게 되는 실용적인 패턴입니다.