HTTP 캐시의 Cache-Control과 ETag 역할 구분하기
HTTP 캐시의 Cache-Control과 ETag 역할 구분하기
max-age 동안은 fresh 응답을 바로 재사용할 수 있다. stale이 되면 If-None-Match에 ETag를 보내고 서버가 304를 반환해 본문 전송을 생략할 수 있다.
목차
- #왜 이 문제가 생기는가
- #캐시의 세 가지 질문
- #Cache-Control은 재사용 정책을 정한다
- #ETag는 저장된 응답을 재검증한다
- #fresh에서 stale로 바뀌는 흐름
- #정적 자산과 API 응답의 정책은 다르다
- #private, public, no-cache, no-store 구분
- #Vary와 CDN을 함께 생각하기
- #캐시 문제를 디버깅하는 순서
- #결론
- #관련 노트
왜 이 문제가 생기는가
웹 성능을 개선하려고 모든 응답에 긴 max-age를 붙이면 배포 뒤 오래된 JavaScript가 남거나 사용자별 API 응답이 잘못 재사용될 수 있다. 반대로 매번 no-store를 붙이면 브라우저는 이미 받은 파일과 똑같은 본문을 계속 내려받는다.
HTTP 캐시는 단순히 “저장한다/저장하지 않는다”의 문제가 아니다. 저장된 응답을 얼마 동안 확인 없이 재사용할지, 오래된 뒤 어떤 조건으로 서버에 검증할지, 브라우저와 공유 캐시 중 누가 보관할지를 각각 설계해야 한다.
파일명, 사용자 ID, ETag 값은 캐시 흐름을 설명하기 위해 만든 예시다. 실제 서비스 응답을 사용하지 않았다.
캐시의 세 가지 질문
캐시 정책을 읽을 때 다음 세 질문을 분리하면 이해하기 쉽다.
- 저장 가능한가? — 응답을 캐시에 보관해도 되는가?
- 지금 재사용 가능한가? — 서버에 묻지 않고 바로 사용할 만큼 fresh한가?
- 오래되었다면 검증 가능한가? — 서버에 조건부 요청을 보내 본문을 다시 받을지 판단할 수 있는가?
Cache-Control은 주로 저장과 재사용 정책을 표현하고, ETag와 Last-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 + 새 본문
endETag가 있다고 항상 매 요청 서버에 확인하는 것은 아니다. 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 헤더가 있는 응답의 키가 사용자별로 분리되지 않으면 데이터 노출 사고로 이어질 수 있다. 기본값에 기대지 말고 다음을 명시적으로 검토한다.
- CDN이 어떤 응답을 저장하는가?
- cache key에 query와 필요한 헤더가 포함되는가?
- 개인화 응답은
private또는no-store인가? - purge와 배포 롤백은 어떻게 하는가?
캐시 문제를 디버깅하는 순서
- 요청 URL과 query가 정말 같은지 확인한다.
- 응답의
Cache-Control,Age,ETag,Vary를 기록한다. - 브라우저 캐시, service worker, CDN 중 어느 계층이 응답했는지 구분한다.
- 조건부 요청에
If-None-Match가 실렸는지 본다. - 304가 왔다면 서버가 현재 표현과 같은 ETag를 계산한 근거를 확인한다.
- 개발자 도구의 “Disable cache” 상태와 일반 사용자 조건을 혼동하지 않는다.
max-age 동안은 fresh 응답을 바로 재사용할 수 있다. stale이 되면 If-None-Match에 ETag를 보내고 서버가 304를 반환해 본문 전송을 생략할 수 있다.
결론
Cache-Control은 저장된 응답을 누가, 얼마 동안, 어떤 조건으로 재사용할지 정하고 ETag는 stale한 응답이 여전히 같은지 재검증한다. 해시 정적 자산에는 긴 fresh 기간을, HTML과 변경 가능한 API에는 데이터 성격에 맞는 재검증 정책을 둔다. 브라우저와 CDN의 역할을 분리하고 사용자별 응답이 공유 캐시에 섞이지 않는지 확인하는 것이 성능보다 먼저다.