콘텐츠로 이동

HTTP Cache

HTTP 캐시는 서버가 보낸 HTTP 응답을 브라우저, CDN, 리버스 프록시 같은 중간 계층에 저장해 두었다가 같은 의미의 요청이 다시 왔을 때 재사용하는 메커니즘이다.

중요한 점은 “파일을 어딘가에 저장한다”가 아니라 응답을 언제 저장할지, 얼마나 신선하다고 볼지, 누구와 공유할지, 언제 원본 서버에 다시 확인할지를 HTTP 헤더로 합의한다는 것이다. 이 합의가 깨지면 페이지가 빨라지는 대신 오래된 가격, 다른 사용자의 프로필, 오염된 스크립트가 조용히 전달될 수 있다.

이 문서는 HTTP 요청-응답과 헤더를 이미 알고 있다는 전제에서 시작하지만, 캐시 전용 용어는 첫 등장 위치에서 다시 정의한다. RTT(round-trip time, 요청이 갔다가 응답의 첫 신호가 돌아오기까지 걸리는 왕복 시간)도 이 문서 안에서 필요한 만큼 다시 잡는다.


1.5. 선행 기술의 한계 — HTTP Cache 등장 이유

섹션 제목: “1.5. 선행 기술의 한계 — HTTP Cache 등장 이유”

선행 상태 - 캐시 없는 HTTP: 브라우저가 같은 이미지, 같은 CSS, 같은 공개 API 응답을 매번 Origin 서버까지 가져오면 요청마다 DNS, TCP/TLS 연결, HTTP 왕복, 서버 처리, 본문 전송 비용이 반복된다. 한국 사용자와 미국 리전 사이 왕복 시간이 120ms라면, 본문이 작아도 “확인하러 갔다 오는 비용”만 요청마다 120ms씩 붙는다. 20개의 정적 자산을 모두 Origin에서 다시 받는 페이지는 서버 처리 시간이 거의 없어도 네트워크 왕복만으로 체감 지연이 커진다.

1차 시도 - Expires: HTTP/1.0은 Expires: Tue, 01 Jan 2030 01:00:00 GMT처럼 절대 시각으로 만료 시간을 지정했다. RFC 9111을 따르는 캐시는 이 값을 로컬 시계와 단순 비교하지 않는다. Date: Tue, 01 Jan 2030 00:00:00 GMT가 함께 왔다면 freshness_lifetime = Expires - Date = 3600초로 계산하고, 응답의 현재 나이(current_age)와 비교한다. Date가 없으면 메시지를 받은 시각을 대신 사용한다. 이 방식은 가능한 한 Origin이 제공한 두 시각의 차이를 사용해 클럭 스큐(clock skew)의 영향을 줄인다.

HTTP/1.1 Cache-Control이 푸는 방식: Cache-Control: max-age=300은 HTTP-date 두 개를 생성하고 파싱하는 대신 delta-seconds 정수 하나로 freshness lifetime을 직접 표현한다. Expires - Date도 클럭 스큐를 줄이지만, max-age는 날짜 형식·GMT 표기·누락되거나 잘못된 Date 같은 파싱과 설정 모호성이 적다. 여기에 공유 캐시 전용 s-maxage, 저장 금지 no-store, 저장은 허용하되 매번 확인하는 no-cache, 사용자별 저장 범위인 private, CDN 공유 허용인 public 같은 지시어가 더해지면서 캐시는 단순 만료 시간이 아니라 정책 언어가 되었다.

검증자(validator)의 등장: 캐시가 만료됐다고 항상 전체 본문을 다시 받을 필요는 없다. 서버가 ETagLast-Modified를 보내면 브라우저는 다음 요청에 If-None-Match 또는 If-Modified-Since를 붙여 “내가 가진 버전이 아직 같은가?”만 확인할 수 있다. 변경이 없으면 서버는 304 Not Modified로 본문 없이 응답한다.

공유 캐시의 등장: 브라우저 캐시는 한 사용자에게만 도움을 준다. CDN이나 리버스 프록시 캐시는 여러 사용자가 같은 응답을 재사용하게 해 Origin 부하와 네트워크 비용을 동시에 줄인다. 하지만 이 공유성 때문에 사용자별 응답, 인증 헤더, 쿠키, 언어, 압축 방식이 캐시 키에 어떻게 반영되는지가 보안 문제로 바뀐다.

이 토픽이 사라지면 깨지는 것: Cache-Control과 검증자 없이 서비스가 커지면 같은 응답을 계속 Origin이 만들고 전송한다. 반대로 캐시를 무작정 켜면 배포 후 오래된 JS가 남거나, Authorization이 붙은 응답이 CDN에 저장되거나, 캐시 키에 포함되지 않은 입력이 응답에 반영되어 캐시 포이즈닝으로 이어진다.

출처: RFC 9111 - HTTP Caching, MDN HTTP caching


2. 캐시를 읽는 기본 질문 네 가지

섹션 제목: “2. 캐시를 읽는 기본 질문 네 가지”

HTTP 캐시는 헤더 이름을 외우기 전에 네 가지 질문으로 나누면 훨씬 안정적으로 이해된다.

  1. 저장해도 되는가? 응답에 개인정보, 결제 정보, 사용자별 권한 결과가 있으면 저장 위치가 제한되거나 저장 자체가 금지된다. 여기서 public, private, no-store가 필요하다.

  2. 얼마나 신선한가? 저장된 응답을 다시 확인하지 않고 써도 되는 시간을 fresh lifetime이라고 한다. 여기서 max-age, s-maxage, Expires, Age가 필요하다.

  3. 만료 뒤 어떻게 확인하는가? stale(신선하지 않은) 응답을 무조건 버릴지, 서버에 조건부 요청을 보낼지, 장애 때 잠깐 쓸지 결정한다. 여기서 ETag, Last-Modified, If-None-Match, 304, stale-while-revalidate, stale-if-error가 필요하다.

  4. 무엇을 같은 응답으로 볼 것인가? URL이 같아도 언어, 압축 방식, 인증 상태, 쿼리스트링에 따라 응답이 달라질 수 있다. 여기서 캐시 키(cache key), Vary, CDN Cache Policy가 필요하다.

HTTP 캐시 판단의 네 질문
flowchart TD
A["HTTP 응답 수신"] --> B{"저장해도 되는가?"}
B -->|"아니오"| NoStore["저장하지 않음"]
B -->|"예"| C{"누구와 공유할 수 있는가?"}
C --> Private["브라우저 같은 private cache"]
C --> Shared["CDN 같은 shared cache"]
Private --> D{"fresh인가?"}
Shared --> D
D -->|"예"| Hit["캐시 HIT - 즉시 재사용"]
D -->|"아니오"| E{"검증자나 stale 정책이 있는가?"}
E -->|"검증자 있음"| Validate["조건부 요청 - 304 또는 200"]
E -->|"stale 허용"| Stale["stale 응답 제공 + 갱신"]
E -->|"없음"| Miss["Origin에서 새로 수신"]

첫 회독 기준: 처음 읽을 때는 모든 CDN 설정을 외우려 하지 말고, 위 네 질문에 각 헤더가 어느 답을 주는지만 잡으면 된다. 세부 튜닝은 뒤의 선택 기준과 실패 신호를 보면서 붙인다.


HTTP 캐시는 한 장소가 아니라 요청 경로의 여러 층에 있다. L2 앞선 문서에서 본 리버스 프록시와 CDN 경로 위에 캐시 저장소가 얹힌다고 보면 된다.

브라우저에서 Origin까지의 캐시 계층
flowchart LR
User["사용자"] --> Browser["브라우저 private cache"]
Browser --> CDN["CDN edge shared cache"]
CDN --> Proxy["Reverse proxy cache"]
Proxy --> Origin["Origin application"]
Origin --> DB["DB or upstream"]
계층저장 범위대표 예강점위험
브라우저 캐시사용자 1명Chrome, Safari cache가장 가까워 RTT 0에 가까움배포 후 오래된 파일이 남기 쉬움
CDN 캐시여러 사용자CloudFront, Cloudflare, FastlyOrigin 부하와 국제망 지연 감소사용자별 응답이 섞이면 보안 사고
리버스 프록시 캐시서비스 앞단Nginx, Varnish내부 API와 정적 파일 보호캐시 키 설정이 앱 의미와 어긋날 수 있음
애플리케이션 캐시서버 코드 내부Redis, in-memory cacheDB 부하 직접 감소HTTP 헤더와 별도 무효화가 필요

이 문서의 주인공은 HTTP 응답을 다루는 브라우저, CDN, 프록시 캐시다. Redis 같은 서버 사이드 캐시는 같은 원리를 공유하지만, HTTP 헤더 대신 애플리케이션 코드가 저장과 무효화를 직접 제어한다.

Cache Hit는 캐시가 저장된 응답을 바로 반환하는 상태다. Cache Miss는 저장된 응답이 없거나, 캐시 키가 다르거나, 정책상 사용할 수 없어 다음 계층으로 요청을 넘기는 상태다. Revalidation은 저장된 응답이 stale이지만 ETag 같은 검증자를 이용해 Origin에 “아직 같은가?”만 확인하는 상태다.

세 상태는 비용이 다르다.

상태Origin 요청본문 전송사용자 지연대표 신호
Fresh hit없음없음가장 낮음x-cache: Hit, Age 증가
Revalidation hit있음없음 또는 매우 작음RTT 1회304 Not Modified
Miss있음전체 본문가장 높음x-cache: Miss, Age: 0 또는 없음

작은 JSON 5KB라면 본문 절약보다 RTT 절약이 중요할 수 있다. 반대로 이미지 3MB라면 304를 받더라도 RTT는 들지만 본문 전송을 피하는 효과가 크다.

3-2. 수치 감각: hit rate가 Origin 부하를 얼마나 줄이는가

섹션 제목: “3-2. 수치 감각: hit rate가 Origin 부하를 얼마나 줄이는가”

캐시 hit rate는 전체 요청 중 캐시에서 처리된 비율이다.

총 요청: 10,000 req/min
CDN hit rate: 90%
Origin까지 도달하는 요청: 10,000 * (1 - 0.90) = 1,000 req/min

hit rate가 90%에서 95%로 오르면 단 5%p 개선처럼 보이지만 Origin 입장에서는 1,000 req/min이 500 req/min으로 줄어든다. 남은 미스 트래픽 기준으로는 50% 감소다.

총 요청hit rateOrigin 요청80% 대비 Origin 감소
10,000 req/min80%2,000 req/min기준
10,000 req/min90%1,000 req/min50% 감소
10,000 req/min95%500 req/min75% 감소
10,000 req/min99%100 req/min95% 감소

이 표가 중요한 이유는 캐시 최적화의 목표가 “브라우저가 빠르다”에만 있지 않기 때문이다. CDN hit rate가 높아지면 Origin 서버 수, DB 커넥션, 국제망 전송 비용, 배포 중 장애 영향 범위가 같이 줄어든다.

3-3. 반례: 모든 응답을 캐시하면 성능이 좋아지는가

섹션 제목: “3-3. 반례: 모든 응답을 캐시하면 성능이 좋아지는가”

아니다. 다음 응답은 캐시를 켤수록 위험하거나 효과가 작다.

  • 매 요청마다 사용자 권한에 따라 필드가 달라지는 /me, /cart, /payment-info
  • 검색어, 정렬, 필터, 쿠키 조합이 너무 많아 같은 응답이 거의 반복되지 않는 API
  • 가격, 재고, 좌석처럼 stale 응답이 실제 손해로 이어지는 데이터
  • Set-Cookie를 포함해 사용자 상태를 바꾸는 응답
  • 캐시 키에 포함되지 않은 헤더나 쿼리스트링이 응답을 바꾸는 엔드포인트

캐시는 “반복되는 같은 의미의 요청”이 있어야 효과가 난다. 반복성이 낮은 응답에 캐시를 얹으면 hit rate는 낮고, 디버깅 표면만 넓어진다.


4. Freshness: 언제까지 그대로 써도 되는가

섹션 제목: “4. Freshness: 언제까지 그대로 써도 되는가”

Freshness는 저장된 응답을 Origin에 묻지 않고 써도 되는 기간이다. Cache-Control은 freshness와 저장 범위를 지정하는 핵심 응답 헤더다.

디렉티브첫 정의주로 답하는 질문
max-age=N응답 생성 후 N초 동안 fresh로 간주얼마나 신선한가
s-maxage=NCDN 같은 shared cache에만 적용되는 freshness공유 캐시는 얼마나 신선한가
publicshared cache에 저장해도 됨누구와 공유할 수 있는가
private사용자 전용 private cache에만 저장 가능누구와 공유할 수 있는가
no-cache저장은 가능하지만 사용 전 매번 재검증 필요만료 뒤 어떻게 확인하는가
no-store저장 자체 금지저장해도 되는가
must-revalidatestale 응답을 임의로 제공하지 말고 반드시 재검증stale 사용 경계
immutablefresh 기간에는 내용이 바뀌지 않으므로 새로고침 재검증도 생략 가능불필요한 재검증 제거
stale-while-revalidate=N만료 후 N초 동안 stale을 즉시 주고 뒤에서 갱신만료 순간 지연 완화
stale-if-error=NOrigin 오류 때 N초 동안 stale 응답 허용장애 내성

Cache-Control은 조합 언어다. public, max-age=60, s-maxage=300, stale-while-revalidate=30은 브라우저에는 60초 freshness를 주고, CDN에는 300초 freshness와 30초 stale 배경 갱신을 허용한다.

freshness 판단은 보통 더 구체적인 신호가 우선한다. Cache-Control: max-ages-maxage가 있으면 그것을 먼저 보고, 없으면 Expires를 본다. 둘 다 없으면 브라우저나 중간 캐시가 Last-Modified 같은 힌트로 휴리스틱 freshness를 추정할 수 있다. shared cache에서는 Age 헤더도 같이 읽어야 한다. 예를 들어 Cache-Control: max-age=3600이고 Age: 2400이면, 캐시는 대략 1200초 더 fresh하다고 판단할 수 있다.

max-age는 모든 캐시에 적용되는 기본 freshness다. s-maxage는 shared cache 전용이고, CDN이나 프록시에서는 max-age보다 우선한다.

Cache-Control: public, max-age=60, s-maxage=600

이 응답을 받은 브라우저는 60초 동안 fresh로 본다. CDN은 600초 동안 fresh로 본다. 사용자가 새로고침하면 브라우저는 60초 뒤 CDN에 다시 확인하지만, CDN은 아직 600초 안이면 Origin까지 가지 않고 자기 캐시로 응답할 수 있다.

이 조합은 “사용자에게는 비교적 빨리 확인하게 하되, Origin은 CDN으로 보호하고 싶다”는 뜻이다. 공개 카탈로그, 공통 설정값, 이미지 메타데이터처럼 여러 사용자에게 같은 응답을 줄 수 있고 몇 분 stale이 허용되는 데이터에 잘 맞는다.

no-cache는 이름 때문에 가장 많이 헷갈린다. 캐시에 저장하지 말라가 아니라 저장해도 되지만 사용하기 전에 매번 서버에 확인하라는 뜻이다.

Cache-Control: no-cache
ETag: "product-list-v12"

브라우저는 응답을 저장한다. 다음 요청 때 If-None-Match: "product-list-v12"를 보내고, 서버가 304 Not Modified를 반환하면 저장된 본문을 사용한다.

no-store는 저장 자체를 금지한다.

Cache-Control: no-store

토큰, 결제 정보, 주민등록번호 같은 민감 데이터는 재검증을 잘하느냐가 문제가 아니다. 디스크나 shared cache에 남으면 안 되므로 no-store가 필요하다.

must-revalidate는 또 다른 경계다. no-cache가 “저장해도 되지만 사용 전 항상 재검증하라”는 뜻이라면, must-revalidate는 “fresh 기간이 지나 stale이 되면 임의로 재사용하지 말고 반드시 재검증하라”는 뜻이다. 네트워크 오류 때 stale 응답을 허용할지 말지는 stale-if-error 같은 지시어로 별도로 표현한다.

퀴즈

개인정보 응답에 no-cache만 붙이면 왜 부족한가?

힌트: no-cache는 저장 금지가 아니라 재검증 강제다.

정답 보기

no-cache는 저장을 허용하고 사용 전 재검증만 강제한다. 저장 자체를 막아야 하는 토큰, 결제, 개인 프로필 응답에는 no-store가 필요하다.

public은 CDN 같은 shared cache에도 저장해도 된다는 뜻이다. private은 브라우저 같은 사용자 전용 캐시에는 저장해도 되지만 shared cache에는 저장하지 말라는 뜻이다.

Cache-Control: private, max-age=60

이 응답은 한 사용자의 브라우저가 60초 동안 재사용할 수 있다. 하지만 CDN은 여러 사용자에게 같은 저장소를 쓰므로 저장하면 안 된다.

privateno-store보다 약하다. 브라우저 저장도 피해야 하는 민감 데이터는 private만으로 부족하다.

응답 성격적합한 지시어이유
로고, 해시가 붙은 JS/CSSpublic, max-age=31536000, immutableURL이 바뀌면 새 리소스이므로 장기 캐시 가능
HTML 진입점no-cacheHTML은 최신 JS/CSS URL을 가리켜야 함
공개 카테고리 목록public, max-age=60, s-maxage=300, stale-while-revalidate=30여러 사용자에게 같고 짧은 stale 허용
로그인 사용자 프로필private, no-storeshared cache 저장 금지, 브라우저 저장도 최소화
결제/토큰 응답no-store저장 자체 금지

Expires는 Origin이 의도한 절대 만료 시각을 HTTP-date로 표현한다.

Date: Tue, 01 Jan 2030 00:00:00 GMT
Expires: Tue, 01 Jan 2030 01:00:00 GMT

RFC 9111의 계산은 다음과 같다.

freshness_lifetime = Expires - Date = 3600초
response_is_fresh = freshness_lifetime > current_age

두 시각을 Origin 응답에서 함께 받아 차이를 계산하므로, 사용자 기기 시계가 빠르거나 느리다는 이유만으로 만료가 같은 폭으로 이동하지 않는다. 이 계산은 클럭 스큐를 줄이기 위한 표준 동작이다. 그럼에도 새 정책에서 max-age가 더 단순한 이유는 다음과 같다.

  • max-age=3600은 freshness lifetime을 정수 초로 직접 표현해 HTTP-date 생성·파싱과 GMT 표기 오류를 피한다.
  • Date 누락 시 수신 시각으로 대체하는 경계와 잘못된 날짜 형식을 디버깅할 일이 줄어든다.
  • Cache-Control: max-ageExpires가 함께 있으면 max-age가 우선하므로, 두 값을 주 정책처럼 관리하면 충돌 원인이 하나 더 생긴다.

호환성이 필요하면 Cache-Control을 주 정책으로 두고 Expires를 보조로 둔다.

Cache-Control: public, max-age=3600
Expires: Tue, 01 Jan 2030 01:00:00 GMT

최신 캐시는 max-age=3600을 따른다. Expires만 단독으로 새 설계에 사용하는 것은 피한다.

4-6. Worked example: 네 가지 응답의 헤더 설계

섹션 제목: “4-6. Worked example: 네 가지 응답의 헤더 설계”

예시 A - 해시가 붙은 정적 자산

GET /assets/app.a1b2c3.js
Cache-Control: public, max-age=31536000, immutable

파일명에 콘텐츠 해시가 있으므로 내용이 바뀌면 URL도 바뀐다. /assets/app.d4e5f6.js는 기존 캐시와 다른 리소스다. 그래서 1년 캐시가 안전하다.

예시 B - HTML 진입점

GET /
Cache-Control: no-cache
ETag: "html-v42"

HTML은 최신 JS/CSS 파일명을 참조해야 한다. HTML 자체를 1년 캐시하면 새 배포 뒤에도 브라우저가 예전 번들 URL을 계속 볼 수 있다. no-cache로 매번 재검증하게 두면 변경이 없을 때는 304로 가볍게 끝나고, 배포 뒤에는 새 HTML을 받는다.

예시 C - 공개 카탈로그 API

GET /api/categories
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=30, stale-if-error=86400
ETag: "categories-v18"

브라우저는 60초, CDN은 300초 fresh로 쓴다. 만료 직후 30초 동안은 stale을 즉시 주고 뒤에서 갱신할 수 있다. Origin 장애 때는 하루 동안 이전 응답을 임시로 제공할 수 있다. 카테고리 목록은 몇 분 stale이 허용되기 때문에 가능한 전략이다.

예시 D - 로그인 사용자 프로필

GET /api/me
Authorization: Bearer ...
Cache-Control: private, no-store

사용자별 응답이고 민감 데이터가 포함될 수 있다. CDN shared cache에 저장되면 안 되고, 브라우저 디스크에도 남기지 않는 편이 안전하다.

4-7. 반례: immutable을 붙이면 항상 좋은가

섹션 제목: “4-7. 반례: immutable을 붙이면 항상 좋은가”

immutable은 “fresh 기간 동안 이 URL의 내용은 절대 바뀌지 않는다”는 약속이다. 해시가 붙은 파일에는 잘 맞는다. 하지만 /assets/app.js처럼 URL이 고정되어 있고 내용이 배포 때 바뀌는 파일에 붙이면 장애가 된다.

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

위 헤더가 /app.js에 붙어 있고 파일명이 바뀌지 않으면, 사용자는 최대 1년 동안 예전 JS를 사용할 수 있다. 강력 새로고침이나 CDN invalidation으로도 브라우저 private cache까지 즉시 지우기 어렵다. immutable은 cache busting과 함께 쓸 때만 안전하다.


5. Validation: 만료된 캐시를 어떻게 확인하는가

섹션 제목: “5. Validation: 만료된 캐시를 어떻게 확인하는가”

Freshness가 끝난 응답을 stale이라고 한다. stale이라고 해서 즉시 버리는 것은 아니다. 서버가 검증자(validator)를 제공했다면 클라이언트는 조건부 요청(conditional request)으로 “내가 가진 버전이 아직 유효한가?”를 확인할 수 있다.

ETag(entity tag)는 리소스 버전을 나타내는 식별자다. 보통 콘텐츠 해시, 버전 번호, 빌드 ID로 만든다.

HTTP/1.1 200 OK
ETag: "products-v42"
Cache-Control: public, max-age=300

300초가 지나 stale이 되면 클라이언트는 다음 요청에 If-None-Match를 붙인다.

GET /api/products
If-None-Match: "products-v42"

서버의 현재 버전도 "products-v42"라면:

HTTP/1.1 304 Not Modified
ETag: "products-v42"

본문이 없다. 클라이언트는 저장해 둔 본문을 그대로 사용한다.

버전이 바뀌었다면:

HTTP/1.1 200 OK
ETag: "products-v43"
Cache-Control: public, max-age=300
{ ...새 본문... }
ETag 조건부 요청 흐름
flowchart TD
A["첫 요청"] --> B["200 OK + ETag v42 + 본문"]
B --> C["max-age 동안 fresh hit"]
C --> D["만료 후 If-None-Match v42"]
D --> E{"Origin의 현재 버전"}
E -->|"v42와 같음"| F["304 Not Modified - 본문 없음"]
E -->|"v43으로 변경"| G["200 OK + ETag v43 + 새 본문"]

강한 ETag(strong ETag)는 바이트 단위 내용이 같을 때만 같다고 본다.

ETag: "a1b2c3"

약한 ETag(weak ETag)는 의미상 같은 표현이면 같다고 볼 수 있음을 나타낸다.

ETag: W/"products-v42"

HTML 공백이나 JSON 필드 순서처럼 사용자 의미가 변하지 않는 차이를 약하게 처리하고 싶을 때 weak ETag를 쓸 수 있다. 하지만 byte range 요청이나 정확한 바이트 동일성이 중요한 파일 다운로드에는 strong ETag가 더 안전하다.

Last-Modified는 리소스가 마지막으로 수정된 시각이다.

Last-Modified: Wed, 01 Jan 2025 12:00:00 GMT

클라이언트는 다음 요청에 If-Modified-Since를 보낸다.

If-Modified-Since: Wed, 01 Jan 2025 12:00:00 GMT

서버가 그 이후 수정이 없다고 판단하면 304를 반환한다.

ETag와 비교하면 Last-Modified는 만들기 쉽지만 정밀도가 낮다.

기준ETagLast-Modified
비교 대상버전 식별자 또는 콘텐츠 해시수정 시각
정밀도구현에 따라 매우 높음보통 초 단위
장점내용 변경 감지에 강함계산이 쉽고 저장 메타데이터 활용 가능
약점해시 계산 비용, 분산 서버 간 일관성 필요같은 초에 두 번 바뀌면 놓칠 수 있음
우선순위둘 다 있으면 보통 ETag 우선ETag가 없을 때 폴백

5-4. 수치 예시: 304는 무엇을 절약하는가

섹션 제목: “5-4. 수치 예시: 304는 무엇을 절약하는가”

3MB 이미지가 있고 사용자의 RTT가 120ms라고 하자.

방식Origin 왕복본문 전송대략적인 효과
Fresh cache hit없음0가장 빠름
304 revalidation1 RTT헤더만3MB 전송 절약, RTT는 남음
200 full response1 RTT3MB가장 비쌈

304는 “네트워크 왕복을 없애는 기술”이 아니라 “본문 재전송을 없애는 기술”이다. RTT까지 없애려면 fresh 기간 안에서 hit가 나야 한다. 그래서 정적 해시 자산은 긴 max-ageimmutable을 쓰고, HTML처럼 항상 확인이 필요한 응답은 no-cache와 ETag를 조합한다.

  • 캐시 만료 뒤 항상 200 OK와 전체 본문이 내려온다. ETag가 없거나, 서버가 If-None-Match를 처리하지 않거나, CDN이 조건부 요청 헤더를 Origin에 전달하지 않는 상태일 수 있다.

  • 서로 다른 Origin 인스턴스가 같은 본문에 다른 ETag를 만든다. 배포 ID, gzip 여부, 인스턴스 로컬 시간 같은 비결정적 입력이 ETag에 섞였을 수 있다. CDN은 계속 변경된 것으로 보고 revalidation 이득을 잃는다.

  • Last-Modified가 현재보다 미래 시각이다. 서버 시계가 틀렸거나 빌드 파이프라인이 잘못된 메타데이터를 넣었다. 휴리스틱 캐시와 조건부 요청이 모두 흔들린다.

  • 304 응답에 캐시 관련 헤더가 빠진다. 304도 캐시 메타데이터를 갱신하는 응답이다. Cache-Control, ETag, Date 같은 헤더가 일관되게 유지되어야 한다.


6. Cache Key와 Vary: 무엇을 같은 응답으로 볼 것인가

섹션 제목: “6. Cache Key와 Vary: 무엇을 같은 응답으로 볼 것인가”

캐시 키(cache key)는 캐시가 “이 요청과 저 요청은 같은 저장 항목을 써도 된다”고 판단할 때 쓰는 식별자다. 기본적으로는 메서드, scheme, host, path, query string이 핵심이다.

GET https://api.example.com/products?page=1

이 요청의 기본 키는 대략 다음 요소로 만들어진다.

method=GET
scheme=https
host=api.example.com
path=/products
query=page=1

하지만 URL이 같아도 응답이 달라지는 경우가 있다. 예를 들어 같은 /products라도 Accept-Language: ko면 한국어 이름, Accept-Language: en이면 영어 이름을 내려줄 수 있다. 이때 Vary가 필요하다.

Vary: Accept-Encoding

이 헤더는 “응답이 Accept-Encoding 요청 헤더에 따라 달라지므로, 캐시 키에 그 값을 포함하라”는 뜻이다.

GET /app.js
Accept-Encoding: br
→ key: /app.js + br
GET /app.js
Accept-Encoding: gzip
→ key: /app.js + gzip

브라우저나 CDN은 Brotli 버전과 gzip 버전을 별도 항목으로 저장한다. 그래야 gzip만 이해하는 클라이언트에게 Brotli 본문을 주는 사고를 피할 수 있다.

다음 API가 요청 언어에 따라 다른 상품명을 내려준다고 하자.

GET /api/products
Accept-Language: ko
→ { "name": "노트북" }
GET /api/products
Accept-Language: en
→ { "name": "Laptop" }

Vary: Accept-Language가 없으면 URL만 보고 같은 캐시 항목으로 취급될 수 있다. 첫 요청이 한국어였다면 영어 사용자에게도 "노트북"이 내려갈 수 있다.

Cache-Control: public, max-age=300
Vary: Accept-Language, Accept-Encoding

이제 언어와 압축 방식별로 캐시 항목이 분리된다.

6-3. 반례: Vary를 많이 붙이면 안전한가

섹션 제목: “6-3. 반례: Vary를 많이 붙이면 안전한가”

아니다. Vary는 응답 혼선을 막지만 캐시 공간을 쪼갠다. 조합이 많아질수록 hit rate가 떨어진다.

Vary: Accept-Encoding, Accept-Language, User-Agent

언어 10개, 압축 3개, User-Agent 변형 1,000개라면 이론상 30,000개 조합이 생긴다. 대부분의 조합은 한두 번만 요청되고, CDN은 계속 miss를 낸다.

헤더캐시 키 포함 적합도이유
Accept-Encoding높음값의 종류가 적고 압축 본문 혼선을 막아야 함
Accept-Language주의언어 수가 많으면 hit rate가 낮아짐
User-Agent낮음값 종류가 너무 많아 캐시 파편화
Authorization매우 주의사용자별 키가 되어 shared cache 이점이 사라지거나 누출 위험
Cookie매우 주의추적 쿠키까지 키에 들어가면 거의 매 요청 miss

언어처럼 사용자에게 보이는 차이가 중요한 경우에는 /ko/products, /en/products처럼 URL에 변형을 드러내는 방식이 더 단순할 때가 많다. URL이 곧 캐시 키가 되므로 Vary 조합 폭발을 줄일 수 있다.

6-4. CDN Cache Policy와 Origin Request Policy

섹션 제목: “6-4. CDN Cache Policy와 Origin Request Policy”

CDN에는 두 질문이 있다.

  1. 캐시 키에 무엇을 넣을 것인가?
  2. Origin에는 무엇을 전달할 것인가?

이 둘은 같지 않다. 어떤 헤더는 Origin 로직에는 필요하지만 캐시 키에 넣으면 hit rate를 망친다.

Cache Policy:
key = host + path + normalized query + Accept-Encoding
Origin Request Policy:
forward = Authorization, X-Request-Id, selected query strings

공개 API에서 X-Request-Id는 로그 추적에는 필요하지만 응답 내용을 바꾸지 않는다. 캐시 키에 넣으면 요청마다 값이 달라져 모든 요청이 miss가 된다. 반대로 Accept-Encoding은 응답 본문 형식을 바꾸므로 키에 들어가야 한다.

판단 기준: 어떤 요청 요소를 캐시 키에 넣을지는 “이 값이 달라질 때 응답 본문 또는 의미가 달라지는가?”로 결정한다. 로그, 추적, 실험용 헤더처럼 응답 의미를 바꾸지 않는 값은 Origin에 전달하더라도 키에서는 빼는 편이 좋다.

  • x-cache: Miss가 반복되고 Age가 거의 증가하지 않는다. 쿠키, 쿼리스트링, 헤더가 과도하게 캐시 키에 포함되어 요청마다 다른 키가 되었을 수 있다.

  • 특정 언어나 압축 형식이 다른 사용자에게 섞여 보인다. 응답을 바꾸는 요청 헤더가 키에 빠졌거나 Vary를 CDN이 정책으로 반영하지 않는 상태일 수 있다.

  • 인증된 요청이 CDN에서 hit 난다. 공개 응답이라면 의도일 수 있지만, 사용자별 응답이라면 즉시 보안 사고로 다뤄야 한다.

  • 같은 URL인데 쿼리 순서만 바뀌어 miss가 난다. ?a=1&b=2?b=2&a=1을 서로 다른 키로 보는 설정일 수 있다. 쿼리 정규화가 필요할 수 있다.


HTTP 캐시의 보안 문제는 대부분 shared cache에서 생긴다. 브라우저 private cache는 한 사용자만 보지만, CDN shared cache는 여러 사용자에게 같은 저장소를 제공한다.

구분private cacheshared cache
대표브라우저 캐시CDN, 회사 프록시, 리버스 프록시
사용자 범위한 사용자여러 사용자
private 응답저장 가능저장 금지
public 응답저장 가능저장 가능
주요 위험로컬 기기에 민감 정보 잔존다른 사용자에게 응답 누출

사용자별 응답은 기본적으로 shared cache에 저장하면 안 된다. Authorization이나 Cookie가 붙었다는 사실만으로 항상 사용자별이라는 뜻은 아니지만, 안전한 기본값은 private, no-store 또는 명시적 캐시 비활성화다.

개인화 응답 누출은 shared cache가 사용자 A의 응답을 저장한 뒤 사용자 B에게 같은 URL의 hit로 반환할 때 발생한다.

1. A가 GET /api/me 요청
Authorization: Bearer token-a
2. Origin 응답
Cache-Control: public, max-age=300
{ "email": "a@example.com" }
3. CDN이 /api/me를 public 응답으로 저장
4. B가 GET /api/me 요청
Authorization: Bearer token-b
5. CDN이 key가 같다고 판단해 A의 본문 반환

상태 코드는 200 OK라서 모니터링이 조용할 수 있다. 사용자가 직접 신고하기 전까지 발견되지 않는 것이 위험하다.

예방 기준

  • 인증이 필요한 엔드포인트는 기본값을 Cache-Control: private, no-store로 둔다.
  • 공개 가능한 인증 응답이라면 사용자별 필드가 절대 없는지 확인하고, CDN 정책에서 Authorization 처리를 명시한다.
  • Set-Cookie가 있는 응답은 shared cache 저장을 피한다.
  • URL이 같아도 사용자 권한에 따라 필드가 달라지면 public cache 대상이 아니다.

7-3. Cache poisoning과 cache key poisoning

섹션 제목: “7-3. Cache poisoning과 cache key poisoning”

캐시 포이즈닝(cache poisoning)은 공격자가 오염된 응답을 shared cache에 저장시킨 뒤 다른 사용자에게 전달되게 만드는 공격이다. 핵심 패턴은 캐시 키에는 포함되지 않지만 응답에는 영향을 주는 입력이다.

정상 요청:
GET /page
Host: example.com
→ cache key: /page + example.com
→ response: <script src="https://example.com/app.js">
공격 요청:
GET /page
Host: example.com
X-Forwarded-Host: attacker.example
→ cache key: /page + example.com
→ response: <script src="https://attacker.example/app.js">

Origin이 X-Forwarded-Host를 믿고 절대 URL을 만들지만 CDN 캐시 키에는 그 헤더가 없다면, 공격자의 응답이 정상 키에 저장될 수 있다. 이후 일반 사용자는 같은 /page를 요청했을 뿐인데 오염된 스크립트를 받는다.

cache key poisoning은 캐시 키 자체를 조작해 의도하지 않은 항목을 만들거나 덮어쓰게 하는 더 넓은 문제로 볼 수 있다. 쿼리 정규화, 경로 정규화, 헤더 allowlist가 엉키면 발생한다.

웹 보안 문서의 신뢰 경계 질문을 캐시에 적용하면 다음과 같다.

  1. 이 응답은 사용자별인가, 공개인가? 사용자별이면 shared cache 저장을 금지한다.

  2. 응답 본문을 바꾸는 입력은 캐시 키에 모두 들어갔는가? 언어, 압축, A/B 실험, 권한, 쿼리 필터가 빠지면 혼선이 난다.

  3. 캐시 키에는 없지만 Origin이 신뢰하는 헤더가 있는가? X-Forwarded-Host, X-Original-URL, Forwarded 같은 헤더를 절대 URL 생성이나 라우팅에 쓰는지 본다.

  4. 캐시된 stale 응답이 보안 결정을 포함하는가? 권한, 결제 가능 여부, 재고 차감 같은 결정은 stale 허용 범위를 매우 좁게 잡아야 한다.

  5. 장애 때 stale을 주는 것이 안전한가? 문서, 이미지, 공개 카탈로그는 가능할 수 있다. 결제 승인, 좌석 예약, 계정 상태는 위험하다.

  • Origin에서 절대 URL을 만들 때 요청 헤더를 무조건 믿지 말고 설정된 canonical base URL을 사용한다.
  • CDN Origin Request Policy는 필요한 헤더만 allowlist로 전달한다.
  • Cache Policy는 응답 의미를 바꾸는 입력만 키에 포함한다.
  • 인증 응답에는 기본적으로 private, no-store를 붙인다.
  • HTML에 사용자별 조각을 서버에서 섞어 넣는다면 public cache 대상에서 제외하거나 조각 캐싱 전략을 별도로 설계한다.

8. Invalidation: 캐시를 어떻게 바꿀 것인가

섹션 제목: “8. Invalidation: 캐시를 어떻게 바꿀 것인가”

캐시의 어려움은 저장보다 무효화(invalidation)에 있다. TTL은 “이 시간 동안은 예전 응답을 써도 된다”는 약속이다. 이 약속을 너무 길게 잡으면 배포와 데이터 변경이 늦게 보인다. 너무 짧게 잡으면 hit rate가 낮아져 Origin을 보호하지 못한다.

Cache busting은 URL에 버전이나 해시를 넣어 내용이 바뀌면 URL도 바뀌게 하는 전략이다.

이전 배포: /assets/app.a1b2c3.js
새 배포: /assets/app.d4e5f6.js

캐시는 URL이 다르면 다른 리소스로 본다. 그래서 이전 파일을 지우지 않아도 새 HTML이 새 URL을 참조하면 브라우저는 새 파일을 요청한다.

이 방식은 정적 JS/CSS/이미지에 가장 강하다.

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

단, HTML 진입점까지 장기 캐시하면 안 된다. HTML이 새 해시 파일명을 알려주는 역할을 하기 때문이다.

GET /
Cache-Control: no-cache

CDN invalidation은 CDN 저장소에서 특정 경로를 강제로 제거하는 작업이다. URL을 바꾸기 어려운 동적 페이지나 API 응답에 쓴다.

Terminal window
aws cloudfront create-invalidation \
--distribution-id E1234ABCD5678 \
--paths "/api/products" "/api/categories/*"

이 명령은 실전에서 필요하지만 본문 이해의 핵심은 명령어가 아니라 성질이다.

  • 전파에 시간이 걸린다. “요청했다”와 “모든 엣지에서 사라졌다”는 다르다.
  • 와일드카드 범위를 잘못 잡으면 일부 변형이 남는다.
  • 너무 자주 쓰면 비용과 운영 복잡도가 커진다.
  • 브라우저 private cache는 CDN invalidation으로 직접 지워지지 않는다.
상황우선 전략이유
파일 내용 변경 때 URL 변경 가능cache busting가장 안정적이고 브라우저 캐시까지 해결
URL 고정 공개 응답, 변경 드묾짧은 TTL + 필요 시 invalidation동적 응답은 URL 버전 관리가 어려움
가격/재고처럼 정확성 중요짧은 TTL 또는 no-cachestale 허용 비용이 큼
공지/문서처럼 약간 stale 허용긴 TTL + stale-if-error장애 내성과 비용 절감
사용자별 응답no-store/privateinvalidation보다 저장 금지가 우선

8-4. Worked example: 배포 후 오래된 JS

섹션 제목: “8-4. Worked example: 배포 후 오래된 JS”

문제 상황:

/assets/app.js
Cache-Control: public, max-age=86400

파일명이 고정되어 있고 하루 동안 fresh다. 새 배포로 내용이 바뀌어도 브라우저와 CDN은 같은 URL의 기존 파일을 fresh로 본다. 사용자는 하루 동안 예전 JS를 실행할 수 있다.

근본 해결:

/assets/app.a1b2c3.js
Cache-Control: public, max-age=31536000, immutable
/
Cache-Control: no-cache

HTML은 매번 재검증해 새 해시 파일명을 알게 하고, 해시 파일은 장기 캐시한다. 이 조합은 CDN invalidation을 배포의 기본 수단으로 만들지 않는다.

8-5. 반례: /* invalidation을 매 배포마다 실행하면 되는가

섹션 제목: “8-5. 반례: /* invalidation을 매 배포마다 실행하면 되는가”

가능은 하지만 좋은 기본값은 아니다.

  • 정적 자산이 많을수록 invalidation 전파와 비용이 커진다.
  • 브라우저 private cache는 여전히 남을 수 있다.
  • 매 배포마다 전체 캐시를 비우면 배포 직후 Origin 트래픽이 급증한다.
  • cache busting으로 해결할 문제를 운영 절차로 해결하게 된다.

전체 invalidation은 사고 대응이나 구조 전환 때의 임시 수단에 가깝다. 정상 배포 경로는 URL 버전 관리와 적절한 HTML 재검증으로 설계하는 편이 안정적이다.


stale은 fresh lifetime이 끝난 응답이다. 일반적으로는 재검증하거나 새로 가져와야 하지만, 일부 응답은 stale을 잠깐 제공하는 것이 더 낫다. 이때 쓰는 지시어가 stale-while-revalidatestale-if-error다.

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

이 응답은 다음 시간대를 가진다.

0~60초: fresh. 캐시가 즉시 응답한다.
61~360초: stale 허용 구간. 캐시가 이전 응답을 즉시 주고 뒤에서 갱신한다.
361초 이후: stale 허용도 끝남. Origin에서 새 응답을 받아야 한다.

핵심은 사용자 지연을 줄이는 것이다. 만료 직후 첫 사용자가 Origin 응답을 기다리는 대신, 캐시는 잠깐 오래된 응답을 주고 백그라운드에서 새 버전을 가져온다.

Cache-Control: public, max-age=60, stale-if-error=86400

Origin이 500, 502, 503, 504 같은 오류를 내는 동안 stale 응답을 임시로 제공할 수 있다. 문서, 이미지, 공개 카탈로그에는 장애 완화 효과가 있다. 하지만 결제 승인, 계정 정지 여부, 재고 차감처럼 최신성이 안전성인 응답에는 위험하다.

캐시 스탬피드(cache stampede, thundering herd)는 인기 있는 캐시 항목이 만료되는 순간 많은 요청이 동시에 Origin으로 몰리는 현상이다.

시나리오:
/api/products
max-age=3600
평소 요청량 500 req/s
00:00~01:00:
CDN fresh hit. Origin 요청 거의 0.
01:00:01:
캐시 만료.
동시에 들어온 500개 요청이 모두 miss.
Origin과 DB가 한꺼번에 500개 재생성 요청을 받음.

캐시는 평균 부하를 줄이지만, 만료 시점이 정렬되면 순간 부하를 키울 수 있다. 특히 여러 인기 키가 같은 TTL로 동시에 생성되면 만료도 동시에 온다.

패턴적용 계층원리적합한 상황
stale-while-revalidateHTTP 캐시stale을 즉시 주고 갱신을 뒤로 미룸공개 응답, 약간 stale 허용
collapsed forwarding / single-flightCDN 또는 프록시같은 miss 요청을 하나로 합침동일 키 동시 miss
TTL jitter모든 캐시TTL에 랜덤 편차를 줘 만료 시점 분산많은 키가 동시에 생성될 때
early refresh앱/프록시만료 전 일부 요청이 미리 갱신갱신 비용이 크고 예측 가능할 때
mutex lock앱/Redis한 요청만 재생성하고 나머지는 대기 또는 stale 사용DB 조회 비용이 매우 클 때

HTTP 캐시 문서에서 XFetch 같은 확률적 조기 만료 알고리즘을 깊게 구현할 필요는 없다. 하지만 원리는 알아두면 좋다. 만료 직전에 모든 요청이 기다리지 않도록 일부 요청만 갱신 책임을 지게 하는 것이다.

인기 API 1,000개가 모두 max-age=300으로 같은 시각에 캐시되면 5분마다 만료 파도가 온다.

TTL에 ±10% jitter를 주면 각 키의 만료는 270~330초 사이로 흩어진다.

기존: 300초에 1,000개 키 동시 만료
jitter: 270~330초 60초 구간에 분산
대략 초당 16~17개 키 만료

HTTP 표준 헤더만으로 모든 CDN에서 jitter를 직접 표현하기는 어렵다. 하지만 Origin이 응답별 max-age를 약간 다르게 주거나, 애플리케이션 캐시와 함께 쓸 때 유용한 사고방식이다.

stale 전략은 장애 내성을 주지만, 데이터 정확성을 희생한다.

응답stale 허용이유
도움말 문서높음몇 분 또는 몇 시간 늦어도 안전
공개 카테고리중간약간 늦어도 대개 허용
검색 결과중간정렬/필터 정확성 요구에 따라 다름
가격낮음잘못된 가격 노출 위험
재고/좌석낮음oversell 위험
결제/권한거의 없음stale이 보안/금전 사고로 이어짐

stale-if-error를 붙일 때는 “Origin이 죽었을 때 오래된 응답을 보여주는 것이 500보다 나은가?”를 묻는다. 답이 아니면 stale 정책을 쓰지 않는다.


10. 브라우저 캐시와 CDN 캐시는 어떻게 다른가

섹션 제목: “10. 브라우저 캐시와 CDN 캐시는 어떻게 다른가”

브라우저와 CDN은 같은 HTTP 헤더를 읽지만 목표가 다르다. 브라우저는 한 사용자 경험을 빠르게 만들고, CDN은 여러 사용자 요청을 Origin에서 떼어낸다.

브라우저 캐시와 CDN 캐시의 차이

브라우저 캐시

사용자 기기에 저장되는 private cache다. 뒤로가기, 새로고침, 같은 사용자의 반복 방문을 빠르게 한다.

HTML, 이미지, JS/CSS, 개인 사용자의 캐시 동작을 볼 때

CDN 캐시

엣지 서버에 저장되는 shared cache다. 여러 사용자가 같은 응답을 공유해 Origin 부하와 국제망 지연을 줄인다.

CloudFront x-cache, Age, cache policy, Origin 부하를 볼 때

리버스 프록시 캐시

서비스 앞단에서 Origin 애플리케이션을 보호하는 캐시다. Nginx, Varnish 같은 계층이 담당한다.

내부 네트워크에서 API/정적 파일을 별도로 캐시할 때
Cache-Control: public, max-age=60, s-maxage=600

브라우저는 60초 후 stale로 본다. CDN은 600초 후 stale로 본다. 그래서 사용자 관점에서는 “새로고침했는데 CDN에서 여전히 old response가 온다”처럼 보일 수 있다.

Cache-Control: private, max-age=60

브라우저는 저장할 수 있다. CDN은 저장하면 안 된다. CloudFront 같은 CDN에서 hit가 난다면 정책이 헤더를 무시하고 있거나, Origin이 다른 헤더를 보내고 있는지 확인해야 한다.

10-2. CloudFront에서 자주 보는 관찰 신호

섹션 제목: “10-2. CloudFront에서 자주 보는 관찰 신호”
헤더의미읽는 법
x-cache: Hit from cloudfront엣지 캐시 hitOrigin까지 가지 않았을 가능성이 높음
x-cache: Miss from cloudfront엣지 캐시 missOrigin으로 요청이 갔거나 저장 조건이 안 맞음
Age: 123shared cache에 저장된 뒤 지난 초max-age와 비교해 fresh/stale 판단
x-amz-cf-pop: ICN...응답한 엣지 위치지역별 캐시 편차를 볼 때 도움
cache-controlOrigin 또는 CDN이 전달한 정책의도한 지시어가 실제로 도달했는지 확인

CloudFront가 항상 Origin 헤더만 그대로 따르는 것은 아니다. Cache Policy의 min/default/max TTL, 캐시 키 설정, Origin Request Policy가 함께 결과를 만든다. 그래서 “Origin은 max-age=300을 보냈다”만으로 충분하지 않고, CDN 정책이 그 헤더를 사용하도록 되어 있는지도 봐야 한다.

Cache-ControlExpires도 없으면 브라우저는 휴리스틱으로 캐시할 수 있다. 대표적인 추정은 DateLast-Modified 차이를 이용해 freshness를 계산하는 방식이다.

Last-Modified가 30일 전
휴리스틱 freshness가 10%라면
30일 * 0.1 = 3일

개발자가 “캐시 헤더를 안 줬으니 캐시 안 되겠지”라고 생각했는데, 브라우저가 며칠 동안 응답을 재사용할 수 있다. API 응답에는 캐시할지 말지를 명시적으로 써야 한다.

민감 API: Cache-Control: no-store
항상 확인할 API: Cache-Control: no-cache
짧게 캐시할 공개 API: Cache-Control: public, max-age=60

HTTP 캐시 전략은 엔드포인트의 의미에서 출발한다. 다음 표는 첫 설계의 기본값으로 쓸 수 있다.

응답 유형권장 헤더왜 이렇게 하는가실패하면 보이는 신호
해시 정적 자산public, max-age=31536000, immutableURL 변경이 무효화 역할해시 없는 파일에 붙이면 오래된 JS
HTML shellno-cache + ETag최신 자산 URL을 받아야 함배포 후 예전 bundle 참조
공개 문서/이미지public, max-age=3600, stale-if-error=86400stale이 500보다 나을 수 있음수정 반영 지연
공개 목록 APIpublic, max-age=60, s-maxage=300, stale-while-revalidate=30Origin 보호와 최신성 균형stale 허용 범위 초과
검색 API짧은 max-age 또는 no-cache쿼리 조합이 많아 hit rate 낮음CDN miss 반복
사용자 프로필private, no-store사용자별·민감 데이터다른 사용자 데이터 누출
결제/권한 결정no-storestale 허용 불가오래된 권한 또는 결제 상태
CORS preflightAccess-Control-Max-AgeOPTIONS 왕복 감소preflight가 실제 요청만큼 많음
Cache-Control 전략 결정
flowchart TD
A["응답을 저장해도 되는가?"] --> B{"민감 정보나 토큰인가?"}
B -->|"예"| NoStore["no-store"]
B -->|"아니오"| C{"사용자별로 다른가?"}
C -->|"예"| Private["private 또는 private, no-store"]
C -->|"아니오"| D{"URL이 내용 버전을 포함하는가?"}
D -->|"예"| Immutable["public, max-age=31536000, immutable"]
D -->|"아니오"| E{"몇 초 stale이 허용되는가?"}
E -->|"허용 안 됨"| Revalidate["no-cache 또는 짧은 max-age"]
E -->|"허용됨"| Shared["public, max-age=N, s-maxage=M, stale-*"]

11-2. API에서 public cache를 켜기 전 질문

섹션 제목: “11-2. API에서 public cache를 켜기 전 질문”
  • 이 응답은 로그인하지 않은 사용자에게도 같은가?
  • 로그인했더라도 응답 본문이 사용자 권한, 지역, 쿠키에 따라 달라지지 않는가?
  • 쿼리스트링과 헤더 중 응답을 바꾸는 값이 캐시 키에 포함되는가?
  • stale 응답이 몇 초까지 허용되는가?
  • Origin이 장애일 때 stale을 주는 것이 500보다 나은가?
  • 삭제, 수정, 재고 변경 같은 write 이후 무효화 경로가 있는가?
  • hit rate가 낮을 때 캐시 계층이 오히려 지연만 추가하지 않는가?
요구Vary가 적합URL 분리가 적합
gzip/br 압축 차이아니오
2~3개 콘텐츠 타입가능가능
많은 언어제한적대체로 예
모바일/데스크톱 별도 HTML주의명시 URL 또는 responsive 설계 검토
사용자 권한별 필드아니오URL 분리보다 private/no-store 우선

URL 분리는 캐시 키를 명확하게 만든다. Vary는 프로토콜적으로 맞지만 조합 폭발을 만들 수 있다. 둘 중 하나를 고를 때는 hit rate와 응답 혼선 위험을 같이 본다.


이 절은 긴 복구 런북이 아니라 증상이 왜 생기는지에 집중한다. 명령어는 13절 선택 부록에 짧게 모았다.

12-1. 배포 후 변경 사항이 반영되지 않는다

섹션 제목: “12-1. 배포 후 변경 사항이 반영되지 않는다”

증상

  • 새 배포 뒤 일부 사용자가 예전 JS/CSS를 계속 실행한다.
  • 강력 새로고침을 하면 고쳐진다.
  • CDN invalidation을 했는데도 특정 사용자는 계속 예전 파일을 본다.

왜 생기는가

URL이 그대로인데 장기 max-age가 붙어 있으면 브라우저는 그 URL의 기존 응답을 fresh로 본다. CDN invalidation은 CDN 저장소를 지울 수 있지만 이미 사용자의 브라우저 private cache에 fresh로 저장된 파일까지 지우지는 못한다.

학습 포인트

배포 무효화는 URL 설계가 1차 방어다. 해시 파일명과 HTML no-cache 조합이 없으면 운영 명령으로 브라우저 캐시 문제를 따라잡아야 한다.

12-2. CloudFront에서 항상 miss가 난다

섹션 제목: “12-2. CloudFront에서 항상 miss가 난다”

증상

  • 같은 URL을 반복 요청해도 x-cache: Miss from cloudfront가 계속 보인다.
  • Age가 없거나 항상 0에 가깝다.
  • Origin 요청 수가 기대만큼 줄지 않는다.

왜 생기는가

Origin이 no-storeno-cache를 보내고 있을 수 있다. 또는 Cache Policy가 쿠키, 전체 쿼리스트링, Authorization, 추적 헤더를 캐시 키에 포함해 요청마다 다른 키를 만들 수 있다. Set-Cookie가 있는 응답을 CDN이 저장하지 않도록 처리하는 경우도 있다.

학습 포인트

miss 반복은 “캐시가 없다”가 아니라 “같은 키로 재사용 가능한 응답이 없다”는 뜻이다. Origin 헤더와 CDN cache key를 함께 봐야 한다.

12-3. 304가 오지 않고 매번 200이 온다

섹션 제목: “12-3. 304가 오지 않고 매번 200이 온다”

증상

  • ETag가 있는 것 같은데 조건부 요청 후에도 항상 전체 본문을 받는다.
  • 네트워크 전송량이 줄지 않는다.

왜 생기는가

서버가 If-None-Match를 처리하지 않거나, CDN/프록시가 조건부 요청 헤더를 Origin에 전달하지 않거나, Origin 인스턴스마다 ETag 생성 방식이 달라질 수 있다. gzip 여부에 따라 ETag가 달라지는 설정도 흔한 원인이다.

학습 포인트

ETag는 헤더를 붙이는 것만으로 끝나지 않는다. 다음 요청에서 비교하고, 같으면 304로 응답하는 서버 로직 또는 정적 파일 서버 동작이 필요하다.

12-4. 다른 사용자 데이터가 보인다

섹션 제목: “12-4. 다른 사용자 데이터가 보인다”

증상

  • /api/me, /profile, /cart 같은 URL에서 다른 사용자의 정보가 보인다.
  • 상태 코드는 200이고 서버 오류 로그는 없다.
  • CDN hit가 보인다.

왜 생기는가

사용자별 응답이 public으로 저장되었거나, CDN이 Authorization/Cookie를 고려하지 않고 같은 URL을 같은 키로 처리했다. private response leakage는 5xx가 아니라 정상 200으로 나타나기 때문에 더 위험하다.

학습 포인트

사용자별 응답은 성능 최적화 대상이기 전에 보안 경계다. public cache를 켜려면 “누가 봐도 같은 응답”이라는 증거가 있어야 한다.

12-5. 언어나 압축 응답이 섞인다

섹션 제목: “12-5. 언어나 압축 응답이 섞인다”

증상

  • 영어 브라우저에서 한국어 응답이 보인다.
  • Brotli를 지원하지 않는 클라이언트가 압축을 풀지 못한다.
  • 특정 CDN에서는 정상인데 다른 CDN에서는 섞인다.

왜 생기는가

응답을 바꾸는 요청 헤더가 캐시 키에 반영되지 않았다. Vary가 빠졌거나, CDN Cache Policy가 해당 헤더를 키에 포함하지 않는다.

학습 포인트

Vary는 브라우저에게만 쓰는 주석이 아니다. shared cache가 실제로 그 값을 키에 포함하도록 CDN 정책까지 맞아야 한다.

12-6. 캐시 hit rate는 높은데 DB가 순간적으로 터진다

섹션 제목: “12-6. 캐시 hit rate는 높은데 DB가 순간적으로 터진다”

증상

  • 평소에는 Origin 부하가 낮다.
  • 일정 주기마다 DB 커넥션, CPU, p95 latency가 튄다.
  • 인기 API TTL과 스파이크 주기가 일치한다.

왜 생기는가

많은 요청이 같은 TTL 만료 시점에 동시에 miss를 경험한다. 캐시가 평균 요청을 줄였지만, 만료 순간을 정렬해 스탬피드를 만들었다.

학습 포인트

hit rate 평균만 보면 안 된다. 만료 시점의 동시성, stale 정책, single-flight, TTL jitter까지 봐야 한다.

12-7. 오염된 HTML이나 스크립트가 캐시된다

섹션 제목: “12-7. 오염된 HTML이나 스크립트가 캐시된다”

증상

  • 특정 헤더나 쿼리를 넣은 뒤 CDN hit 응답이 바뀐다.
  • 일반 사용자에게도 공격자가 만든 절대 URL이나 스크립트 출처가 보인다.
  • Origin 로그에는 공격 요청이 한 번뿐인데 피해는 여러 사용자에게 퍼진다.

왜 생기는가

캐시 키에 없는 입력이 Origin 응답에 반영되었다. shared cache는 정상 키의 응답이라고 믿고 저장한다.

학습 포인트

캐시 보안은 입력 검증과 키 설계가 만나는 지점이다. Origin이 신뢰하는 입력과 CDN이 구분하는 입력 목록이 달라지면 포이즈닝 표면이 생긴다.


13. 선택 부록: 짧은 진단 스니펫

섹션 제목: “13. 선택 부록: 짧은 진단 스니펫”

아래 명령은 본문 개념을 확인하기 위한 최소 관찰용이다. 이 절을 펼치지 않아도 앞의 개념 판단은 이해되어야 한다.

캐시 헤더와 CDN hit 확인
Terminal window
curl -I https://example.com/path

확인할 헤더:

cache-control: public, max-age=300
etag: "v42"
age: 123
x-cache: Hit from cloudfront
x-amz-cf-pop: ICN...

해석:

  • cache-control이 의도한 정책인지 본다.
  • Age가 증가하면 shared cache에 저장되어 재사용 중이다.
  • 반복 요청에도 Miss면 cache key 또는 저장 조건을 의심한다.
ETag 조건부 요청 확인
Terminal window
curl -I https://example.com/resource
curl -I https://example.com/resource \
-H 'If-None-Match: "위 응답의 ETag 값"'

기대:

HTTP/2 304

계속 200 OK라면 서버의 조건부 요청 처리, 프록시의 If-None-Match 전달, ETag 생성 일관성을 확인한다.

개인화 응답 캐시 위험 확인
Terminal window
curl -I https://api.example.com/me \
-H "Authorization: Bearer TOKEN"

안전한 기본 신호:

cache-control: private, no-store

위 응답에서 public, 긴 s-maxage, CDN Hit가 보이면 사용자별 응답이 shared cache에 저장되는지 즉시 확인한다.

CloudFront invalidation 최소 예시
Terminal window
aws cloudfront create-invalidation \
--distribution-id E1234ABCD5678 \
--paths "/api/products*"

이 명령은 CDN 엣지 캐시 삭제 요청이다. 브라우저 private cache를 직접 지우지 못하며, 전파 지연이 있을 수 있다. 배포의 기본 전략으로 반복하기보다 URL 버전 관리와 TTL 설계를 먼저 점검한다.

Vary 혼선 확인
Terminal window
curl -I https://example.com/api/products \
-H "Accept-Language: ko"
curl -I https://example.com/api/products \
-H "Accept-Language: en"

응답 본문이 언어별로 다르다면 다음 중 하나가 필요하다.

Vary: Accept-Language

또는:

/ko/api/products
/en/api/products

언어 수가 많고 CDN hit rate가 중요하면 URL 분리가 더 단순할 수 있다.

캐시 포이즈닝 간이 관찰
Terminal window
curl -s https://example.com/page \
-H "X-Forwarded-Host: attacker.example"

응답 본문에 attacker.example이 반영된다면 그 헤더가 Origin 출력에 영향을 주는 것이다. 이 응답이 같은 캐시 키로 저장될 수 있는지 CDN policy를 함께 확인한다. 방어는 불필요한 헤더 전달 제거, canonical host 사용, cache key와 Origin 입력의 정렬이다.


항목HTTP 캐시Redis 캐시
저장 대상HTTP 응답 전체와 헤더애플리케이션이 정한 값
위치브라우저, CDN, 프록시서버 사이드 인프라
제어 방식HTTP 헤더, CDN policy애플리케이션 코드, Redis 명령
hit 효과네트워크 RTT, Origin 요청, 본문 전송 감소DB/외부 API 조회 감소
무효화TTL, URL 버전, CDN invalidationkey 삭제, TTL, write-through/back
보안 위험shared cache 누출, poisoning키 설계 오류, stale 데이터, stampede

두 캐시는 경쟁 관계가 아니다. 공개 이미지라면 HTTP 캐시가 우선이고, 비싼 DB 집계 결과라면 Redis 캐시가 먼저일 수 있다. 공개 API는 Redis로 DB 부하를 줄인 뒤 CDN으로 Origin 요청 자체를 줄이는 식으로 같이 쓸 수 있다.

빌드 캐시는 CI나 번들러가 같은 소스와 의존성으로 만든 중간 산출물을 재사용해 빌드 시간을 줄이는 장치다. HTTP 캐시는 배포된 뒤 브라우저나 CDN이 HTTP 응답을 재사용해 사용자 요청의 왕복과 Origin 부하를 줄이는 장치다.

둘은 이름에 모두 cache가 붙지만 실패 지점이 다르다. 빌드 캐시가 깨지면 배포 시간이 길어지거나 잘못된 산출물이 만들어지고, HTTP 캐시가 잘못되면 사용자에게 오래된 JS, 잘못된 API 응답, 다른 사용자의 데이터가 전달될 수 있다. 이 문서에서 다루는 것은 후자다.

14-3. CORS preflight 캐시와 HTTP 응답 캐시

섹션 제목: “14-3. CORS preflight 캐시와 HTTP 응답 캐시”

CORS preflight 캐시는 Access-Control-Max-Age로 OPTIONS 허가 결과를 저장한다. 일반 HTTP 응답 본문을 저장하는 Cache-Control과 목적이 다르다.

Access-Control-Max-Age: 7200

이 값은 “이 cross-origin 요청 조합은 잠시 허용된 것으로 기억해도 된다”는 뜻이다. 실제 GET /api/products 응답 본문을 캐시한다는 뜻이 아니다.

혼동하면 OPTIONS 요청은 줄었는데 실제 API Origin 부하는 그대로인 상황을 잘못 해석할 수 있다.

14-4. Service Worker Cache API와 HTTP 캐시

섹션 제목: “14-4. Service Worker Cache API와 HTTP 캐시”

Service Worker Cache API는 브라우저 안에서 JavaScript가 직접 요청/응답 저장소를 다루는 기능이다. HTTP 캐시 헤더와 별도로 동작할 수 있다.

이 문서의 범위는 HTTP 표준 헤더와 shared cache다. PWA 오프라인 캐싱, Service Worker의 stale strategy, Workbox 설정은 별도 주제로 보는 편이 좋다. 다만 실패 신호는 비슷하다. 오래된 응답이 보이면 HTTP 캐시뿐 아니라 Service Worker 캐시도 확인해야 한다.


HTTP 캐시 학습 완료 기준

  • Cache-Control이 저장 가능 여부, freshness, 공유 범위, stale 정책을 표현한다는 점을 설명한다.
  • Expires freshness lifetime이 Expires - Date로 계산되어 클럭 스큐를 줄인다는 점과, max-age가 날짜 파싱·설정 모호성을 줄이는 이유를 설명한다.
  • max-age와 s-maxage의 적용 대상을 브라우저와 shared cache로 구분한다.
  • no-cache와 no-store를 저장 여부 기준으로 구분한다.
  • public과 private을 shared cache 저장 가능성 기준으로 판단한다.
  • ETag와 Last-Modified를 이용한 304 조건부 요청 흐름을 단계별로 설명한다.
  • 304가 RTT를 없애는 것이 아니라 본문 전송을 줄인다는 점을 수치 예시로 설명한다.
  • Vary가 캐시 키를 확장하며, 과도한 Vary가 hit rate를 낮춘다는 점을 설명한다.
  • 사용자별 응답 누출과 cache poisoning이 shared cache에서 왜 위험한지 설명한다.
  • hashed asset, HTML shell, 공개 API, 개인정보 API에 서로 다른 Cache-Control을 설계한다.
  • stale-while-revalidate와 stale-if-error의 차이와 적용 경계를 설명한다.
  • 캐시 스탬피드가 왜 생기는지, stale 전략과 TTL jitter가 어떻게 완화하는지 설명한다.
  • CloudFront의 x-cache, Age, cache-control 헤더로 hit/miss와 freshness를 읽는다.

  • HTTP cache: HTTP 응답을 저장하고 재사용하는 프로토콜 기반 캐시.
  • Fresh: Origin에 확인하지 않고 재사용해도 되는 상태.
  • Stale: freshness가 끝나 재검증이나 갱신이 필요한 상태.
  • Cache hit: 캐시가 저장된 응답을 반환한 상태.
  • Cache miss: 캐시가 응답하지 못해 다음 계층 또는 Origin으로 요청이 간 상태.
  • Revalidation: stale 응답이 아직 유효한지 Origin에 조건부로 확인하는 과정.
  • Cache-Control: HTTP 캐시 정책을 표현하는 핵심 헤더.
  • Expires: 절대 시각 기반의 레거시 만료 헤더.
  • max-age: 모든 캐시에 적용되는 상대 freshness 시간.
  • s-maxage: shared cache에만 적용되는 freshness 시간.
  • public: shared cache 저장을 허용하는 지시어.
  • private: 사용자 전용 캐시에만 저장을 허용하는 지시어.
  • no-cache: 저장은 허용하되 사용 전 재검증을 강제하는 지시어.
  • no-store: 저장 자체를 금지하는 지시어.
  • immutable: fresh 기간에는 URL의 내용이 변하지 않는다는 지시어.
  • ETag: 리소스 버전을 나타내는 검증자.
  • Last-Modified: 리소스 마지막 수정 시각 기반 검증자.
  • If-None-Match: 클라이언트가 가진 ETag를 서버에 보내는 조건부 요청 헤더.
  • If-Modified-Since: 클라이언트가 가진 수정 시각을 서버에 보내는 조건부 요청 헤더.
  • 304 Not Modified: 리소스 변경이 없으니 캐시 본문을 재사용하라는 응답.
  • Vary: 어떤 요청 헤더가 응답 변형을 만드는지 알려 캐시 키를 확장하는 헤더.
  • Cache key: 캐시가 같은 응답으로 볼 요청을 식별하는 키.
  • CDN invalidation: CDN 저장 항목을 강제로 삭제하는 작업.
  • Cache busting: URL에 버전이나 해시를 넣어 새 리소스로 인식시키는 전략.
  • stale-while-revalidate: stale을 즉시 제공하고 백그라운드에서 갱신하는 지시어.
  • stale-if-error: Origin 오류 때 stale 응답을 제공할 수 있게 하는 지시어.
  • Cache stampede: 캐시 만료 순간 많은 요청이 동시에 Origin으로 몰리는 현상.
  • Cache poisoning: 오염된 응답을 shared cache에 저장시켜 여러 사용자에게 전달하는 공격.
  • Personalized response leakage: 사용자별 응답이 shared cache를 통해 다른 사용자에게 노출되는 사고.


HTTP 캐시는 Cache-Control로 저장 범위와 freshness를 정하고, ETag/Last-Modified로 만료 뒤 재검증하며, Vary와 CDN cache key로 같은 응답의 경계를 정의하는 인프라 레이어다. 좋은 캐시 전략은 빠른 hit만 만드는 것이 아니라 오래된 응답, 사용자별 데이터 누출, cache poisoning, 만료 순간 스탬피드를 함께 막는다.