[Daily morning study] HTTP 캐싱 메커니즘 - Cache-Control, ETag, 조건부 요청
#daily morning study
HTTP 캐싱이란?
HTTP 캐싱은 이전에 받아온 리소스를 저장해두고, 같은 리소스를 다시 요청할 때 서버에 다시 요청하지 않고 저장된 응답을 재사용하는 메커니즘이다. 네트워크 왕복을 줄여 응답 속도를 높이고 서버 부하를 낮춘다.
캐시는 위치에 따라 세 가지로 나뉜다.
| 종류 | 위치 | 설명 |
|---|---|---|
| 브라우저 캐시 | 클라이언트 | 사용자의 브라우저에 저장 |
| 프록시 캐시 | 중간 서버 | 여러 사용자가 공유 (회사 게이트웨이, CDN 등) |
| 서버 캐시 | 오리진 서버 | Redis, Varnish 등 애플리케이션 레벨 캐시 |
HTTP 캐시 제어의 핵심은 응답 헤더에 있다. 서버가 응답을 보낼 때 캐시 관련 헤더를 포함하면, 클라이언트와 중간 프록시가 그 규칙을 따른다.
Cache-Control 헤더
Cache-Control은 HTTP/1.1부터 도입된 캐시 제어의 핵심 헤더다. 여러 디렉티브를 조합해서 사용한다.
응답 헤더에서 사용하는 디렉티브
max-age
Cache-Control: max-age=3600
리소스가 신선(fresh)한 상태로 유지되는 초(second) 단위의 시간이다. 3600이면 1시간 동안 캐시된 응답을 그대로 사용한다.
no-cache
Cache-Control: no-cache
이름이 혼란스럽지만, 캐시를 아예 하지 말라는 게 아니다. 캐시에 저장은 하되, 사용하기 전에 반드시 서버에 유효성을 검증(revalidation)하라는 뜻이다.
no-store
Cache-Control: no-store
응답을 어디에도 저장하면 안 된다. 민감한 정보(뱅킹, 개인정보 등)에 사용한다.
public / private
Cache-Control: public, max-age=86400
Cache-Control: private, max-age=600
public: 브라우저뿐 아니라 CDN 같은 공유 캐시도 저장 가능private: 해당 사용자의 브라우저에만 저장 가능 (공유 캐시는 저장 불가)
must-revalidate
Cache-Control: max-age=3600, must-revalidate
캐시가 만료된 후에는 반드시 서버에 재검증을 요청해야 한다. 서버가 응답하지 않으면 캐시를 사용하지 않고 오류를 반환한다.
stale-while-revalidate
Cache-Control: max-age=60, stale-while-revalidate=3600
max-age가 지나 stale 상태가 되더라도, stale-while-revalidate 시간 내에는 일단 stale 응답을 반환하면서 백그라운드에서 서버에 재검증 요청을 보낸다. 사용자 응답 속도를 희생하지 않으면서도 결과적으로 최신 데이터를 유지한다.
s-maxage
Cache-Control: s-maxage=3600, max-age=600
공유 캐시(CDN, 프록시)에만 적용되는 max-age다. 브라우저는 max-age=600을 따르고, CDN은 s-maxage=3600을 따른다.
요청 헤더에서 사용하는 디렉티브
클라이언트도 요청에 Cache-Control을 넣어 캐시 동작에 영향을 줄 수 있다.
Cache-Control: no-cache # 서버에 재검증 요청
Cache-Control: no-store # 응답을 저장하지 않도록 요청
Cache-Control: max-age=0 # 캐시된 응답을 사용하지 않음
브라우저에서 강제 새로고침(Ctrl+Shift+R)을 하면 Cache-Control: no-cache와 함께 요청이 전송된다.
Expires 헤더 (구버전)
HTTP/1.0 시절부터 있던 캐시 만료 시간 지정 방식이다.
Expires: Wed, 21 Oct 2025 07:28:00 GMT
절대적인 날짜/시간을 지정한다. 클라이언트와 서버의 시계가 다르면 오동작할 수 있다. Cache-Control: max-age가 있으면 Expires는 무시된다. 지금은 하위 호환성 목적으로만 사용한다.
조건부 요청 (Conditional Request)
캐시된 리소스가 만료됐거나 재검증이 필요할 때, 서버에 “이 리소스가 변경됐나요?”라고 물어보는 방식이다. 리소스가 변경되지 않았다면 서버는 본문 없이 304 Not Modified만 반환하므로 대역폭을 아낄 수 있다.
조건부 요청에는 두 가지 방식이 있다.
Last-Modified / If-Modified-Since
서버가 응답할 때 리소스의 마지막 수정 시간을 포함한다.
HTTP/1.1 200 OK
Last-Modified: Mon, 06 Oct 2026 08:00:00 GMT
Cache-Control: max-age=3600
캐시가 만료된 후 클라이언트는 이 시간을 If-Modified-Since 헤더에 담아 요청한다.
GET /api/data HTTP/1.1
If-Modified-Since: Mon, 06 Oct 2026 08:00:00 GMT
서버는 그 시간 이후로 리소스가 변경됐는지 확인한다.
- 변경됨 →
200 OK+ 새 본문 - 변경 안 됨 →
304 Not Modified(본문 없음)
시간 기반이라 초 단위 정밀도만 가지고 있어, 1초 안에 여러 번 수정되면 감지하지 못할 수 있다.
ETag / If-None-Match
ETag(Entity Tag)는 리소스의 특정 버전을 나타내는 식별자다. 파일 해시, 버전 번호, 타임스탬프 조합 등으로 만든다.
HTTP/1.1 200 OK
ETag: "a1b2c3d4e5"
Cache-Control: max-age=3600
클라이언트는 ETag 값을 If-None-Match 헤더에 담아 요청한다.
GET /api/data HTTP/1.1
If-None-Match: "a1b2c3d4e5"
서버는 현재 리소스의 ETag와 비교한다.
- 다름 (변경됨) →
200 OK+ 새 본문 + 새 ETag - 같음 (변경 없음) →
304 Not Modified
ETag가 Last-Modified보다 정확하고 신뢰할 수 있다. 강한 ETag(Strong ETag)와 약한 ETag(Weak ETag, W/"...")로 나뉜다.
캐시 흐름 정리
클라이언트가 리소스 요청
↓
캐시에 저장된 응답이 있는가?
├─ 없음 → 서버에 요청 → 응답 저장 후 반환
└─ 있음 → 아직 신선(fresh)한가?
├─ 신선함 → 캐시된 응답 반환 (서버 요청 없음)
└─ 만료됨 → 조건부 요청 전송
├─ 304 Not Modified → 기존 캐시 갱신 후 반환
└─ 200 OK → 새 응답 저장 후 반환
Vary 헤더
같은 URL이라도 요청 헤더에 따라 다른 응답을 반환하는 경우, Vary 헤더로 이를 명시한다.
HTTP/1.1 200 OK
Vary: Accept-Encoding
Cache-Control: max-age=3600
이 응답은 Accept-Encoding 헤더 값이 같을 때만 캐시를 재사용한다. gzip을 지원하는 클라이언트와 지원하지 않는 클라이언트는 다른 캐시 항목을 가진다.
Vary: Accept-Language # 언어별로 다른 캐시
Vary: Authorization # 인증 토큰별로 다른 캐시 (공유 캐시에 부적절)
Vary: *는 모든 요청을 고유하게 취급하여 사실상 캐싱을 비활성화한다.
캐시 무효화 (Cache Invalidation)
캐시의 가장 어려운 문제 중 하나다. 서버가 콘텐츠를 업데이트했을 때 기존 캐시를 무효화하는 방법이 없는 것이 HTTP 캐싱의 근본적 한계다.
이를 우회하는 일반적인 방법이 캐시 버스팅(Cache Busting)이다.
<!-- 파일명에 버전/해시 포함 -->
<script src="/app.a1b2c3d4.js"></script>
<link href="/style.v2.css" rel="stylesheet">
파일 내용이 바뀌면 URL 자체가 달라지므로 브라우저는 새 파일을 받는다. 이전 URL은 더 이상 요청되지 않는다. webpack, Vite 등 대부분의 빌드 도구가 이 방식을 자동으로 처리한다.
CDN에서 특정 파일을 즉시 무효화하려면 CDN의 Purge API를 사용한다.
실전 캐시 전략
정적 자산 (JS, CSS, 이미지)
Cache-Control: public, max-age=31536000, immutable
해시가 포함된 파일명을 사용하면 1년(31536000초)씩 캐시해도 안전하다. immutable은 캐시 유효 기간 동안 재검증 요청도 보내지 않도록 한다.
HTML 파일
Cache-Control: no-cache
항상 서버에 재검증 요청을 보내도록 한다. HTML은 JS/CSS 파일의 진입점이기 때문에, 새 버전의 JS/CSS를 참조하는 최신 HTML을 항상 받아야 한다.
API 응답 (사용자별 데이터)
Cache-Control: private, no-store
또는 인증이 필요한 응답에 Authorization 헤더가 있으면 공유 캐시는 기본적으로 저장하지 않는다.
API 응답 (공용 데이터, 단기 유효)
Cache-Control: public, max-age=60, stale-while-revalidate=300
60초 동안은 캐시를 그대로 사용하고, 300초까지는 stale 응답을 반환하면서 백그라운드에서 갱신한다.
pragma 헤더 (레거시)
HTTP/1.0 호환을 위한 헤더다.
Pragma: no-cache
HTTP/1.1에서는 Cache-Control: no-cache를 사용한다. 지금은 역호환 목적으로만 존재한다.