HTTP 캐시의 Cache-Control과 ETag 역할 구분하기

HTTP 캐시의 Cache-Control과 ETag 역할 구분하기

한눈에 보기

max-age 동안은 fresh 응답을 바로 재사용할 수 있다. stale이 되면 If-None-Match에 ETag를 보내고 서버가 304를 반환해 본문 전송을 생략할 수 있다.

목차

왜 이 문제가 생기는가

웹 성능을 개선하려고 모든 응답에 긴 max-age를 붙이면 배포 뒤 오래된 JavaScript가 남거나 사용자별 API 응답이 잘못 재사용될 수 있다. 반대로 매번 no-store를 붙이면 브라우저는 이미 받은 파일과 똑같은 본문을 계속 내려받는다.

HTTP 캐시는 단순히 “저장한다/저장하지 않는다”의 문제가 아니다. 저장된 응답을 얼마 동안 확인 없이 재사용할지, 오래된 뒤 어떤 조건으로 서버에 검증할지, 브라우저와 공유 캐시 중 누가 보관할지를 각각 설계해야 한다.

이 글의 예제에 관하여

파일명, 사용자 ID, ETag 값은 캐시 흐름을 설명하기 위해 만든 예시다. 실제 서비스 응답을 사용하지 않았다.

캐시의 세 가지 질문

캐시 정책을 읽을 때 다음 세 질문을 분리하면 이해하기 쉽다.

  1. 저장 가능한가? — 응답을 캐시에 보관해도 되는가?
  2. 지금 재사용 가능한가? — 서버에 묻지 않고 바로 사용할 만큼 fresh한가?
  3. 오래되었다면 검증 가능한가? — 서버에 조건부 요청을 보내 본문을 다시 받을지 판단할 수 있는가?

Cache-Control은 주로 저장과 재사용 정책을 표현하고, ETagLast-Modified는 저장된 표현의 변경 여부를 검증하는 validator 역할을 한다.

Cache-Control은 재사용 정책을 정한다

예를 들어 다음 응답은 브라우저 같은 개인 캐시에 저장하고 60초 동안 fresh하게 재사용할 수 있다는 뜻이다.

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, max-age=60
ETag: "profile-42-v7"

{"id":"42","displayName":"Example User"}

60초 안에 같은 리소스가 필요하면 캐시는 네트워크 요청 없이 저장한 응답을 사용할 수 있다. 이때 서버 데이터가 중간에 바뀌더라도 브라우저는 즉시 알지 못한다. max-age는 “데이터가 절대 변하지 않는다”가 아니라 “이 시간 동안은 확인하지 않고 사용해도 된다”는 제품 정책이다.

따라서 신선도 시간은 데이터 특성에 맞춰야 한다.

데이터 변경 특성 정책의 출발점
해시가 포함된 JS 파일 내용이 바뀌면 URL도 바뀜 매우 긴 fresh 기간
사용자 프로필 변경 가능, 사용자별 짧은 private 캐시 + 재검증
결제 직전 금액 즉시 정확해야 함 저장 최소화 또는 매번 검증
공개 카테고리 목록 공유 가능, 가끔 변경 CDN 캐시 + 갱신 전략

ETag는 저장된 응답을 재검증한다

ETag는 특정 URL에서 반환된 표현의 버전을 식별하는 validator다. 캐시가 stale해지면 저장한 ETag를 If-None-Match에 담아 조건부 요청을 보낼 수 있다.

GET /profiles/42 HTTP/1.1
Host: api.example.test
If-None-Match: "profile-42-v7"

서버의 현재 표현이 같다면 본문 없이 304 Not Modified로 응답한다.

HTTP/1.1 304 Not Modified
Cache-Control: private, max-age=60
ETag: "profile-42-v7"

브라우저는 기존 본문을 재사용하고 헤더 정책을 갱신한다. 네트워크 왕복은 여전히 있지만 큰 본문 전송을 피할 수 있다. 데이터가 바뀌었다면 새 ETag와 본문을 포함한 200을 반환한다.

HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: "profile-42-v8"
Content-Type: application/json

{"id":"42","displayName":"Updated User"}

ETag를 응답 바이트 전체의 강한 해시로 만들 수도 있지만 매 요청마다 큰 본문을 직렬화하고 해시하는 비용이 클 수 있다. 데이터베이스의 안정적인 revision이나 변경 버전을 사용할 수 있다면 더 저렴한 validator가 된다. 단, 같은 ETag를 주는 두 응답은 캐시 관점에서 같은 표현이어야 한다.

fresh에서 stale로 바뀌는 흐름

sequenceDiagram
    participant B as Browser Cache
    participant S as Server

    B->>S: GET /profiles/42
    S-->>B: 200 + max-age=60 + ETag v7 + body
    Note over B: 60초 동안 fresh
    B->>B: 네트워크 없이 저장 응답 재사용
    Note over B: 60초 후 stale
    B->>S: GET + If-None-Match v7
    alt 표현이 같음
        S-->>B: 304, 본문 없음
        B->>B: 기존 본문 재사용
    else 표현이 변경됨
        S-->>B: 200 + ETag v8 + 새 본문
    end

ETag가 있다고 항상 매 요청 서버에 확인하는 것은 아니다. max-age 동안 fresh하면 validator를 사용할 필요 없이 바로 재사용한다. 반대로 no-cache는 저장을 금지하는 뜻이 아니라, 사용 전에 재검증하도록 요구하는 정책이다.

정적 자산과 API 응답의 정책은 다르다

빌드 결과에 콘텐츠 해시를 포함하면 파일 내용이 바뀔 때 URL도 바뀐다.

/assets/app.a81c9f.js
/assets/app.f29d11.js

이런 파일은 이전 URL의 내용을 오래 캐시해도 안전하다.

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

반면 해시가 없는 /index.html을 같은 기간 캐시하면 새 배포 후에도 예전 자산 URL을 참조할 수 있다. HTML은 짧게 캐시하거나 재검증하고, 해시 자산은 길게 캐시하는 식으로 역할을 나눈다.

# index.html의 예
Cache-Control: no-cache
ETag: "index-release-43"

# app.a81c9f.js의 예
Cache-Control: public, max-age=31536000, immutable

API 응답은 URL이 같아도 내용이 바뀔 수 있고 사용자마다 다를 수 있다. 정적 자산에서 성공한 캐시 정책을 그대로 복사하면 안 된다.

private, public, no-cache, no-store 구분

지시어 의미 대표 상황
private 개인 캐시는 저장 가능, 공유 캐시는 저장하지 않음 사용자별 프로필
public 공유 캐시에도 저장 가능함을 명시 공개 정적 자산
no-cache 저장할 수 있지만 재사용 전 검증 필요 항상 변경 여부를 확인할 HTML
no-store 요청·응답을 저장하지 않도록 요구 저장 자체를 피해야 할 민감 응답
max-age=N 개인 캐시의 fresh 기간 브라우저 재사용 시간
s-maxage=N 공유 캐시에 적용할 fresh 기간 CDN 정책 분리

no-cache라는 이름 때문에 “캐시하지 않는다”고 오해하기 쉽다. 저장 금지는 no-store에 가깝고, no-cache는 재검증을 강조한다.

민감한 응답이라고 무조건 private만 붙이면 충분한 것도 아니다. private는 공유 캐시를 제한하지만 브라우저 저장은 허용한다. 실제 요구가 “어떤 캐시에도 저장하지 않음”이라면 no-store를 검토한다.

Vary와 CDN을 함께 생각하기

같은 URL의 응답이 요청 헤더에 따라 달라진다면 캐시가 그 차이를 알아야 한다.

Vary: Accept-Encoding, Accept-Language

Vary에 포함된 요청 헤더 값은 캐시 변형을 구분하는 데 사용된다. 예를 들어 한국어와 영어 응답이 같은 캐시 키로 섞이지 않게 할 수 있다. 하지만 Vary 기준을 지나치게 많이 만들거나 값의 종류가 매우 많으면 캐시 적중률이 낮아진다.

인증된 응답을 CDN에 캐시할 때는 더 신중해야 한다. 쿠키나 Authorization 헤더가 있는 응답의 키가 사용자별로 분리되지 않으면 데이터 노출 사고로 이어질 수 있다. 기본값에 기대지 말고 다음을 명시적으로 검토한다.

캐시 문제를 디버깅하는 순서

오래된 응답이 보일 때

  1. 요청 URL과 query가 정말 같은지 확인한다.
  2. 응답의 Cache-Control, Age, ETag, Vary를 기록한다.
  3. 브라우저 캐시, service worker, CDN 중 어느 계층이 응답했는지 구분한다.
  4. 조건부 요청에 If-None-Match가 실렸는지 본다.
  5. 304가 왔다면 서버가 현재 표현과 같은 ETag를 계산한 근거를 확인한다.
  6. 개발자 도구의 “Disable cache” 상태와 일반 사용자 조건을 혼동하지 않는다.

max-age 동안은 fresh 응답을 바로 재사용할 수 있다. stale이 되면 If-None-Match에 ETag를 보내고 서버가 304를 반환해 본문 전송을 생략할 수 있다.

결론

Cache-Control은 저장된 응답을 누가, 얼마 동안, 어떤 조건으로 재사용할지 정하고 ETag는 stale한 응답이 여전히 같은지 재검증한다. 해시 정적 자산에는 긴 fresh 기간을, HTML과 변경 가능한 API에는 데이터 성격에 맞는 재검증 정책을 둔다. 브라우저와 CDN의 역할을 분리하고 사용자별 응답이 공유 캐시에 섞이지 않는지 확인하는 것이 성능보다 먼저다.

관련 노트