DevOwen
HTTP 캐싱 완벽 가이드
Cache-Control 의 디렉티브 이름은 *직관과 어긋나는* 함정이 많다. no-cache 는 캐시 안 하는 게 아니고, no-store 가 진짜 핵 옵션이다. 신선도 vs 검증의 두 축으로 설계해야 길을 잃지 않는다.
핵심 요약
(1) 신선도: Cache-Control: max-age (브라우저+공유), s-maxage (공유 전용 — max-age 를 덮어씀), Expires (절대 시각, max-age 있으면 무시), immutable (재검증 생략 — 핑거프린트된 자산만!), stale-while-revalidate/stale-if-error (오래됐어도 일단 제공). (2) 검증: ETag (강/약), Last-Modified 와 If-None-Match/If-Modified-Since 로 304 Not Modified 응답 — 본문 미전송. (3) 키: 기본은 (스킴, 호스트, 경로, 쿼리). Vary 로 Accept-Encoding 같은 헤더를 키에 추가 — 남용하면 파편화. (4) 함정: no-cache ≠ no store, no-store 는 핵 옵션 + BFCache 차단, Vary: * = 사실상 캐시 불가, Vary: Cookie = 모든 사용자가 별 항목. (5) 레시피: 핑거프린트 자산 → public, max-age=31536000, immutable. 자주 변하는 HTML → public, max-age=60, s-maxage=300, stale-while-revalidate=60, stale-if-error=600 + ETag. 인증 페이지 → private, no-cache + ETag. API → 짧은 s-maxage + stale-while-revalidate.
HTTP 캐싱을 '리소스마다 max-age 를 적당히 박는 일' 로 보지 말고 '(1) 신선도(freshness): 재검증 없이 제공할 수 있는 기간, (2) 검증(validation): 만료 후 변경 여부를 저렴하게 확인하는 메커니즘, (3) 키(key)와 변형(Vary): 같은 URL 의 다른 버전 분리, 이 세 축의 설계 문제' 로 본다. 브라우저/CDN/리버스 프록시/Redis 라는 계층 스택 위에서 각 축이 어떻게 적용되는지를 그릴 수 있어야 진짜로 다룰 수 있다.
캐싱 헤더 한 줄이 비용·LCP·복원력·보안 을 동시에 흔든다.
Cache-Control: no-cache를 '캐시 끄기' 로 오해해 매 요청 origin 까지 보내는 사이트가 흔하다 — 사실 저장하되 매번 재검증 의미no-store를 '안전한 기본값' 으로 박으면 BFCache 자격 까지 잃어 뒤로가기 UX 가 망가진다Vary: User-Agent한 줄이 캐시를 수만 개 변형 으로 폭발시켜 hit 율을 0 에 가깝게 만든다s-maxage를 모르면 공유 캐시(CDN) 와 브라우저 에 다른 TTL 을 줄 수 없다
프런트엔드 면접에서 '정적 자산/HTML/API 의 캐시 헤더를 어떻게 다르게 두나' 는 단골 질문이고, 제대로 답하려면 디렉티브 이름의 함정 을 정확히 짚어야 한다.
학습 포인트
면접 답변으로 연결할 학습 포인트입니다.
`no-cache` 는 *저장하되 매번 검증* 이다
디렉티브 이름이 직관과 정반대다. 정리하면:
| 디렉티브 | 의미 | 저장 | 재사용 전 |
|---|---|---|---|
no-cache | 저장은 한다, 매번 origin 과 재검증 | ✅ | 검증 강제 |
no-store | 어디에도 저장 금지 (브라우저/프록시/CDN) | ❌ | 매번 새로 |
max-age=0, must-revalidate | 즉시 stale + 검증 강제 | ✅ | 검증 강제 |
private, max-age=0 | 브라우저만 보관, 즉시 stale | 브라우저만 | 검증 |
'캐시하지 마세요' 가 진짜 의도면 no-store. 단, 이는 BFCache 자격까지 막아 뒤로가기 UX 가 깨질 수 있다.
민감 데이터에 no-cache 를 박고 저장이 막혔다 고 안심하는 것. 디스크 캐시에 그대로 남는다.
`s-maxage` 와 `stale-while-revalidate` 가 운영의 핵심
공유 캐시(CDN) 와 브라우저에 다른 TTL 을 주는 게 부하·신선도·UX 를 동시에 잡는 핵심.
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=60, stale-if-error=600
ETag: "abc123"
max-age=60→ 브라우저는 1분 동안 자기 캐시 사용s-maxage=300→ CDN 은 5분 동안 origin 부하 흡수 (max-age덮어씀)stale-while-revalidate=60→ 5분 지난 직후 60초 동안은 오래된 사본을 즉시 제공 + 백그라운드 재검증 → 사용자는 즉시 응답stale-if-error=600→ origin 5xx 라도 10분간 오래된 사본으로 버팀
사용자에게는 항상 빠른 응답, origin 에는 예측 가능한 부하 를 동시에 준다.
max-age 만 길게 박는 것. 브라우저 캐시까지 길어져 배포해도 사용자는 옛날 버전 을 본다.
`Vary` 는 *정말 응답이 변하는 헤더만*
Vary 는 캐시 키에 헤더를 추가한다. 잘못 쓰면 hit 율이 0 에 수렴한다.
| Vary 값 | 효과 | 주의 |
|---|---|---|
Accept-Encoding | gzip/brotli 변형 분리 | ✅ 거의 항상 안전 |
Accept-Language | 언어별 분리 | 정규화 안 하면 en-US/en-GB 다 따로 |
User-Agent | 브라우저별 분리 | ❌ 수만 개 변형 폭발 |
Cookie | 쿠키 값별 분리 | ❌ 사용자마다 별 항목 |
* | 어떤 캐시 키도 안 만듦 | 사실상 캐시 불가 |
원칙: 응답을 진짜로 바꾸는 헤더만, 가능하면 CDN 에서 정규화 후 키에 사용.
'안전하게 다 분리하자' 며 Vary: User-Agent, Cookie 를 박는 것. 캐시가 죽고 origin 부하만 폭발한다.
읽는 순서
- 1이론
MDN 의
Cache-Control페이지를 보면서 본 글의 5가지 함정 (no-cache≠ no store 등) 을 직접 표로 정리한다. - 2구현
Express/Next 라우트 한 곳에
s-maxage+stale-while-revalidate를 적용하고,cf-cache-status가HIT로 바뀌는지 DevTools Network 로 확인한다. - 3실무
현재 사이트의 정적/HTML/API 응답 헤더를 캡처해 어디서 캐시 누수가 일어나는지 찾아 PR 로 묶는다.
- 4설명
팀에 '캐시 hit 율을 10% 올리는 5가지 헤더 변경' 5분 발표를 준비. 축:
s-maxage,stale-while-revalidate,immutable,Vary정규화,ETag.
면접 연결 질문
[감점 답변] '다 max-age 1년'. [좋은 답변] (1) 핑거프린트된 JS → public, max-age=31536000, immutable (URL 자체가 변하므로 영구). (2) HTML → public, max-age=60, s-maxage=300, stale-while-revalidate=60, stale-if-error=600 + ETag (사용자에겐 빠르게, CDN 으로 origin 보호, 장애 내성). (3) 인증 API → private, no-cache + ETag (공유 금지, 매번 검증 + 304 로 비용 절감).
[감점 답변] '무조건 둘 다'. [좋은 답변] 클라이언트가 두 헤더 모두 조건부 요청에 보내고, 서버가 어느 것이든 일치하면 304 를 응답. ETag 가 더 정밀(바이트 단위) 하지만 서버 클러스터 에서 일관된 ETag 생성 비용이 있다. 콘텐츠 해시 가 가능하면 ETag 만, 수정 시각만 가능하면 Last-Modified 로 충분.
[감점 답변] 'JS 가 무거워서'. [좋은 답변] 문서 응답에 Cache-Control: no-store 가 가장 흔한 차단 요인 — 브라우저가 어떤 사본도 보관하지 못해 BFCache 자격 자체가 사라진다. 보안상 필요한 페이지가 아니라면 no-store 대신 private, no-cache 또는 must-revalidate 로 대체.
자기 점검
'CDN 만 붙이면 다 캐시된다'. 기본적으로 Cloudflare 같은 CDN 은 HTML 을 캐시하지 않는다 — 명시적으로 켜야 한다.
'쿠키마다 응답이 다를 수 있으니 안전하다'. 실제로는 세션 쿠키 한 글자 차이 도 별 항목이 되어 hit 율이 0 에 수렴한다.