브라우저 캐시
사용자 기기에 저장되는 private cache다. 뒤로가기, 새로고침, 같은 사용자의 반복 방문을 빠르게 한다.
HTML, 이미지, JS/CSS, 개인 사용자의 캐시 동작을 볼 때HTTP 캐시는 서버가 보낸 HTTP 응답을 브라우저, CDN, 리버스 프록시 같은 중간 계층에 저장해 두었다가 같은 의미의 요청이 다시 왔을 때 재사용하는 메커니즘이다.
중요한 점은 “파일을 어딘가에 저장한다”가 아니라 응답을 언제 저장할지, 얼마나 신선하다고 볼지, 누구와 공유할지, 언제 원본 서버에 다시 확인할지를 HTTP 헤더로 합의한다는 것이다. 이 합의가 깨지면 페이지가 빨라지는 대신 오래된 가격, 다른 사용자의 프로필, 오염된 스크립트가 조용히 전달될 수 있다.
이 문서는 HTTP 요청-응답과 헤더를 이미 알고 있다는 전제에서 시작하지만, 캐시 전용 용어는 첫 등장 위치에서 다시 정의한다. RTT(round-trip time, 요청이 갔다가 응답의 첫 신호가 돌아오기까지 걸리는 왕복 시간)도 이 문서 안에서 필요한 만큼 다시 잡는다.
선행 상태 - 캐시 없는 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)의 등장: 캐시가 만료됐다고 항상 전체 본문을 다시 받을 필요는 없다. 서버가 ETag나 Last-Modified를 보내면 브라우저는 다음 요청에 If-None-Match 또는 If-Modified-Since를 붙여 “내가 가진 버전이 아직 같은가?”만 확인할 수 있다. 변경이 없으면 서버는 304 Not Modified로 본문 없이 응답한다.
공유 캐시의 등장: 브라우저 캐시는 한 사용자에게만 도움을 준다. CDN이나 리버스 프록시 캐시는 여러 사용자가 같은 응답을 재사용하게 해 Origin 부하와 네트워크 비용을 동시에 줄인다. 하지만 이 공유성 때문에 사용자별 응답, 인증 헤더, 쿠키, 언어, 압축 방식이 캐시 키에 어떻게 반영되는지가 보안 문제로 바뀐다.
이 토픽이 사라지면 깨지는 것: Cache-Control과 검증자 없이 서비스가 커지면 같은 응답을 계속 Origin이 만들고 전송한다. 반대로 캐시를 무작정 켜면 배포 후 오래된 JS가 남거나, Authorization이 붙은 응답이 CDN에 저장되거나, 캐시 키에 포함되지 않은 입력이 응답에 반영되어 캐시 포이즈닝으로 이어진다.
HTTP 캐시는 헤더 이름을 외우기 전에 네 가지 질문으로 나누면 훨씬 안정적으로 이해된다.
저장해도 되는가?
응답에 개인정보, 결제 정보, 사용자별 권한 결과가 있으면 저장 위치가 제한되거나 저장 자체가 금지된다. 여기서 public, private, no-store가 필요하다.
얼마나 신선한가?
저장된 응답을 다시 확인하지 않고 써도 되는 시간을 fresh lifetime이라고 한다. 여기서 max-age, s-maxage, Expires, Age가 필요하다.
만료 뒤 어떻게 확인하는가?
stale(신선하지 않은) 응답을 무조건 버릴지, 서버에 조건부 요청을 보낼지, 장애 때 잠깐 쓸지 결정한다. 여기서 ETag, Last-Modified, If-None-Match, 304, stale-while-revalidate, stale-if-error가 필요하다.
무엇을 같은 응답으로 볼 것인가?
URL이 같아도 언어, 압축 방식, 인증 상태, 쿼리스트링에 따라 응답이 달라질 수 있다. 여기서 캐시 키(cache key), Vary, CDN Cache Policy가 필요하다.
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 경로 위에 캐시 저장소가 얹힌다고 보면 된다.
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, Fastly | Origin 부하와 국제망 지연 감소 | 사용자별 응답이 섞이면 보안 사고 |
| 리버스 프록시 캐시 | 서비스 앞단 | Nginx, Varnish | 내부 API와 정적 파일 보호 | 캐시 키 설정이 앱 의미와 어긋날 수 있음 |
| 애플리케이션 캐시 | 서버 코드 내부 | Redis, in-memory cache | DB 부하 직접 감소 | 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는 들지만 본문 전송을 피하는 효과가 크다.
캐시 hit rate는 전체 요청 중 캐시에서 처리된 비율이다.
총 요청: 10,000 req/minCDN hit rate: 90%Origin까지 도달하는 요청: 10,000 * (1 - 0.90) = 1,000 req/minhit rate가 90%에서 95%로 오르면 단 5%p 개선처럼 보이지만 Origin 입장에서는 1,000 req/min이 500 req/min으로 줄어든다. 남은 미스 트래픽 기준으로는 50% 감소다.
| 총 요청 | hit rate | Origin 요청 | 80% 대비 Origin 감소 |
|---|---|---|---|
| 10,000 req/min | 80% | 2,000 req/min | 기준 |
| 10,000 req/min | 90% | 1,000 req/min | 50% 감소 |
| 10,000 req/min | 95% | 500 req/min | 75% 감소 |
| 10,000 req/min | 99% | 100 req/min | 95% 감소 |
이 표가 중요한 이유는 캐시 최적화의 목표가 “브라우저가 빠르다”에만 있지 않기 때문이다. CDN hit rate가 높아지면 Origin 서버 수, DB 커넥션, 국제망 전송 비용, 배포 중 장애 영향 범위가 같이 줄어든다.
아니다. 다음 응답은 캐시를 켤수록 위험하거나 효과가 작다.
/me, /cart, /payment-infoSet-Cookie를 포함해 사용자 상태를 바꾸는 응답캐시는 “반복되는 같은 의미의 요청”이 있어야 효과가 난다. 반복성이 낮은 응답에 캐시를 얹으면 hit rate는 낮고, 디버깅 표면만 넓어진다.
Freshness는 저장된 응답을 Origin에 묻지 않고 써도 되는 기간이다. Cache-Control은 freshness와 저장 범위를 지정하는 핵심 응답 헤더다.
Cache-Control 디렉티브| 디렉티브 | 첫 정의 | 주로 답하는 질문 |
|---|---|---|
max-age=N | 응답 생성 후 N초 동안 fresh로 간주 | 얼마나 신선한가 |
s-maxage=N | CDN 같은 shared cache에만 적용되는 freshness | 공유 캐시는 얼마나 신선한가 |
public | shared cache에 저장해도 됨 | 누구와 공유할 수 있는가 |
private | 사용자 전용 private cache에만 저장 가능 | 누구와 공유할 수 있는가 |
no-cache | 저장은 가능하지만 사용 전 매번 재검증 필요 | 만료 뒤 어떻게 확인하는가 |
no-store | 저장 자체 금지 | 저장해도 되는가 |
must-revalidate | stale 응답을 임의로 제공하지 말고 반드시 재검증 | stale 사용 경계 |
immutable | fresh 기간에는 내용이 바뀌지 않으므로 새로고침 재검증도 생략 가능 | 불필요한 재검증 제거 |
stale-while-revalidate=N | 만료 후 N초 동안 stale을 즉시 주고 뒤에서 갱신 | 만료 순간 지연 완화 |
stale-if-error=N | Origin 오류 때 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-age나 s-maxage가 있으면 그것을 먼저 보고, 없으면 Expires를 본다. 둘 다 없으면 브라우저나 중간 캐시가 Last-Modified 같은 힌트로 휴리스틱 freshness를 추정할 수 있다. shared cache에서는 Age 헤더도 같이 읽어야 한다. 예를 들어 Cache-Control: max-age=3600이고 Age: 2400이면, 캐시는 대략 1200초 더 fresh하다고 판단할 수 있다.
max-age와 s-maxagemax-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와 no-store의 차이no-cache는 이름 때문에 가장 많이 헷갈린다. 캐시에 저장하지 말라가 아니라 저장해도 되지만 사용하기 전에 매번 서버에 확인하라는 뜻이다.
Cache-Control: no-cacheETag: "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-store가 필요하다.
public과 privatepublic은 CDN 같은 shared cache에도 저장해도 된다는 뜻이다. private은 브라우저 같은 사용자 전용 캐시에는 저장해도 되지만 shared cache에는 저장하지 말라는 뜻이다.
Cache-Control: private, max-age=60이 응답은 한 사용자의 브라우저가 60초 동안 재사용할 수 있다. 하지만 CDN은 여러 사용자에게 같은 저장소를 쓰므로 저장하면 안 된다.
private은 no-store보다 약하다. 브라우저 저장도 피해야 하는 민감 데이터는 private만으로 부족하다.
| 응답 성격 | 적합한 지시어 | 이유 |
|---|---|---|
| 로고, 해시가 붙은 JS/CSS | public, max-age=31536000, immutable | URL이 바뀌면 새 리소스이므로 장기 캐시 가능 |
| HTML 진입점 | no-cache | HTML은 최신 JS/CSS URL을 가리켜야 함 |
| 공개 카테고리 목록 | public, max-age=60, s-maxage=300, stale-while-revalidate=30 | 여러 사용자에게 같고 짧은 stale 허용 |
| 로그인 사용자 프로필 | private, no-store | shared cache 저장 금지, 브라우저 저장도 최소화 |
| 결제/토큰 응답 | no-store | 저장 자체 금지 |
Expires는 왜 레거시인가Expires는 Origin이 의도한 절대 만료 시각을 HTTP-date로 표현한다.
Date: Tue, 01 Jan 2030 00:00:00 GMTExpires: Tue, 01 Jan 2030 01:00:00 GMTRFC 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-age와 Expires가 함께 있으면 max-age가 우선하므로, 두 값을 주 정책처럼 관리하면 충돌 원인이 하나 더 생긴다.호환성이 필요하면 Cache-Control을 주 정책으로 두고 Expires를 보조로 둔다.
Cache-Control: public, max-age=3600Expires: Tue, 01 Jan 2030 01:00:00 GMT최신 캐시는 max-age=3600을 따른다. Expires만 단독으로 새 설계에 사용하는 것은 피한다.
예시 A - 해시가 붙은 정적 자산
GET /assets/app.a1b2c3.jsCache-Control: public, max-age=31536000, immutable파일명에 콘텐츠 해시가 있으므로 내용이 바뀌면 URL도 바뀐다. /assets/app.d4e5f6.js는 기존 캐시와 다른 리소스다. 그래서 1년 캐시가 안전하다.
예시 B - HTML 진입점
GET /Cache-Control: no-cacheETag: "html-v42"HTML은 최신 JS/CSS 파일명을 참조해야 한다. HTML 자체를 1년 캐시하면 새 배포 뒤에도 브라우저가 예전 번들 URL을 계속 볼 수 있다. no-cache로 매번 재검증하게 두면 변경이 없을 때는 304로 가볍게 끝나고, 배포 뒤에는 새 HTML을 받는다.
예시 C - 공개 카탈로그 API
GET /api/categoriesCache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=30, stale-if-error=86400ETag: "categories-v18"브라우저는 60초, CDN은 300초 fresh로 쓴다. 만료 직후 30초 동안은 stale을 즉시 주고 뒤에서 갱신할 수 있다. Origin 장애 때는 하루 동안 이전 응답을 임시로 제공할 수 있다. 카테고리 목록은 몇 분 stale이 허용되기 때문에 가능한 전략이다.
예시 D - 로그인 사용자 프로필
GET /api/meAuthorization: Bearer ...Cache-Control: private, no-store사용자별 응답이고 민감 데이터가 포함될 수 있다. CDN shared cache에 저장되면 안 되고, 브라우저 디스크에도 남기지 않는 편이 안전하다.
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과 함께 쓸 때만 안전하다.
Freshness가 끝난 응답을 stale이라고 한다. stale이라고 해서 즉시 버리는 것은 아니다. 서버가 검증자(validator)를 제공했다면 클라이언트는 조건부 요청(conditional request)으로 “내가 가진 버전이 아직 유효한가?”를 확인할 수 있다.
If-None-MatchETag(entity tag)는 리소스 버전을 나타내는 식별자다. 보통 콘텐츠 해시, 버전 번호, 빌드 ID로 만든다.
HTTP/1.1 200 OKETag: "products-v42"Cache-Control: public, max-age=300300초가 지나 stale이 되면 클라이언트는 다음 요청에 If-None-Match를 붙인다.
GET /api/productsIf-None-Match: "products-v42"서버의 현재 버전도 "products-v42"라면:
HTTP/1.1 304 Not ModifiedETag: "products-v42"본문이 없다. 클라이언트는 저장해 둔 본문을 그대로 사용한다.
버전이 바뀌었다면:
HTTP/1.1 200 OKETag: "products-v43"Cache-Control: public, max-age=300
{ ...새 본문... }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가 더 안전하다.
If-Modified-SinceLast-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는 만들기 쉽지만 정밀도가 낮다.
| 기준 | ETag | Last-Modified |
|---|---|---|
| 비교 대상 | 버전 식별자 또는 콘텐츠 해시 | 수정 시각 |
| 정밀도 | 구현에 따라 매우 높음 | 보통 초 단위 |
| 장점 | 내용 변경 감지에 강함 | 계산이 쉽고 저장 메타데이터 활용 가능 |
| 약점 | 해시 계산 비용, 분산 서버 간 일관성 필요 | 같은 초에 두 번 바뀌면 놓칠 수 있음 |
| 우선순위 | 둘 다 있으면 보통 ETag 우선 | ETag가 없을 때 폴백 |
3MB 이미지가 있고 사용자의 RTT가 120ms라고 하자.
| 방식 | Origin 왕복 | 본문 전송 | 대략적인 효과 |
|---|---|---|---|
| Fresh cache hit | 없음 | 0 | 가장 빠름 |
| 304 revalidation | 1 RTT | 헤더만 | 3MB 전송 절약, RTT는 남음 |
| 200 full response | 1 RTT | 3MB | 가장 비쌈 |
304는 “네트워크 왕복을 없애는 기술”이 아니라 “본문 재전송을 없애는 기술”이다. RTT까지 없애려면 fresh 기간 안에서 hit가 나야 한다. 그래서 정적 해시 자산은 긴 max-age와 immutable을 쓰고, 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 같은 헤더가 일관되게 유지되어야 한다.
캐시 키(cache key)는 캐시가 “이 요청과 저 요청은 같은 저장 항목을 써도 된다”고 판단할 때 쓰는 식별자다. 기본적으로는 메서드, scheme, host, path, query string이 핵심이다.
GET https://api.example.com/products?page=1이 요청의 기본 키는 대략 다음 요소로 만들어진다.
method=GETscheme=httpshost=api.example.compath=/productsquery=page=1하지만 URL이 같아도 응답이 달라지는 경우가 있다. 예를 들어 같은 /products라도 Accept-Language: ko면 한국어 이름, Accept-Language: en이면 영어 이름을 내려줄 수 있다. 이때 Vary가 필요하다.
Vary는 캐시 키를 확장한다Vary: Accept-Encoding이 헤더는 “응답이 Accept-Encoding 요청 헤더에 따라 달라지므로, 캐시 키에 그 값을 포함하라”는 뜻이다.
GET /app.jsAccept-Encoding: br→ key: /app.js + br
GET /app.jsAccept-Encoding: gzip→ key: /app.js + gzip브라우저나 CDN은 Brotli 버전과 gzip 버전을 별도 항목으로 저장한다. 그래야 gzip만 이해하는 클라이언트에게 Brotli 본문을 주는 사고를 피할 수 있다.
다음 API가 요청 언어에 따라 다른 상품명을 내려준다고 하자.
GET /api/productsAccept-Language: ko→ { "name": "노트북" }
GET /api/productsAccept-Language: en→ { "name": "Laptop" }Vary: Accept-Language가 없으면 URL만 보고 같은 캐시 항목으로 취급될 수 있다. 첫 요청이 한국어였다면 영어 사용자에게도 "노트북"이 내려갈 수 있다.
Cache-Control: public, max-age=300Vary: Accept-Language, Accept-Encoding이제 언어와 압축 방식별로 캐시 항목이 분리된다.
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 조합 폭발을 줄일 수 있다.
CDN에는 두 질문이 있다.
이 둘은 같지 않다. 어떤 헤더는 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 cache | shared 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로 둔다.Authorization 처리를 명시한다.Set-Cookie가 있는 응답은 shared cache 저장을 피한다.캐시 포이즈닝(cache poisoning)은 공격자가 오염된 응답을 shared cache에 저장시킨 뒤 다른 사용자에게 전달되게 만드는 공격이다. 핵심 패턴은 캐시 키에는 포함되지 않지만 응답에는 영향을 주는 입력이다.
정상 요청:GET /pageHost: example.com→ cache key: /page + example.com→ response: <script src="https://example.com/app.js">
공격 요청:GET /pageHost: example.comX-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가 엉키면 발생한다.
웹 보안 문서의 신뢰 경계 질문을 캐시에 적용하면 다음과 같다.
이 응답은 사용자별인가, 공개인가? 사용자별이면 shared cache 저장을 금지한다.
응답 본문을 바꾸는 입력은 캐시 키에 모두 들어갔는가? 언어, 압축, A/B 실험, 권한, 쿼리 필터가 빠지면 혼선이 난다.
캐시 키에는 없지만 Origin이 신뢰하는 헤더가 있는가?
X-Forwarded-Host, X-Original-URL, Forwarded 같은 헤더를 절대 URL 생성이나 라우팅에 쓰는지 본다.
캐시된 stale 응답이 보안 결정을 포함하는가? 권한, 결제 가능 여부, 재고 차감 같은 결정은 stale 허용 범위를 매우 좁게 잡아야 한다.
장애 때 stale을 주는 것이 안전한가? 문서, 이미지, 공개 카탈로그는 가능할 수 있다. 결제 승인, 좌석 예약, 계정 상태는 위험하다.
private, no-store를 붙인다.캐시의 어려움은 저장보다 무효화(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-cacheCDN invalidation은 CDN 저장소에서 특정 경로를 강제로 제거하는 작업이다. URL을 바꾸기 어려운 동적 페이지나 API 응답에 쓴다.
aws cloudfront create-invalidation \ --distribution-id E1234ABCD5678 \ --paths "/api/products" "/api/categories/*"이 명령은 실전에서 필요하지만 본문 이해의 핵심은 명령어가 아니라 성질이다.
| 상황 | 우선 전략 | 이유 |
|---|---|---|
| 파일 내용 변경 때 URL 변경 가능 | cache busting | 가장 안정적이고 브라우저 캐시까지 해결 |
| URL 고정 공개 응답, 변경 드묾 | 짧은 TTL + 필요 시 invalidation | 동적 응답은 URL 버전 관리가 어려움 |
| 가격/재고처럼 정확성 중요 | 짧은 TTL 또는 no-cache | stale 허용 비용이 큼 |
| 공지/문서처럼 약간 stale 허용 | 긴 TTL + stale-if-error | 장애 내성과 비용 절감 |
| 사용자별 응답 | no-store/private | invalidation보다 저장 금지가 우선 |
문제 상황:
/assets/app.jsCache-Control: public, max-age=86400파일명이 고정되어 있고 하루 동안 fresh다. 새 배포로 내용이 바뀌어도 브라우저와 CDN은 같은 URL의 기존 파일을 fresh로 본다. 사용자는 하루 동안 예전 JS를 실행할 수 있다.
근본 해결:
/assets/app.a1b2c3.jsCache-Control: public, max-age=31536000, immutable
/Cache-Control: no-cacheHTML은 매번 재검증해 새 해시 파일명을 알게 하고, 해시 파일은 장기 캐시한다. 이 조합은 CDN invalidation을 배포의 기본 수단으로 만들지 않는다.
/* invalidation을 매 배포마다 실행하면 되는가가능은 하지만 좋은 기본값은 아니다.
전체 invalidation은 사고 대응이나 구조 전환 때의 임시 수단에 가깝다. 정상 배포 경로는 URL 버전 관리와 적절한 HTML 재검증으로 설계하는 편이 안정적이다.
stale은 fresh lifetime이 끝난 응답이다. 일반적으로는 재검증하거나 새로 가져와야 하지만, 일부 응답은 stale을 잠깐 제공하는 것이 더 낫다. 이때 쓰는 지시어가 stale-while-revalidate와 stale-if-error다.
stale-while-revalidateCache-Control: public, max-age=60, stale-while-revalidate=300이 응답은 다음 시간대를 가진다.
0~60초: fresh. 캐시가 즉시 응답한다.61~360초: stale 허용 구간. 캐시가 이전 응답을 즉시 주고 뒤에서 갱신한다.361초 이후: stale 허용도 끝남. Origin에서 새 응답을 받아야 한다.핵심은 사용자 지연을 줄이는 것이다. 만료 직후 첫 사용자가 Origin 응답을 기다리는 대신, 캐시는 잠깐 오래된 응답을 주고 백그라운드에서 새 버전을 가져온다.
stale-if-errorCache-Control: public, max-age=60, stale-if-error=86400Origin이 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-revalidate | HTTP 캐시 | stale을 즉시 주고 갱신을 뒤로 미룸 | 공개 응답, 약간 stale 허용 |
| collapsed forwarding / single-flight | CDN 또는 프록시 | 같은 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 정책을 쓰지 않는다.
브라우저와 CDN은 같은 HTTP 헤더를 읽지만 목표가 다르다. 브라우저는 한 사용자 경험을 빠르게 만들고, CDN은 여러 사용자 요청을 Origin에서 떼어낸다.
사용자 기기에 저장되는 private cache다. 뒤로가기, 새로고침, 같은 사용자의 반복 방문을 빠르게 한다.
HTML, 이미지, JS/CSS, 개인 사용자의 캐시 동작을 볼 때엣지 서버에 저장되는 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이 다른 헤더를 보내고 있는지 확인해야 한다.
| 헤더 | 의미 | 읽는 법 |
|---|---|---|
x-cache: Hit from cloudfront | 엣지 캐시 hit | Origin까지 가지 않았을 가능성이 높음 |
x-cache: Miss from cloudfront | 엣지 캐시 miss | Origin으로 요청이 갔거나 저장 조건이 안 맞음 |
Age: 123 | shared cache에 저장된 뒤 지난 초 | max-age와 비교해 fresh/stale 판단 |
x-amz-cf-pop: ICN... | 응답한 엣지 위치 | 지역별 캐시 편차를 볼 때 도움 |
cache-control | Origin 또는 CDN이 전달한 정책 | 의도한 지시어가 실제로 도달했는지 확인 |
CloudFront가 항상 Origin 헤더만 그대로 따르는 것은 아니다. Cache Policy의 min/default/max TTL, 캐시 키 설정, Origin Request Policy가 함께 결과를 만든다. 그래서 “Origin은 max-age=300을 보냈다”만으로 충분하지 않고, CDN 정책이 그 헤더를 사용하도록 되어 있는지도 봐야 한다.
Cache-Control도 Expires도 없으면 브라우저는 휴리스틱으로 캐시할 수 있다. 대표적인 추정은 Date와 Last-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=60HTTP 캐시 전략은 엔드포인트의 의미에서 출발한다. 다음 표는 첫 설계의 기본값으로 쓸 수 있다.
| 응답 유형 | 권장 헤더 | 왜 이렇게 하는가 | 실패하면 보이는 신호 |
|---|---|---|---|
| 해시 정적 자산 | public, max-age=31536000, immutable | URL 변경이 무효화 역할 | 해시 없는 파일에 붙이면 오래된 JS |
| HTML shell | no-cache + ETag | 최신 자산 URL을 받아야 함 | 배포 후 예전 bundle 참조 |
| 공개 문서/이미지 | public, max-age=3600, stale-if-error=86400 | stale이 500보다 나을 수 있음 | 수정 반영 지연 |
| 공개 목록 API | public, max-age=60, s-maxage=300, stale-while-revalidate=30 | Origin 보호와 최신성 균형 | stale 허용 범위 초과 |
| 검색 API | 짧은 max-age 또는 no-cache | 쿼리 조합이 많아 hit rate 낮음 | CDN miss 반복 |
| 사용자 프로필 | private, no-store | 사용자별·민감 데이터 | 다른 사용자 데이터 누출 |
| 결제/권한 결정 | no-store | stale 허용 불가 | 오래된 권한 또는 결제 상태 |
| CORS preflight | Access-Control-Max-Age | OPTIONS 왕복 감소 | preflight가 실제 요청만큼 많음 |
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-*"] Vary와 URL 분리 선택| 요구 | Vary가 적합 | URL 분리가 적합 |
|---|---|---|
| gzip/br 압축 차이 | 예 | 아니오 |
| 2~3개 콘텐츠 타입 | 가능 | 가능 |
| 많은 언어 | 제한적 | 대체로 예 |
| 모바일/데스크톱 별도 HTML | 주의 | 명시 URL 또는 responsive 설계 검토 |
| 사용자 권한별 필드 | 아니오 | URL 분리보다 private/no-store 우선 |
URL 분리는 캐시 키를 명확하게 만든다. Vary는 프로토콜적으로 맞지만 조합 폭발을 만들 수 있다. 둘 중 하나를 고를 때는 hit rate와 응답 혼선 위험을 같이 본다.
이 절은 긴 복구 런북이 아니라 증상이 왜 생기는지에 집중한다. 명령어는 13절 선택 부록에 짧게 모았다.
증상
왜 생기는가
URL이 그대로인데 장기 max-age가 붙어 있으면 브라우저는 그 URL의 기존 응답을 fresh로 본다. CDN invalidation은 CDN 저장소를 지울 수 있지만 이미 사용자의 브라우저 private cache에 fresh로 저장된 파일까지 지우지는 못한다.
학습 포인트
배포 무효화는 URL 설계가 1차 방어다. 해시 파일명과 HTML no-cache 조합이 없으면 운영 명령으로 브라우저 캐시 문제를 따라잡아야 한다.
증상
x-cache: Miss from cloudfront가 계속 보인다.Age가 없거나 항상 0에 가깝다.왜 생기는가
Origin이 no-store나 no-cache를 보내고 있을 수 있다. 또는 Cache Policy가 쿠키, 전체 쿼리스트링, Authorization, 추적 헤더를 캐시 키에 포함해 요청마다 다른 키를 만들 수 있다. Set-Cookie가 있는 응답을 CDN이 저장하지 않도록 처리하는 경우도 있다.
학습 포인트
miss 반복은 “캐시가 없다”가 아니라 “같은 키로 재사용 가능한 응답이 없다”는 뜻이다. Origin 헤더와 CDN cache key를 함께 봐야 한다.
증상
ETag가 있는 것 같은데 조건부 요청 후에도 항상 전체 본문을 받는다.왜 생기는가
서버가 If-None-Match를 처리하지 않거나, CDN/프록시가 조건부 요청 헤더를 Origin에 전달하지 않거나, Origin 인스턴스마다 ETag 생성 방식이 달라질 수 있다. gzip 여부에 따라 ETag가 달라지는 설정도 흔한 원인이다.
학습 포인트
ETag는 헤더를 붙이는 것만으로 끝나지 않는다. 다음 요청에서 비교하고, 같으면 304로 응답하는 서버 로직 또는 정적 파일 서버 동작이 필요하다.
증상
/api/me, /profile, /cart 같은 URL에서 다른 사용자의 정보가 보인다.왜 생기는가
사용자별 응답이 public으로 저장되었거나, CDN이 Authorization/Cookie를 고려하지 않고 같은 URL을 같은 키로 처리했다. private response leakage는 5xx가 아니라 정상 200으로 나타나기 때문에 더 위험하다.
학습 포인트
사용자별 응답은 성능 최적화 대상이기 전에 보안 경계다. public cache를 켜려면 “누가 봐도 같은 응답”이라는 증거가 있어야 한다.
증상
왜 생기는가
응답을 바꾸는 요청 헤더가 캐시 키에 반영되지 않았다. Vary가 빠졌거나, CDN Cache Policy가 해당 헤더를 키에 포함하지 않는다.
학습 포인트
Vary는 브라우저에게만 쓰는 주석이 아니다. shared cache가 실제로 그 값을 키에 포함하도록 CDN 정책까지 맞아야 한다.
증상
왜 생기는가
많은 요청이 같은 TTL 만료 시점에 동시에 miss를 경험한다. 캐시가 평균 요청을 줄였지만, 만료 순간을 정렬해 스탬피드를 만들었다.
학습 포인트
hit rate 평균만 보면 안 된다. 만료 시점의 동시성, stale 정책, single-flight, TTL jitter까지 봐야 한다.
증상
왜 생기는가
캐시 키에 없는 입력이 Origin 응답에 반영되었다. shared cache는 정상 키의 응답이라고 믿고 저장한다.
학습 포인트
캐시 보안은 입력 검증과 키 설계가 만나는 지점이다. Origin이 신뢰하는 입력과 CDN이 구분하는 입력 목록이 달라지면 포이즈닝 표면이 생긴다.
아래 명령은 본문 개념을 확인하기 위한 최소 관찰용이다. 이 절을 펼치지 않아도 앞의 개념 판단은 이해되어야 한다.
curl -I https://example.com/path확인할 헤더:
cache-control: public, max-age=300etag: "v42"age: 123x-cache: Hit from cloudfrontx-amz-cf-pop: ICN...해석:
cache-control이 의도한 정책인지 본다.Age가 증가하면 shared cache에 저장되어 재사용 중이다.Miss면 cache key 또는 저장 조건을 의심한다.curl -I https://example.com/resourcecurl -I https://example.com/resource \ -H 'If-None-Match: "위 응답의 ETag 값"'기대:
HTTP/2 304계속 200 OK라면 서버의 조건부 요청 처리, 프록시의 If-None-Match 전달, ETag 생성 일관성을 확인한다.
curl -I https://api.example.com/me \ -H "Authorization: Bearer TOKEN"안전한 기본 신호:
cache-control: private, no-store위 응답에서 public, 긴 s-maxage, CDN Hit가 보이면 사용자별 응답이 shared cache에 저장되는지 즉시 확인한다.
aws cloudfront create-invalidation \ --distribution-id E1234ABCD5678 \ --paths "/api/products*"이 명령은 CDN 엣지 캐시 삭제 요청이다. 브라우저 private cache를 직접 지우지 못하며, 전파 지연이 있을 수 있다. 배포의 기본 전략으로 반복하기보다 URL 버전 관리와 TTL 설계를 먼저 점검한다.
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 분리가 더 단순할 수 있다.
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 invalidation | key 삭제, 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 응답, 다른 사용자의 데이터가 전달될 수 있다. 이 문서에서 다루는 것은 후자다.
CORS preflight 캐시는 Access-Control-Max-Age로 OPTIONS 허가 결과를 저장한다. 일반 HTTP 응답 본문을 저장하는 Cache-Control과 목적이 다르다.
Access-Control-Max-Age: 7200이 값은 “이 cross-origin 요청 조합은 잠시 허용된 것으로 기억해도 된다”는 뜻이다. 실제 GET /api/products 응답 본문을 캐시한다는 뜻이 아니다.
혼동하면 OPTIONS 요청은 줄었는데 실제 API Origin 부하는 그대로인 상황을 잘못 해석할 수 있다.
Service Worker Cache API는 브라우저 안에서 JavaScript가 직접 요청/응답 저장소를 다루는 기능이다. HTTP 캐시 헤더와 별도로 동작할 수 있다.
이 문서의 범위는 HTTP 표준 헤더와 shared cache다. PWA 오프라인 캐싱, Service Worker의 stale strategy, Workbox 설정은 별도 주제로 보는 편이 좋다. 다만 실패 신호는 비슷하다. 오래된 응답이 보이면 HTTP 캐시뿐 아니라 Service Worker 캐시도 확인해야 한다.
Cache-Control, ETag, Expires, 휴리스틱 캐시를 입문자가 읽기 좋게 정리한 문서.Cache-Control 처리 방식 확인용 공식 문서.Vary가 캐시 키와 hit rate에 미치는 영향을 실무 관점에서 설명한다.Cache-Control 디렉티브를 브라우저 성능 관점에서 확인하기 좋다.HTTP 캐시는 Cache-Control로 저장 범위와 freshness를 정하고, ETag/Last-Modified로 만료 뒤 재검증하며, Vary와 CDN cache key로 같은 응답의 경계를 정의하는 인프라 레이어다. 좋은 캐시 전략은 빠른 hit만 만드는 것이 아니라 오래된 응답, 사용자별 데이터 누출, cache poisoning, 만료 순간 스탬피드를 함께 막는다.