"캐시된 응답은 혼자 존재하지 않아. 브라우저에서 CDN 엣지와 보호 계층을 거쳐 원본까지 이어지는 계층 안에 살아. 같은 Cache-Control도 계층에 따라 적용 범위가 다르니, 어느 캐시에 무엇을 허용하는지 알아야 빠른 캐시와 위험한 캐시를 구분할 수 있어."
위에서 아래로 이어지는 구조
일반적인 웹 요청은 다음 계층을 지날 수 있어.
- 브라우저 캐시 — 한 사용자의 메모리나 디스크에 있는 개인 캐시야. 같은 사용자가 자원을 다시 찾을 때 적중하면 네트워크 전송 자체를 피할 수 있어.
- CDN 엣지 노드 — Cloudflare PoP, AWS CloudFront 엣지, Fastly POP처럼 가까운 접속 지점에서 여러 사용자가 공유해. 인기 콘텐츠가 적중하면 원본까지 요청이 가지 않아.
- CDN 보호 계층이나 중간 계층 — 선택적으로 두는 중앙 캐시야. 여러 엣지에서 동시에 난 미스를 모아 원본으로 향하는 요청 수를 줄여. Cloudflare의 계층형 캐시와 Fastly의 원본 보호가 이런 역할을 해.
- 원본과 앱 서버 — FastAPI, Node, Rails 같은 애플리케이션이 실제 응답을 만들어. 앞단에 Nginx나 Varnish 마이크로캐시가 더 놓일 수도 있어.
각 계층은 HTTP 응답 헤더를 읽어 저장과 재사용 여부를 판단하고, 미스나 재검증이 필요할 때 다음 계층으로 요청을 넘겨. 하나의 정책이 사슬을 따라 전달되지만, 각 캐시는 자신에게 적용되는 지시어만 해석해.
어느 헤더가 어느 계층을 겨냥할까
Cache-Control: max-age=N— 개인 캐시와 공유 캐시에 적용되는 일반 신선도 수명이야.Cache-Control: s-maxage=N— CDN과 프록시 같은 공유 캐시에만 적용돼. 공유 캐시에서는 max-age를 덮어쓰고, 브라우저는 무시해.- private — 개인 캐시에만 저장하도록 제한해. 브라우저는 저장할 수 있지만 CDN은 저장하면 안 돼.
- public — 공유 캐시를 포함한 캐시가 저장할 수 있음을 명시해. 공개 콘텐츠에 흔하지만, 데이터가 정말 모든 캐시 키 범위에서 공유 가능한지 먼저 확인해야 해.
- no-store — 브라우저와 CDN을 포함해 어떤 캐시에도 저장하지 말라는 뜻이야. 토큰이나 결제 정보처럼 남겨서는 안 되는 응답에 써.
- CDN 전용 헤더 —
CDN-Cache-Control,Cloudflare-CDN-Cache-Control,Surrogate-Control처럼 특정 공유 캐시가 별도 정책을 읽게 하는 헤더야. 지원 범위와 우선순위는 제공자마다 다르므로 실제 CDN 문서를 확인해야 해.
강력한 패턴: 브라우저는 짧게, CDN은 길게
모든 사용자에게 같은 내용을 주지만 자주 갱신되는 뉴스 피드나 제품 카탈로그에는 이런 조합을 쓸 수 있어.
Cache-Control: public, max-age=10, s-maxage=600
브라우저에서는 10초 동안 신선하고, 공유 캐시에서는 10분 동안 신선해. 한 엣지에 처음 들어온 요청만 미스가 나고 뒤의 요청이 모두 적중한다고 가정하면, 그 10분 동안 사용자 수가 늘어도 원본 요청은 엣지마다 한 번 수준으로 줄어들 수 있어. 엣지 50곳에서 페이지 조회 100,000건이 고르게 발생하는 단순한 예라면 원본 요청이 약 50건까지 줄 수 있지만, 실제 수치는 지역 분포와 캐시 키, 축출 정책에 따라 달라져.
캐시 키 — 두 요청을 무엇으로 구분할까
캐시는 보통 요청 메서드와 대상 URL을 기본 키로 삼고, 응답의 Vary에 나열된 요청 헤더 값을 보조 키로 반영해. CDN 설정에 따라 쿼리 문자열이나 쿠키를 정규화하거나 별도 키 규칙을 추가할 수도 있어. 같은 URL에 서로 다른 Authorization 값이 들어오고 응답이 Vary: Authorization를 보냈다면 두 표현을 다른 항목으로 구분해야 해. 캐시 가능한 응답인데 필요한 구분이 빠지면 이런 문제가 생겨.
- 사용자 A의 응답이 사용자 B에게 전달되어 사용자 간 데이터가 노출돼.
- gzip만 지원하는 클라이언트가 해제할 수 없는 Brotli 본문을 받아. 이때는 Vary: Accept-Encoding이 빠진 거야.
- JSON을 요청한 클라이언트가 HTML 표현을 받아. 이때는 Vary: Accept 같은 표현 구분이 빠진 거야.
캐시 키 오류는 조용히 잘못된 응답을 내보내서 더 위험해. 표준 콘텐츠 협상에는 Vary를 정확히 쓰고, CDN에서 키를 별도로 조정했다면 그 설정까지 함께 검토해야 해. 사용자별 데이터는 private이나 no-store로 공유 저장 자체를 막는 편이 더 안전할 때가 많아.
캐시 제거 — 저장된 응답을 즉시 무효화해야 할 때
콘텐츠를 고쳤는데 CDN의 기존 응답이 TTL이 끝날 때까지 남아 있을 수 있어. 즉시 무효화하는 방법은 크게 두 가지야.
- URL로 제거 — 특정 URL의 캐시 항목을 지우라고 CDN API에 요청해. Cloudflare API나 Fastly API처럼 대상이 분명할 때 쓰는 방식이야.
- 캐시 태그로 제거 — 응답에
Surrogate-Key: article-42 author-pippa처럼 태그를 붙이고 나중에 같은 태그를 가진 항목을 한꺼번에 무효화해. URL을 전부 나열하지 않고 관련 콘텐츠 묶음을 지울 수 있지만, 지원 방식은 CDN 상품과 설정에 따라 달라.
제거는 유용하지만 정상적인 신선도 설계를 대신하지는 못해. 변경 허용 시간을 반영한 TTL을 정하고, 잠시 오래된 응답을 보여도 되는 콘텐츠에는 stale-while-revalidate를 조합해 긴급 제거에 의존하는 상황을 줄여.
cwkPippa의 CDN 현실
no-store처럼 공유 저장을 막는 정책이 중요해. 같은 URL이 Accept-Language에 따라 한국어와 영어를 반환한다면 Vary: Accept-Language를 보내거나 로캘을 URL에 포함해 캐시 키가 표현을 확실히 구분하도록 해야 해. 핵심은 Vary 하나를 습관처럼 붙이는 게 아니라, 실제 표현 선택 기준과 캐시 키를 일치시키는 거야.