캐시를 넣는 순간 같은 값이 두 개가 되고, 둘 중 하나는 반드시 낡는다.
이 경계는 무엇을 청구하는가
앞 편에서 값의 소유자를 정했습니다. 이제 그 값이 복제됩니다.
캐시는 대개 성능 이야기로 시작됩니다. “느리니까 앞에 두자”, “요청이 많으니까 앞에 두자”라는 이유입니다. 그런데 캐시를 넣는 결정은 사본을 만드는 결정입니다.
사본이 하나 생기면 “원본이 바뀌었을 때 이 사본은 어떻게 되는가”라는 질문이 하나 생깁니다. 사본이 다섯 개면 질문이 다섯 개입니다.
이 경계가 청구하는 것은 낡음입니다. 그리고 낡음은 두 가지 방식으로 청구됩니다.
- 사용자가 옛 데이터를 봅니다. 재고가 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만 캐시하게 하면, 우리가 퍼지할 수 있는 층에만 사본이 생깁니다.
무효화 전략 세 가지
캐시를 언제 버릴 것인가에 대한 답은 세 종류입니다.
시간 기반(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입니다. 캐시에 있으면 즉시 보여주고, 백그라운드에서 갱신하고, 새 데이터가 오면 바꿔치기합니다.
사용자 입장에서는 화면이 즉시 나오고, 잠시 뒤 값이 조용히 바뀝니다. 이 “조용히 바뀜”을 어떻게 다룰지가 뒤에서 다룰 디자인 문제입니다.
캐시 스탬피드
인기 있는 캐시 항목이 만료되는 순간, 그 값을 기다리던 요청 수천 개가 동시에 원본으로 몰립니다. 캐시 스탬피드(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 네트워크 경계에서 본 낙관적 업데이트는 캐시에 아직 서버가 모르는 값을 넣는 일입니다. 이 값이 확정될 때까지 시각적으로 다르게 보일지가 선택입니다.
메시지 앱이 이 패턴의 교과서입니다. 보내는 중인 메시지는 회색이거나 시계 아이콘이 붙고, 전송되면 체크가 붙습니다. 사용자는 언제나 자기 메시지가 도착했는지 알 수 있습니다.
새로고침 어포던스
캐시가 있는 화면에는 사용자가 강제로 갱신할 방법이 있어야 합니다.
모바일의 당겨서 새로고침이 이 역할을 합니다. 데스크톱에서는 명시적인 새로고침 버튼이 필요합니다. 브라우저 새로고침으로 대신하면 되지 않느냐는 반문이 있지만, 브라우저 새로고침은 페이지 전체를 다시 그립니다. 사용자가 원한 것은 이 카드의 숫자만 다시 받는 것입니다.
단, 새로고침 버튼이 실제로 캐시를 우회해야 합니다. 버튼을 눌렀는데 같은 캐시를 다시 읽어 같은 값이 나오면, 그 버튼은 없느니만 못합니다.
// 사용자가 명시적으로 요청한 갱신은 캐시를 무시해야 합니다
function handleRefresh() {
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
}
캐시가 만드는 착시
개발 환경에서는 캐시 문제가 잘 보이지 않습니다.
혼자 쓰는 로컬 환경에서는 내가 데이터를 바꾸고 내가 조회합니다. 캐시가 어긋나는 순간이 잘 생기지 않습니다. 여러 사용자가 동시에 쓰는 프로덕션에서 비로소 드러납니다.
- 관리자가 가격을 바꿨는데 사용자에게는 옛 가격이 보입니다.
- 사용자 A가 댓글을 지웠는데 사용자 B에게는 아직 보입니다.
- 재고가 0이 되었는데 다른 탭에서는 구매 가능으로 보입니다.
이런 상황을 설계 단계에서 시나리오로 적어 두어야 합니다. “이 값이 최대 몇 초까지 낡아도 괜찮은가”를 데이터마다 정해 두면, 그것이 곧 TTL이 되고 UI 문구가 됩니다.
참고 자료
- MDN. HTTP caching. https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching
- Grigorik, I. et al. RFC 5861: HTTP Cache-Control Extensions for Stale Content. https://datatracker.ietf.org/doc/html/rfc5861
- TanStack Query. Caching. https://tanstack.com/query/latest/docs/framework/react/guides/caching
- Vercel. Data Cache and Revalidation. https://nextjs.org/docs/app/building-your-application/caching
- Fowler, M. Two Hard Things. https://martinfowler.com/bliki/TwoHardThings.html
다음 편 예고
사본이 여러 개일 때 어느 것이 맞는지를 다뤘습니다. 다음은 더 어려운 질문입니다. 결제는 성공하고 주문 생성은 실패했다면, 그 순간 시스템은 어떤 상태입니까.