본문으로 건너뛰기

캐시 경계와 무효화 전략 - 디자인 패턴, 시스템 수준 편 ep.08

캐시를 넣는 순간 같은 값이 두 개가 되고, 둘 중 하나는 반드시 낡는다.


이 경계는 무엇을 청구하는가

앞 편에서 값의 소유자를 정했습니다. 이제 그 값이 복제됩니다.

캐시는 대개 성능 이야기로 시작됩니다. “느리니까 앞에 두자”, “요청이 많으니까 앞에 두자”라는 이유입니다. 그런데 캐시를 넣는 결정은 사본을 만드는 결정입니다.

사본이 하나 생기면 “원본이 바뀌었을 때 이 사본은 어떻게 되는가”라는 질문이 하나 생깁니다. 사본이 다섯 개면 질문이 다섯 개입니다.

이 경계가 청구하는 것은 낡음입니다. 그리고 낡음은 두 가지 방식으로 청구됩니다.

  • 사용자가 옛 데이터를 봅니다. 재고가 0인데 “구매 가능”으로 보입니다.
  • 무효화 로직이 코드에 퍼집니다. 상품을 수정하는 코드가 CDN·서버 캐시·클라이언트 캐시를 모두 알아야 합니다.

두 번째가 더 무섭습니다. 캐시를 잘못 설계하면, 데이터를 쓰는 모든 코드가 캐시의 존재를 알아야 합니다.

캐시 경계가 답하는 질문은 하나입니다. 이 값의 사본은 몇 개이고 누가 낡았는가.


이 경계에 붙어 있는 이름들

다섯 개의 층

브라우저에서 데이터베이스까지 가는 길에 캐시가 있을 수 있는 자리는 다섯 곳입니다.

층위치무효화 주체범위
브라우저 캐시사용자 기기사용자·헤더그 사용자
CDN · 엣지전 세계 PoP(Point of Presence)퍼지 API모든 사용자
리버스 프록시우리 인프라직접 제어모든 사용자
애플리케이션 캐시서버 메모리·Redis코드모든 사용자
클라이언트 쿼리 캐시브라우저 메모리코드그 탭

각 층은 독립적으로 낡습니다. CDN을 퍼지해도 이미 브라우저에 내려간 사본은 그대로입니다. 캐시 문제가 어려운 이유가 여기에 있습니다. 무효화 명령이 위에서 아래로 흐르지 않습니다.

그래서 층마다 다른 전략이 필요합니다.

  • 바꿀 수 없는 것은 영원히 캐시합니다. 해시가 붙은 정적 파일(app.a3f21b.js)은 내용이 바뀌면 이름이 바뀝니다. 1년 캐시해도 안전합니다.
  • 바뀔 수 있는 것은 짧게 캐시하거나, 무효화 가능한 층에만 둡니다. 상품 가격을 브라우저에 1시간 캐시하면, 가격을 내려도 1시간 동안 옛 가격이 보입니다.
Cache-Control: public, max-age=31536000, immutable    ← 해시 붙은 파일
Cache-Control: public, max-age=0, s-maxage=60         ← CDN만 60초, 브라우저는 안 함
Cache-Control: private, no-store                       ← 개인 데이터

max-age와 s-maxage의 구분이 실무적으로 중요합니다. s-maxage는 공유 캐시(CDN)에만 적용됩니다. 브라우저는 캐시하지 않고 CDN만 캐시하게 하면, 우리가 퍼지할 수 있는 층에만 사본이 생깁니다.

다섯 계층의 캐시를 통과하는 요청 다이어그램. 좌측 사용자에서 우측 데이터베이스까지 가는 경로에 다섯 개의 캐시 층이 순서대로 배치되어 있습니다. 브라우저 캐시, CDN 엣지, 리버스 프록시, 애플리케이션 캐시, 클라이언트 쿼리 캐시입니다. 각 층에는 무효화 주체와 적용 범위가 표시됩니다. 브라우저 캐시는 사용자와 헤더가 제어하며 그 사용자에게만 적용되고, CDN은 퍼지 API로 제어하며 모든 사용자에게 적용됩니다. 각 층 아래에는 그 층에 사본이 남아 있을 때 발생하는 문제가 짧게 적혀 있습니다. 다이어그램 하단에는 무효화 명령이 위에서 아래로 흐르지 않는다는 점이 강조되어, CDN을 퍼지해도 이미 브라우저에 내려간 사본은 그대로 남는다는 상황이 별도 화살표로 표시되어 있습니다.

무효화 전략 세 가지

캐시를 언제 버릴 것인가에 대한 답은 세 종류입니다.

시간 기반(TTL): 일정 시간이 지나면 버립니다. 가장 단순하고 흔하지만, 정확하지 않습니다. 60초 TTL은 “최대 60초까지 낡은 데이터를 보여도 괜찮다”는 선언입니다.

이벤트 기반: 원본이 바뀌면 즉시 버립니다. 정확하지만, 쓰기 경로가 캐시를 알아야 합니다.

태그 기반: 캐시에 태그를 붙이고, 태그 단위로 버립니다. 이벤트 기반의 실무적 형태입니다.

// 태그 기반 — Next.js의 예
const products = await fetch('/api/products', {
  next: { tags: ['products'] },
})

// 상품이 바뀌면 태그로 한 번에 무효화합니다
revalidateTag('products')

세 가지는 배타적이지 않습니다. 실무에서는 섞어 씁니다. TTL을 안전망으로 깔고, 이벤트로 즉시 무효화합니다. 이벤트가 유실되어도 TTL이 지나면 결국 갱신되기 때문입니다.

전략정확성복잡도적합한 곳
TTL낮음낮음통계, 목록, 낡아도 되는 것
이벤트높음높음가격, 재고, 즉시 반영이 필요한 것
태그높음중간연관된 여러 캐시를 함께 버릴 때

stale-while-revalidate

TTL이 지났을 때 선택지는 두 가지입니다. 새 데이터가 올 때까지 기다리거나, 낡은 데이터를 먼저 주고 뒤에서 갱신하거나 합니다.

후자가 stale-while-revalidate입니다.

Cache-Control: max-age=60, stale-while-revalidate=300

이 헤더는 60초 동안은 신선하다는 뜻입니다. 60초에서 360초 사이에는 낡은 것을 즉시 주면서 동시에 뒤에서 새로 받아옵니다. 360초가 지나면 그때는 기다립니다.

TanStack Query와 SWR의 기본 동작이 바로 stale-while-revalidate입니다. 캐시에 있으면 즉시 보여주고, 백그라운드에서 갱신하고, 새 데이터가 오면 바꿔치기합니다.

사용자 입장에서는 화면이 즉시 나오고, 잠시 뒤 값이 조용히 바뀝니다. 이 “조용히 바뀜”을 어떻게 다룰지가 뒤에서 다룰 디자인 문제입니다.

세 가지 무효화 전략의 비교 다이어그램. 세로로 세 개의 타임라인이 배치되어 있습니다. 첫 번째는 TTL 방식으로, 일정 간격마다 캐시가 만료되고 새로 채워지는 주기가 표시되며 원본이 바뀐 시점과 캐시가 갱신되는 시점 사이에 지연 구간이 강조됩니다. 두 번째는 이벤트 기반으로, 원본이 바뀌는 즉시 무효화 신호가 발생해 캐시가 갱신되는 모습이 그려지며 지연 구간이 거의 없습니다. 다만 쓰기 경로가 캐시를 알아야 한다는 비용이 표시됩니다. 세 번째는 stale-while-revalidate로, TTL이 지난 뒤에도 낡은 값을 즉시 반환하면서 백그라운드에서 갱신이 진행되는 이중 흐름이 그려지고 사용자는 기다리지 않는다는 점이 표시됩니다. 우측에는 각 전략의 정확성과 복잡도가 표로 정리되어 있습니다.

캐시 스탬피드

인기 있는 캐시 항목이 만료되는 순간, 그 값을 기다리던 요청 수천 개가 동시에 원본으로 몰립니다. 캐시 스탬피드(cache stampede) 또는 thundering herd라고 부릅니다.

ep.05 네트워크 경계에서 본 재시도 폭풍과 구조가 같습니다. 그리고 해결책도 비슷합니다.

// 잠금 — 첫 요청만 원본으로 가고 나머지는 기다립니다
const inflight = new Map<string, Promise<Product[]>>()

async function getProducts(key: string): Promise<Product[]> {
  const cached = await cache.get(key)
  if (cached) return cached

  // 이미 누군가 가져오는 중이면 그 약속을 공유합니다
  const existing = inflight.get(key)
  if (existing) return existing

  const promise = fetchFromDb(key).then(async (data) => {
    await cache.set(key, data, { ttl: 60 })
    inflight.delete(key)
    return data
  })

  inflight.set(key, promise)
  return promise
}

TTL에 지터를 섞는 것도 방법입니다. 모든 항목이 정확히 같은 순간 만료되지 않게 합니다.

// 60초 ± 10% — 만료 시점이 흩어집니다
const ttl = 60 + Math.floor(Math.random() * 12) - 6

안티패턴

무효화 없는 캐시: TTL만 걸어 두고 이벤트 무효화를 붙이지 않으면, 관리자가 상품을 수정하고도 “왜 안 바뀌죠”라는 문의를 받습니다.

개인 데이터를 공유 캐시에: Cache-Control: public이 붙은 응답에 사용자 이름이 들어 있으면, CDN이 그것을 다른 사용자에게 줄 수 있습니다. 개인화된 응답은 private 또는 no-store입니다. 이 실수는 ep.10 신뢰 경계의 사고로 이어집니다.

캐시를 진실의 원본으로: 캐시에만 있고 원본에는 없는 데이터가 생기면, 캐시를 비우는 순간 데이터가 사라집니다. 캐시는 언제 없어지든 간에 시스템이 동작해야 합니다.

너무 많은 층: 다섯 층을 다 쓸 필요는 없습니다. 층이 늘어날 때마다 무효화 경로가 늘어납니다. 한 층을 잘 쓰는 것이 다섯 층을 대충 쓰는 것보다 낫습니다.


이 경계가 화면에 드러나는 자리

캐시는 사용자에게 보이지 않아야 한다고들 하지만, 낡았다는 사실은 종종 보여야 합니다.

낡음을 드러내는 세 가지 방법

첫째, 시점을 명시합니다.

마지막 갱신 5분 전    [새로고침]

주식 시세, 대시보드, 모니터링 화면처럼 숫자의 신선도가 의사결정에 영향을 주는 곳에서는 필수입니다. 사용자가 이 데이터가 언제 기준인지 알아야 합니다.

둘째, 갱신 중임을 표시합니다.

stale-while-revalidate는 화면에 낡은 값이 있는 동안 뒤에서 새 값을 받습니다. 이 구간을 표시할지는 판단입니다.

  • 값이 자주 바뀌지 않는다면 → 표시하지 않습니다. 조용히 바꿔치기해도 됩니다.
  • 값이 크게 바뀔 수 있다면 → 표시합니다. 갑자기 숫자가 바뀌면 사용자가 놀랍니다.
// 갱신 중임을 은근히 표시
<div className={isFetching ? 'opacity-60' : ''}>
  <Price value={data.price} />
</div>

투명도를 살짝 낮추거나 미세한 펄스를 주는 정도면 충분합니다. 스켈레톤으로 되돌리면 안 됩니다. 이미 보여준 데이터를 지우고 다시 로딩 화면을 보여주는 것은 역행입니다.

셋째, 낙관적 값과 확정 값을 구분합니다.

ep.05 네트워크 경계에서 본 낙관적 업데이트는 캐시에 아직 서버가 모르는 값을 넣는 일입니다. 이 값이 확정될 때까지 시각적으로 다르게 보일지가 선택입니다.

메시지 앱이 이 패턴의 교과서입니다. 보내는 중인 메시지는 회색이거나 시계 아이콘이 붙고, 전송되면 체크가 붙습니다. 사용자는 언제나 자기 메시지가 도착했는지 알 수 있습니다.

낡은 데이터를 드러내는 세 가지 UI 패턴. 첫 번째 패턴은 갱신 시점 명시로, 대시보드 카드 상단에 마지막 갱신 5분 전이라는 텍스트와 새로고침 버튼이 배치된 모습입니다. 숫자의 신선도가 의사결정에 영향을 주는 곳에 필요하다는 설명이 붙습니다. 두 번째 패턴은 갱신 중 표시로, 가격 정보가 투명도가 낮아진 상태로 표시되며 그 옆에 미세한 진행 표시가 있는 모습입니다. 스켈레톤으로 되돌리지 않고 기존 값을 유지한다는 점이 강조되며, 잘못된 예로 화면을 비우고 스켈레톤을 다시 보여주는 경우가 대비되어 있습니다. 세 번째 패턴은 낙관적 값과 확정 값의 구분으로, 메시지 목록에서 전송 중인 메시지는 회색과 시계 아이콘으로, 전송 완료된 메시지는 일반 색과 체크 아이콘으로 표시된 모습입니다. 사용자가 언제나 도착 여부를 알 수 있다는 점이 설명됩니다.

새로고침 어포던스

캐시가 있는 화면에는 사용자가 강제로 갱신할 방법이 있어야 합니다.

모바일의 당겨서 새로고침이 이 역할을 합니다. 데스크톱에서는 명시적인 새로고침 버튼이 필요합니다. 브라우저 새로고침으로 대신하면 되지 않느냐는 반문이 있지만, 브라우저 새로고침은 페이지 전체를 다시 그립니다. 사용자가 원한 것은 이 카드의 숫자만 다시 받는 것입니다.

단, 새로고침 버튼이 실제로 캐시를 우회해야 합니다. 버튼을 눌렀는데 같은 캐시를 다시 읽어 같은 값이 나오면, 그 버튼은 없느니만 못합니다.

// 사용자가 명시적으로 요청한 갱신은 캐시를 무시해야 합니다
function handleRefresh() {
  queryClient.invalidateQueries({ queryKey: ['dashboard'] })
}

캐시가 만드는 착시

개발 환경에서는 캐시 문제가 잘 보이지 않습니다.

혼자 쓰는 로컬 환경에서는 내가 데이터를 바꾸고 내가 조회합니다. 캐시가 어긋나는 순간이 잘 생기지 않습니다. 여러 사용자가 동시에 쓰는 프로덕션에서 비로소 드러납니다.

  • 관리자가 가격을 바꿨는데 사용자에게는 옛 가격이 보입니다.
  • 사용자 A가 댓글을 지웠는데 사용자 B에게는 아직 보입니다.
  • 재고가 0이 되었는데 다른 탭에서는 구매 가능으로 보입니다.

이런 상황을 설계 단계에서 시나리오로 적어 두어야 합니다. “이 값이 최대 몇 초까지 낡아도 괜찮은가”를 데이터마다 정해 두면, 그것이 곧 TTL이 되고 UI 문구가 됩니다.


참고 자료


다음 편 예고

ep.09 - 일관성 경계와 Saga 패턴

사본이 여러 개일 때 어느 것이 맞는지를 다뤘습니다. 다음은 더 어려운 질문입니다. 결제는 성공하고 주문 생성은 실패했다면, 그 순간 시스템은 어떤 상태입니까.