본문으로 건너뛰기
yutils

HTTP 캐싱 — Cache-Control·ETag 와 자산별 올바른 헤더 조합

max-age vs s-maxage·immutable·stale-while-revalidate·ETag vs Last-Modified·CDN과 브라우저 캐시·정적 자산·HTML·API 응답에 맞는 헤더 조합.

약 9분 읽기

HTTP 캐싱이 잘된 사이트는 두 번째 방문이 거의 즉시 뜨고, CDN 비용·서버 부하 모두 1/10 수준. 캐싱이 잘못된 사이트는 사용자가 stale 한 콘텐츠를 몇 시간씩 보거나, 반대로 캐시가 전혀 안 먹어 매번 origin 까지 다녀온다. 이 가이드는 Cache-Control 디렉티브 의미, ETag / Last-Modified 의 동작, CDN vs 브라우저 캐시 분리, 자산 종류별 권장 헤더를 정리한다.

두 가지 캐시 계층 — 브라우저 vs 공유 (CDN)

같은 응답에 대해 두 군데서 캐싱이 일어난다.

  • private (브라우저) — 한 사용자만 사용. Authorization 헤더 있는 응답은 기본 private.
  • shared (CDN, proxy) — 여러 사용자가 공유. public 디렉티브로 명시.

Cache-Control 의 디렉티브 일부는 두 곳 모두에 적용 (max-age), 일부는 shared 만 (s-maxage).

Cache-Control 디렉티브 정리

디렉티브의미예시
max-age=NN 초 동안 fresh (모든 캐시)max-age=3600 (1 시간)
s-maxage=Nshared 캐시 (CDN) 만의 max-age 오버라이드s-maxage=86400
publicshared 캐시에 저장 가능public, max-age=300
privateshared 캐시 X (브라우저만)private, max-age=0
no-cache저장은 하되 매번 origin 에 validation 요청 (304 가능)no-cache
no-store저장 자체 금지no-store (민감 데이터)
immutablefresh 동안 reload 시도 X. 콘텐츠 절대 안 변함max-age=31536000, immutable
stale-while-revalidate=N만료 후 N 초 동안 stale 반환 + 백그라운드로 갱신max-age=60, stale-while-revalidate=600
must-revalidate만료 후 stale 사용 금지max-age=0, must-revalidate

흔한 헤더 조합 — 자산별

해시된 정적 자산 (JS/CSS/이미지)

Cache-Control: public, max-age=31536000, immutable

파일명에 hash (app.a3f9.js) → 콘텐츠 변경 = 파일명 변경. 1 년 캐시 + immutable. Next.js, Vite, webpack 가 hashed asset 에 자동 적용.

HTML (SPA index, SSR 페이지)

Cache-Control: no-cache

하나의 URL 이 항상 최신 콘텐츠를 가리켜야 함. no-cache 는 매 요청 origin 에 validation — ETag 일치면 304 (본문 전송 0). no-store 와 헷갈리지 말 것. no-store 는 저장 자체 X.

API 응답 (사용자별 데이터)

Cache-Control: private, no-cache

CDN 에서 캐시되면 안 됨 (다른 사용자에게 노출 위험). 브라우저는 캐시 + ETag validation.

API 응답 (공개 read, 거의 안 변함)

Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600

브라우저 1 분, CDN 5 분, 만료 후 10 분 stale 허용. 트래픽 ↑ 시 origin 부하 ↓.

민감 응답 (계좌·결제·인증)

Cache-Control: no-store

절대 저장 금지. 페이지 history 의 뒤로가기에서도 재요청.

ETag — conditional GET

ETag 는 응답 본문의 해시 (또는 버전 식별자). 서버가 박아 보내고, 다음 요청에 브라우저가 If-None-Match 로 동봉. 변경 없으면 304 반환 → 본문 0.

# 첫 요청
HTTP/1.1 200 OK
ETag: "33a64df5"
Cache-Control: no-cache
Content-Type: text/html

<html>...</html>

# 두 번째 요청
GET /index.html
If-None-Match: "33a64df5"

# 변경 없음
HTTP/1.1 304 Not Modified
ETag: "33a64df5"

304 는 본문 없음 — 네트워크 절약. no-cache + ETag 가 SPA index.html 의 표준 조합. HTTP 상태 코드 에서 304 응답의 의미를 확인할 수 있다.

strong vs weak ETag

  • strong: "abc123" — 바이트 단위로 동일.
  • weak: W/"abc123" — 의미적으로 동일 (공백·줄바꿈 차이 무시).

대부분 strong. weak 은 gzip/brotli 같은 변환에서 사용.

Last-Modified — 시간 기반 validation

ETag 의 대체. 본문의 마지막 변경 시각 (HTTP date format).

HTTP/1.1 200 OK
Last-Modified: Wed, 15 May 2026 14:30:00 GMT
Cache-Control: no-cache

# 다음 요청
GET /image.jpg
If-Modified-Since: Wed, 15 May 2026 14:30:00 GMT

# 변경 없음
HTTP/1.1 304 Not Modified

ETag 와 비교:

  • ETag — 1 초 미만 변경, 정확한 비교 가능. 권장.
  • Last-Modified — 초 단위 정밀도. 같은 초에 두 번 변경 되면 못 잡음.
  • 둘 다 박기 — 일반적. 클라이언트가 둘 다 보낼 수 있음.

CDN 별 차이 — Vary, s-maxage

Vary 헤더

같은 URL 이 요청 헤더에 따라 다른 응답 → CDN 이 헤더별로 캐시 분리.

Vary: Accept-Encoding
# → gzip / brotli 별로 캐시 분리

Vary: Accept-Language
# → 언어별 분리

Vary: Cookie
# → 쿠키별 분리 (대부분 안 좋음 — 사용자마다 캐시 분리 = 캐시 무력화)

Vary: Cookie 는 사용자별 캐시 → CDN 효과 거의 0. 인증 데이터 는 차라리 private 으로.

Cloudflare Cache Tag

CF Workers / Pages 의 Cache-Tag 헤더 → 태그 기반 purge. Cache-Tag: product-42, category-shoes → API 가 변경되면 두 태그 모두 purge 호출.

stale-while-revalidate — 무중단 갱신

만료 직후 stale 응답 즉시 반환 + 백그라운드 재검증. 사용자는 절대 기다 리지 않음.

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

# T=0   응답 캐시
# T=30  fresh, 캐시 반환
# T=60  만료 시작
# T=90  stale 반환 + 백그라운드 revalidate → 새 응답 캐시
# T=120 fresh 다시 반환

Vercel, Cloudflare 모두 지원. Next.js Route Handlers / Server Components 에서 표준 패턴. 검색 결과 페이지, dashboard, 거의 모든 SSR 응답에 적합.

흔한 함정

1. no-cacheno-store 혼동

no-cache = 저장 O + 매번 validation. no-store = 저장 X. 민감 데이터에는 no-store, "항상 최신" 콘텐츠에는 no-cache.

2. 모든 응답에 no-store

과보안. 정적 이미지·CSS 까지 no-store 면 사이트가 느려짐. 자산별로 다른 헤더.

3. max-age=0, must-revalidate = no-cache?

실제 동작 거의 같음. 두 표현 다 자주 보임.

4. CDN 의 캐시 무한 확장

s-maxage 없이 max-age 만 박으면 CDN 도 같은 시간. 보통 CDN 은 더 오래 캐시해도 OK → s-maxage 명시.

5. Set-Cookie 응답의 캐싱

Set-Cookie 가 있는 응답은 기본 private. 명시적으로 public 박으면 다른 사용자에게 노출 — 사고. 인증 응답은 절대 public X.

6. CDN purge 누락

콘텐츠 변경 후 origin 만 갱신 → CDN 은 stale. 배포 파이프라인에 CDN purge 통합 또는 cache-busting URL (?v=...) 사용.

7. Pragma: no-cache 사용

HTTP/1.0 legacy. 모든 모던 클라이언트 무시. 박지 말 것.

디버깅 — 헤더 보기

curl -I https://example.com/api/users
# Cache-Control: public, max-age=60
# ETag: "abc123"
# Vary: Accept-Encoding

# CDN 캐시 hit/miss 확인 (Cloudflare)
curl -I https://example.com/asset.js
# CF-Cache-Status: HIT  (또는 MISS / EXPIRED / DYNAMIC)

cURL 빌더 에서 method/header 조합한 curl 명령을 만든 뒤 -I 옵션으로 헤더만 빠르게 확인. URL 파서 로 path/query 분해 후 다른 URL 의 동작과 비교.

요약

  • 해시된 정적 자산 = max-age=31536000, immutable. 1 년 캐시.
  • HTML = no-cache + ETag. 매번 validation, 본문 절약.
  • 공개 API = public, max-age + s-maxage + stale-while-revalidate. CDN 적극 활용.
  • 민감 API = private, no-cache 또는 no-store.
  • ETag 가 표준. Last-Modified 는 보조.
  • Vary: Cookie 피하기 — 캐시 무력화.
가이드 목록으로