Skip to content

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를 선언해야 한다.

bash
# 응답의 신선도 관련 헤더만 추려서 본다
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를 약한 것으로 바꾸는데, 바이트가 달라졌기 때문이다.

bash
# 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, immutable

URL과 내용이 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, immutableURL=내용이 보장될 때만. 해시 없이 이 정책을 주면 배포가 사용자에게 도달하지 않는 사고가 된다
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).

이때 서버에서 헤더를 고쳐도 이미 나간 응답은 회수되지 않는다는 것이 비가역성의 실감이다. 할 수 있는 일은 사용자의 캐시가 자연 만료되기를 기다리는 것뿐이다. 그래서 캐시 정책은 "문제 생기면 고치지"가 통하지 않는, 앞에서 설계해야 하는 몇 안 되는 영역이다.

진단 절차 — "옛 버전이 보여요" ​

  1. DevTools Network에서 해당 리소스의 Size 열 확인: (memory cache)/(disk cache)면 신선 구간에서 재사용된 것 — 응답 헤더의 max-age와 Date/Age로 언제까지 신선한지 계산한다.
  2. Status가 304면 서버가 "그대로"라고 답한 것 — 서버 쪽 검증자(ETag)가 정말 갱신됐는지 curl -sI로 확인한다.
  3. HTTP 캐시가 아니면 다른 계층을 의심한다: 서비스 워커(Application 패널), CDN(응답의 Age, x-cache: HIT 류 헤더), BFCache.
  4. 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의 영역이다.

참고 자료 ​