2-2. HTTP 캐싱 — 서버에 닿지 않는 응답
한 줄 요약: 신선도와 재검증의 2단 모델 위에서 캐시 지시어를 정확히 읽고, 자산 유형별 캐싱 정책을 설계하며, "왜 옛 버전이 보이는가"를 계층별로 진단할 수 있다.
학습 목표
- 신선도(freshness)와 재검증(revalidation)이 각각 무엇을 절약하는지 구분하고, 응답이 둘 중 어느 경로로 처리될지 헤더만 보고 판단할 수 있다.
no-cache/no-store/must-revalidate의 실제 의미를 스펙 기준으로 설명하고, 흔한 오용을 교정할 수 있다.- 공유 캐시와 사설 캐시의 차이,
Vary가 캐시 키를 확장하는 방식과 캐시를 무력화하는 조건을 설명할 수 있다. - 콘텐츠 해시 +
immutable기반의 불변 자산 패턴을 설계하고, 왜 HTML 진입점에는 이 패턴이 불가능한지 설명할 수 있다. - DevTools에서 메모리 캐시 / 디스크 캐시 / 304 재검증의 세 경로를 구분해 관찰할 수 있다.
배경: 왜 이것이 존재하는가
HTTP 캐싱(RFC 9111)의 목적은 단순하다 — 이미 받은 응답을 다시 받지 않는 것. 하지만 백엔드에서 다루던 캐시와는 제어 모델이 근본적으로 다르다.
Redis나 로컬 메모리 캐시는 내 캐시다. 내가 넣고, 내가 TTL을 정하고, 문제가 생기면 내가 지운다. HTTP 캐시는 정반대다. 실제 저장소는 사용자의 브라우저, 회사 프록시, 통신사 캐시, CDN 엣지 — 전부 남의 인프라다. 서버가 할 수 있는 것은 응답에 헤더로 정책을 선언하는 것뿐이고, 일단 응답이 나가면 그 정책을 회수할 방법이 없다. Cache-Control: max-age=86400을 붙여 내보낸 응답은 앞으로 24시간 동안 전 세계 어딘가의 캐시에서 살아 있고, 그 사이 서버에서 무엇을 고치든 그 사용자에게는 도달하지 않는다. 비가역성이 설계를 지배한다 — 이것이 HTTP 캐싱 설계의 제1 감각이고, 백엔드 캐시 경험이 오히려 방해가 되는 지점이다.
프론트엔드에서 이것이 중요한 이유는 명확하다. 웹 페이지 하나가 수십 개의 정적 자산(JS, CSS, 이미지, 폰트)을 로드하는데, 재방문 시 이들이 캐시에서 오는가 네트워크에서 오는가가 로딩 성능의 절반을 결정한다. 그리고 배포 직후 "새 버전이 안 보여요"라는 문의의 대부분이 이 계층에서 발생한다.
용어 하나만 먼저: 이 문서에서 사설 캐시(private cache)는 한 사용자 전용 캐시(브라우저), 공유 캐시(shared cache)는 여러 사용자가 공유하는 캐시(CDN, 프록시)를 가리킨다.
핵심 개념
2단 모델 — 신선도와 재검증
캐시된 응답의 생애는 두 단계로 나뉜다. 이 구분이 이 문서의 뼈대다.
응답 저장 시점
│
│ ① 신선(fresh) 구간: 서버에 묻지 않고 그대로 재사용
│ → 절약: RTT 전체. 요청 자체가 네트워크에 나가지 않는다
│
신선도 수명(freshness lifetime) 만료
│
│ ② 진부(stale) 구간: 조건부 요청으로 서버에 "아직 유효한가?" 질의
│ → 304면 바디 없이 헤더만 받고 저장본 재사용
│ → 절약: 대역폭(바디 전송). RTT는 지불한다
▼두 단계가 절약하는 비용이 다르다는 점이 핵심이다. 신선도는 지연 자체를 없애고(요청 0회), 재검증은 전송량만 줄인다(요청 1회, 바디 0바이트). 큰 JS 번들이라면 304도 이득이 크지만, 작은 API 응답이라면 304와 200의 체감 차이는 거의 없다 — 비용의 대부분이 바디가 아니라 왕복(RTT)이기 때문이다. 그래서 캐싱 정책 설계란 결국 "이 리소스를 어느 구간에 얼마나 머물게 할 것인가"의 결정이다.
신선도 계산 — 캐시 헤더를 안 줬는데 왜 캐시되는가
응답이 신선한지는 신선도 수명 > 현재 나이로 판정한다.
- 신선도 수명:
Cache-Control: max-age=N(초)이 있으면 그 값. 없고Expires(절대 시각, HTTP/1.0 유산)가 있으면Expires - Date. 둘 다 없으면 — 여기가 함정인데 — 휴리스틱 캐싱(heuristic caching)이 발동한다. - 현재 나이: 응답의
Date로부터 흐른 시간에, 중간 캐시에 머문 시간(Age헤더)을 더한 값. CDN을 거친 응답에서Age: 3025같은 헤더를 봤다면, 이 응답이 CDN에 50분 넘게 저장되어 있었다는 뜻이다.
휴리스틱 캐싱은 "캐시 헤더를 아무것도 안 줬으니 캐시가 안 되겠지"라는 직관을 배반하는 지점이다. RFC 9111은 명시적 지시가 없는 캐시 가능 응답에 대해 캐시가 수명을 추정하는 것을 허용하며, 관례적 알고리즘(스펙에 예시로 명시)은 (Date - Last-Modified) × 10%다. 3년 전에 마지막으로 수정된 파일이라면 약 110일간 신선한 것으로 취급될 수 있다. "아무것도 안 하기"는 "캐시하지 않기"가 아니라 "캐시 동작을 브라우저 재량에 맡기기"다. 캐시하면 안 되는 응답에는 반드시 명시적으로 no-store를 선언해야 한다.
# 응답의 신선도 관련 헤더만 추려서 본다
curl -sI https://developer.mozilla.org/ | grep -i '^\(cache-control\|age\|date\|expires\|last-modified\|etag\)'
# cache-control: public, max-age=3600 ← 1시간 신선
# age: 1742 ← CDN에 29분째 저장됨 → 남은 신선 시간 약 31분
# date: ...
# etag: "..." ← 만료 후 재검증에 쓸 검증자재검증 — 304는 "바디를 생략해도 된다"는 신호
신선도가 만료된 응답은 버려지는 것이 아니라 조건부 요청(conditional request)의 재료가 된다. 저장해 둔 응답의 검증자(validator)를 조건 헤더에 실어 보내면, 서버는 리소스가 그대로일 때 304 Not Modified — 바디 없는 응답 — 로 답한다.
| 검증자 (응답 헤더) | 조건 헤더 (재검증 요청) | 비교 방식 |
|---|---|---|
ETag: "abc123" | If-None-Match: "abc123" | 불투명 토큰의 일치 여부. 서버가 무엇으로 만들든 자유(콘텐츠 해시, 버전 번호 등) |
Last-Modified: <날짜> | If-Modified-Since: <날짜> | 수정 시각 비교. 정밀도가 1초라 빠른 연속 수정을 놓칠 수 있다 |
둘 다 있으면 클라이언트는 둘 다 보내고, 서버는 If-None-Match를 우선해야 한다(스펙). ETag에는 강/약 구분이 있다 — W/"abc"(약한 검증자)는 "의미상 같음"(예: 압축 방식만 다름)을, 따옴표만 있는 강한 검증자는 바이트 단위 동일을 뜻한다. nginx는 gzip을 적용할 때 ETag를 약한 것으로 바꾸는데, 바이트가 달라졌기 때문이다.
# 304를 직접 재현한다. 1) 먼저 ETag를 받아 온다
curl -sI https://www.rfc-editor.org/rfc/rfc9111.html | grep -i etag
# etag: "2c39a-5f9f7c8b12345"
# 2) 그 값을 조건 헤더로 되돌려 보낸다
curl -sI -H 'If-None-Match: "2c39a-5f9f7c8b12345"' https://www.rfc-editor.org/rfc/rfc9111.html | head -1
# HTTP/2 304 ← 바디 없음. "네가 가진 게 최신이다"브라우저에서의 관찰: DevTools Network 패널에서 Status 열이 304인 행은 재검증 왕복이 실제로 일어난 것이고, Size 열이 (disk cache)/(memory cache)인 행은 신선 구간이라 요청 자체가 나가지 않은 것이다(목록에 보이지만 네트워크에는 없다). 이 세 가지를 구분하는 것이 캐시 디버깅의 시작이다. 주의: Network 패널의 "Disable cache" 체크는 HTTP 캐시만 끈다 — 서비스 워커나 BFCache는 별개다(Phase 0-1에서 본 계층 구분).
지시어 정밀 해부 — no-cache는 캐시 금지가 아니다
Cache-Control 지시어는 이름이 직관을 배반하는 것들이 있어 정확한 의미를 못 박아 둘 필요가 있다.
| 지시어 | 실제 의미 | 흔한 오해 |
|---|---|---|
no-cache | 저장은 하되, 매번 재검증 없이는 재사용 금지. 신선 구간을 0으로 만드는 것과 유사 | "캐시하지 마라"로 오독. 저장 자체는 허용된다 |
no-store | 어떤 캐시에도 저장 금지. 진짜 "캐시하지 마라" | — |
must-revalidate | 신선한 동안은 자유롭게 재사용. 만료 후에는 재검증이 실패하면(서버 접근 불가) 진부한 응답을 쓰지 말고 에러를 내라 | "매번 재검증하라"로 오독. 신선 구간에는 아무 영향이 없다 |
max-age=0 | 즉시 만료. no-cache와 실질적으로 유사하나, 재검증 강제력은 no-cache 쪽이 명확 | — |
immutable | 신선한 동안에는 사용자가 새로고침해도 재검증하지 마라 (RFC 8246) | — |
must-revalidate의 존재 이유가 흥미롭다. 캐시는 원래 서버에 연결할 수 없으면 진부한 응답이라도 내놓는 것이 허용된다(안 보여주는 것보다 낫다는 판단). must-revalidate는 그 관용을 금지하는 지시어다 — 결제 상태처럼 낡은 정보가 무정보보다 해로운 경우를 위한 것이다.
민감한 개인 정보 응답에 no-cache만 붙이는 것은 흔하고 위험한 실수다. 저장은 되므로 공용 PC의 디스크에 남을 수 있다. 의도가 "남기지 마라"면 no-store다.
공유 캐시와 사설 캐시 — 같은 응답, 다른 규칙
브라우저 캐시는 한 사람의 것이지만 CDN·프록시는 수만 명이 공유한다. 개인화된 응답이 공유 캐시에 저장되는 순간 다른 사용자의 데이터가 노출되므로, 스펙은 두 캐시를 구분하는 지시어를 둔다.
private— 사설 캐시(브라우저)만 저장 가능. 공유 캐시는 저장 금지. 개인화된 응답의 기본값이어야 한다.public— 공유 캐시 저장을 명시적으로 허용. 특히Authorization헤더가 붙은 요청의 응답은 기본적으로 공유 캐시 저장이 금지되는데,public(또는s-maxage,must-revalidate)이 그 금지를 해제한다. 반대로 말하면, 인증 API 응답에 무심코public을 붙이면 한 사용자의 응답이 CDN을 통해 모두에게 서빙될 수 있다.s-maxage=N— 공유 캐시 전용 수명.max-age보다 우선한다. "브라우저는 60초, CDN은 1시간" 같은 이원 정책(max-age=60, s-maxage=3600)이 가능해진다 — CDN은 배포 시 API로 퍼지(purge)할 수 있으니 길게, 브라우저는 퍼지가 불가능하니 짧게 주는 것이 전형적 패턴이다.
Vary — 캐시 키의 확장과 붕괴
2-1에서 남긴 문제: 같은 URL이 콘텐츠 협상으로 여러 표현을 가질 때, 캐시는 무엇을 기준으로 저장본을 재사용하는가? 기본 캐시 키는 메서드 + URL이다. Vary 헤더는 여기에 요청 헤더를 추가한다.
Vary: Accept-Encoding
→ 캐시 키 = URL + 요청의 Accept-Encoding 값
→ gzip 본과 br 본이 별도 항목으로 저장되고, 요청의 선호에 맞는 쪽이 재사용된다Vary는 정확성을 위한 장치지만, 잘못 쓰면 캐시를 조용히 무력화한다. 캐시 키에 들어간 헤더의 값의 가짓수만큼 캐시가 쪼개지기 때문이다.
Vary: Accept-Encoding— 값이 사실상 몇 가지뿐. 안전하고 표준적.Vary: Accept-Language— 언어 수만큼 분화. 의도적이라면 유효.Vary: User-Agent— UA 문자열은 브라우저 버전·OS 조합마다 다르다. 사실상 사용자마다 다른 키가 되어 공유 캐시 적중률이 0에 수렴한다.Vary: Cookie— 쿠키가 사용자마다 다르므로 마찬가지. 개인화 응답이라면 차라리private이 정직하다.Vary: *— "요청 헤더 밖의 무언가에 따라 달라진다"는 선언으로, 스펙상 어떤 저장 응답도 재검증 없이 재사용할 수 없게 만든다.
경계 조건의 전형: "모바일이면 다른 HTML을 주자"며 서버에서 UA 스니핑을 하고 Vary: User-Agent를 붙이는 순간, CDN 캐시는 사실상 꺼진다. 반응형 CSS(Phase 1-5)가 이 문제의 클라이언트 측 해법이었던 셈이다.
불변 자산 패턴 — 캐시를 영구화하는 유일한 방법
캐싱의 근본 딜레마는 "오래 캐시하고 싶다 ↔ 언제든 갱신하고 싶다"의 충돌이다. 이 충돌을 정면 돌파하는 것이 현대 프론트엔드 배포의 표준 패턴이다: 내용이 바뀌면 URL을 바꾼다.
app.a3f8c2.js ← 파일 내용의 해시가 파일명에 포함된다
└─ Cache-Control: public, max-age=31536000, immutableURL과 내용이 1:1로 묶이면 "이 URL의 내용이 갱신될 가능성"이 원천적으로 사라지므로, 최대치(1년)로 캐시해도 안전하다. immutable은 여기에 하나를 더 얹는다 — 사용자가 새로고침을 눌러도 재검증 요청(304 왕복)조차 보내지 말라는 지시다. 수십 개 자산의 재검증 왕복이 전부 사라진다. 갱신은 무효화가 아니라 새 URL로의 교체로 이루어진다: 다음 배포에서 app.b7d901.js가 나오고, 옛 파일은 그냥 잊힌다. 해시 파일명을 만들고 참조를 갱신하는 일은 번들러가 자동화하는데, 그 내부는 Phase 6-2에서 다룬다.
그런데 이 패턴에는 논리적 구멍이 하나 있다. app.b7d901.js라는 새 이름을 사용자가 어떻게 알게 되는가? 그 이름은 HTML(<script src="/app.b7d901.js">)에 적혀 있고, HTML의 URL은 /나 /products/42처럼 바뀔 수 없다 — 진입점의 URL이 배포마다 바뀌면 그것은 웹사이트가 아니다. 따라서:
- HTML은 영구 캐시가 불가능하다. 신선도를 0 또는 매우 짧게 주고(
no-cache또는max-age=0, must-revalidate), ETag로 재검증시키는 것이 정석이다. HTML은 크기가 작아 304 왕복의 비용이 감내할 만하다. - 전체 구조는 2단 위임이 된다: 재검증되는 가벼운 진입점(HTML)이, 영구 캐시되는 무거운 자산(해시 파일명)을 가리킨다. 캐싱 관점에서 SPA 배포란 이 위임 구조의 유지 관리다.
stale-while-revalidate — 진부함을 감수하고 지연을 없애기
재검증에는 여전히 RTT 1회가 든다. RFC 5861의 확장 지시어 stale-while-revalidate=N은 절충안을 제공한다: 신선도가 만료됐어도 N초 동안은 일단 진부한 응답을 즉시 내주고, 뒤에서 비동기로 재검증해 다음 요청을 위해 갱신해 둔다.
Cache-Control: max-age=60, stale-while-revalidate=300
→ 0~60초: 신선, 즉시 응답
→ 60~360초: 진부한 응답을 즉시 주고 + 백그라운드 재검증
→ 이후: 일반 재검증(동기)사용자는 항상 캐시 속도로 응답받고, 데이터는 "최대 한 세대 뒤처짐"을 감수한다. 이 트레이드오프 구조 — 지연을 없애는 대가로 일시적 진부함을 허용 — 는 HTTP 캐시 밖에서도 반복된다. TanStack Query와 SWR 라이브러리(이름 자체가 이 지시어에서 왔다)가 애플리케이션 계층에서 같은 전략을 구현한 것인데, Phase 5-8에서 다룬다. 브라우저 지원은 Chrome/Firefox가 구현했고 Safari는 미구현이므로(2026년 기준), 주 활용처는 브라우저보다 CDN 계층이다.
실무 관점
자산 유형별 표준 정책
| 자산 | 권장 정책 | 근거와 경계 조건 |
|---|---|---|
| 해시 파일명 JS/CSS/폰트/이미지 | public, max-age=31536000, immutable | URL=내용이 보장될 때만. 해시 없이 이 정책을 주면 배포가 사용자에게 도달하지 않는 사고가 된다 |
| HTML (진입점) | no-cache (또는 max-age=0, must-revalidate) + ETag | 진입점은 URL을 바꿀 수 없으므로 매번 재검증. CDN에는 s-maxage + 배포 시 퍼지 조합 가능 |
| 개인화 API 응답 | private, no-cache 또는 no-store | 공유 캐시 유입 차단이 최우선. 디스크에도 남기지 않으려면 no-store |
| 공용 API 응답 (예: 상품 목록) | public, max-age=짧게, stale-while-revalidate | 진부함 허용 범위를 데이터 성격이 결정한다 |
| 사용자 업로드 파일 (URL 불변) | public, max-age=길게 | 업로드 후 내용 수정이 없다는 전제가 무너지면(덮어쓰기 허용) 정책도 무너진다 |
캐시 사고의 전형 — 배포가 도달하지 않는다
가장 흔한 사고는 HTML에 긴 max-age를 준 것이다. 예컨대 서버 기본 설정이 모든 응답에 max-age=86400을 붙이고 있었다면, 배포 후 24시간 동안 사용자들은 옛 HTML을 보고, 옛 HTML은 옛 해시의 JS를 가리키므로 화면 전체가 옛 버전이다. 더 나쁜 변형: 배포 시스템이 옛 해시 파일을 서버에서 삭제하는 경우, 캐시된 옛 HTML이 이미 삭제된 app.a3f8c2.js를 요청해 404 — 일부 사용자에게만 하얀 화면이 뜬다. "일부 사용자만, 새로고침하면 낫기도 하는" 재현 불가 장애의 고전적 원인이다. 교훈은 두 가지: 진입점의 신선도는 0에 두고, 옛 해시 자산은 최소 한 세대 이상 함께 서빙한다(배포의 아토믹성 문제 — CDN 계층 운용은 Phase 6-5).
이때 서버에서 헤더를 고쳐도 이미 나간 응답은 회수되지 않는다는 것이 비가역성의 실감이다. 할 수 있는 일은 사용자의 캐시가 자연 만료되기를 기다리는 것뿐이다. 그래서 캐시 정책은 "문제 생기면 고치지"가 통하지 않는, 앞에서 설계해야 하는 몇 안 되는 영역이다.
진단 절차 — "옛 버전이 보여요"
- DevTools Network에서 해당 리소스의 Size 열 확인:
(memory cache)/(disk cache)면 신선 구간에서 재사용된 것 — 응답 헤더의max-age와Date/Age로 언제까지 신선한지 계산한다. - Status가 304면 서버가 "그대로"라고 답한 것 — 서버 쪽 검증자(ETag)가 정말 갱신됐는지
curl -sI로 확인한다. - HTTP 캐시가 아니면 다른 계층을 의심한다: 서비스 워커(Application 패널), CDN(응답의
Age,x-cache: HIT류 헤더), BFCache. Age헤더가 크면 CDN 캐시가 원인 — 브라우저가 아니라 CDN 퍼지가 필요한 상황이다.
브라우저 캐시는 이제 사이트별로 분리된다
한때 "jQuery를 공용 CDN에서 로드하면 다른 사이트에서 받아 둔 캐시에 적중한다"는 최적화 통념이 있었다. 이것은 더 이상 사실이 아니다. 추적 방지를 위해(캐시 적중 여부로 방문 이력을 추측하는 공격 차단) 모든 주요 브라우저가 HTTP 캐시를 최상위 사이트 기준으로 파티셔닝한다 — Chrome은 2020년부터. A 사이트에서 캐시한 cdn.example/jquery.js는 B 사이트에서 캐시 미스다. 따라서 공용 CDN의 캐시 공유 이점은 소멸했고, 자산은 자기 오리진에서 서빙하는 것이 연결 비용 면에서도 유리하다(2-4의 연결 관리와 연결되는 지점).
더 깊이
요청 쪽에도 Cache-Control이 있다
Cache-Control은 응답 전용이 아니다. 클라이언트도 요청에 지시어를 실을 수 있다: max-age=0(신선해도 재검증 원함), no-cache(재검증 강제), only-if-cached(네트워크에 나가지 말 것) 등. 브라우저 새로고침이 실제로 이것을 쓴다 — 일반 새로고침은 요청에 max-age=0을, 강력 새로고침(Cmd+Shift+R)은 no-cache를 싣고 조건 헤더 없이 보낸다. immutable이 무력화하는 것이 바로 일반 새로고침의 max-age=0 재검증이다. DevTools Network에서 새로고침 방식별로 요청 헤더를 비교해 보면 확인할 수 있다.
휴리스틱의 스펙 근거
RFC 9111 §4.2.2는 휴리스틱 수명 사용을 허용하되 상한과 알고리즘을 정의하지 않는다. 다만 "Last-Modified 이후 경과 시간의 일부(예: 10%)"를 관례로 언급하며, 실제로 Chrome·Firefox가 이 10% 규칙을 쓴다. 즉 휴리스틱 캐싱의 구체 동작은 표준이 보장하는 동작이 아니라 구현 세부다 — 여기 의존한 코드를 짜면 안 되고, 캐시 여부가 중요한 응답에는 반드시 명시적 지시를 줘야 한다는 결론이 스펙 구조 자체에서 나온다.
진부 응답의 관용과 stale-if-error
RFC 5861에는 stale-while-revalidate 외에 stale-if-error=N도 있다 — 재검증했더니 서버가 5xx이거나 연결이 안 될 때, N초까지는 진부한 응답으로 대신하라는 지시다. 오리진 장애 시 CDN이 "낡은 페이지라도 보여주는" 가용성 패턴의 표준적 표현이다. must-revalidate와 정확히 반대 방향의 선언이라는 점이 흥미롭다 — 스펙은 "진부함과 무응답 중 무엇이 덜 해로운가"라는 판단을 서버가 리소스별로 내릴 수 있게 양방향 지시어를 모두 마련해 둔 것이다.
정리
- HTTP 캐싱은 제어권 없는 원격 캐시에 헤더로 정책을 선언하는 모델이고, 나간 응답은 회수할 수 없다. 이 비가역성 때문에 캐시 정책은 사후 대응이 아니라 사전 설계의 영역이다.
- 캐시된 응답은 신선 구간(요청 생략, RTT 절약)과 진부 구간(조건부 요청 → 304, 대역폭 절약)의 2단계를 거친다. 명시적 지시가 없으면 휴리스틱 캐싱이 브라우저 재량으로 동작한다.
no-cache는 저장 허용 + 매번 재검증,no-store만이 저장 금지다. 개인화 응답에는private, 공유 캐시 전용 수명은s-maxage로 분리한다.Vary는 캐시 키를 요청 헤더로 확장하는 정확성 장치지만, 가짓수 많은 헤더(User-Agent, Cookie)를 넣으면 공유 캐시가 조용히 무력화된다.- 현대 배포의 표준은 2단 위임이다: 재검증되는 가벼운 HTML 진입점이, 콘텐츠 해시 파일명 +
immutable로 영구 캐시되는 자산을 가리킨다. 갱신은 무효화가 아니라 새 URL로의 교체다.
확인 문제
1. 어떤 API 응답에 캐시 관련 헤더가 전혀 없는데(Cache-Control도 Expires도 없음), Last-Modified: 100일 전만 있다. 이 응답을 받은 브라우저는 10일 뒤 같은 URL을 요청할 때 어떻게 동작할 가능성이 높은가? 이 API가 실시간성이 중요한 데이터라면 서버는 무엇을 해야 하는가?
정답과 해설
명시적 신선도 지시가 없으므로 휴리스틱 캐싱이 발동한다. 관례적 알고리즘은 (Date - Last-Modified) × 10% = 100일 × 10% = 약 10일의 신선도 수명이므로, 10일 뒤 시점이면 신선 만료 직후 — 그 전까지는 요청이 네트워크에 나가지도 않고 캐시에서 응답됐을 가능성이 높다. 실시간성이 중요하다면 서버가 명시적으로 선언해야 한다: 매번 재검증시키려면 Cache-Control: no-cache(+ ETag), 저장 자체를 막으려면 no-store. "헤더를 안 주면 캐시가 안 된다"는 가정이 정확히 뒤집히는 사례이며, 휴리스틱의 구체 동작(10%)은 표준 보장이 아니라 구현 관례라는 점도 함께 기억할 것.
2. SPA를 배포했더니 일부 사용자에게서 하얀 화면과 함께 app.a3f8c2.js 404 에러가 보고됐다. 새 배포의 번들은 app.b7d901.js이고, 배포 스크립트는 서버의 옛 파일을 모두 삭제한 뒤 새 파일을 올린다. HTML의 응답 헤더는 Cache-Control: max-age=600이다. 사고의 전체 인과를 재구성하고, 두 가지 수정을 제안하라.
정답과 해설
인과: ① HTML이 10분간 신선하므로, 배포 직전에 방문한 사용자의 브라우저는 배포 후에도 최대 10분간 옛 HTML을 캐시에서 사용한다. ② 옛 HTML은 app.a3f8c2.js를 참조한다. ③ 그 파일이 사용자 캐시에 있었다면 문제없이 동작했겠지만, 캐시에 없던 사용자(첫 방문, 캐시 축출)는 서버에 요청한다. ④ 배포 스크립트가 옛 파일을 삭제했으므로 404 — 스크립트 로드 실패로 하얀 화면. "일부 사용자만" 겪는 이유는 HTML 캐시 보유 여부 + JS 캐시 보유 여부의 조합에 따라 갈리기 때문이다. 수정: (1) HTML의 신선도를 0으로 — no-cache + ETag 재검증 구조로 바꿔 배포가 즉시 도달하게 한다. (2) 옛 해시 자산을 즉시 삭제하지 않고 최소 한 세대(또는 일정 기간) 함께 서빙한다. 두 수정은 상호 보완이며 둘 다 필요하다 — (1)만으로는 배포 순간에 이미 열려 있던 탭이, (2)만으로는 배포 도달 지연이 남는다.
3. 다국어 서비스가 서버에서 Accept-Language를 읽어 같은 URL로 한국어/영어 HTML을 내보내며 Vary: Accept-Language를 정확히 선언했다. 그런데 CDN을 붙인 뒤 캐시 적중률이 기대보다 훨씬 낮다. 왜인가? 적중률을 높이는 설계 대안 두 가지는?
정답과 해설
Vary: Accept-Language는 캐시 키를 요청의 Accept-Language 값 문자열로 확장하는데, 이 헤더는 언어 수만큼이 아니라 브라우저·OS 설정 조합만큼 다양하다 — ko-KR,ko;q=0.9,en-US;q=0.8, ko,en;q=0.9, ko-KR 등은 서버 입장에서 전부 "한국어"지만 캐시에는 전부 다른 키다. 키가 잘게 쪼개져 같은 한국어 응답이 수십 개 항목으로 중복 저장되고 적중률이 떨어진다. 대안: (1) 언어를 URL로 승격 — /ko/..., /en/.... 캐시 키가 곧 URL이 되어 Vary가 필요 없어지고, 공유·북마크에도 명확하다(대부분의 대형 서비스가 이 방식인 이유). (2) CDN 엣지에서 Accept-Language를 정규화해(ko 또는 en 둘 중 하나로) 오리진에 전달하고 그 정규화 값으로 캐시 키를 구성한다 — CDN 계층의 키 제어 기능이 필요하며, 상세는 Phase 7-2의 영역이다.
참고 자료
- RFC 9111 — HTTP Caching — 이 문서의 1차 자료. 저장 규칙(§3), 신선도(§4.2), 재검증(§4.3)의 정확한 조건.
- RFC 5861 — Cache-Control Extensions for Stale Content —
stale-while-revalidate/stale-if-error의 원 정의. 두 페이지짜리 짧은 스펙. - RFC 8246 — HTTP Immutable Responses —
immutable이 새로고침 재검증을 없애는 근거. - MDN — HTTP caching — 휴리스틱 캐싱과 브라우저 동작을 포함한 실무형 개관.
- web.dev — Love your cache — 불변 자산 + 재검증 HTML의 2단 위임 패턴을 Chrome 팀 관점에서 정리.
- Chrome for Developers — Gaining security and privacy by partitioning the cache — 캐시 파티셔닝의 배경과 공용 CDN 통념이 폐기된 이유.