Cache-Control 완전 정복: max-age와 s-maxage로 TTL 설계하기

🤖 AI Summary
CDN 캐시가 얼마나 오래 살아 있을지는 Cache-Control 헤더가 정합니다. RFC 9111 기준으로 max-age는 응답이 stale이 되기까지의 초를 지정하고, s-maxage는 공유 캐시에서만 max-age와 Expires를 덮어써요. 즉 브라우저와 CDN에 서로 다른 수명을 줄 수 있죠. no-cache는 저장을 막는 것이 아니라 재사용마다 재검증을 요구하는 디렉티브이고, no-store가 아예 저장을 금지합니다. immutable은 fresh 상태 동안 갱신하지 않겠다는 신호인데 RFC 9111이 아니라 RFC 8246에서 정의됐어요. 만료된 뒤에는 ETag·Last-Modified로 재검증해 304로 되살리거나, stale-while-revalidate로 갱신하는 동안 옛 응답을 내보냅니다. 콘텐츠 유형별 TTL은 스피디 CDN 백서 기준으로 정적 7~30일, 동적 5~60초처럼 크게 달라집니다.
블로그 목차
Cache-Control은 무엇을 정하는가
캐시를 얼마나 믿고 재사용할지를 정하는 헤더가 Cache-Control입니다. 핵심 개념은 fresh와 stale 두 가지예요. RFC 9111은 "fresh 응답은 나이(age)가 아직 freshness lifetime을 넘지 않은 응답이고, stale 응답은 그것을 넘긴 응답"이라고 정의합니다. 쉽게 말해 이 헤더로 정한 수명이 남아 있으면 캐시가 그대로 내보내고, 지나면 오리진에 다시 물어봐야 하죠. 그 수명을 얼마로, 어디(브라우저냐 CDN이냐)에 줄지가 이 글의 주제입니다.
먼저 짚을 것은 캐시에 두 종류가 있다는 점입니다. 브라우저의 로컬 캐시 같은 private 캐시와, CDN 엣지처럼 여러 사용자가 공유하는 shared 캐시죠. 뒤에서 볼 디렉티브 상당수가 이 둘을 다르게 대합니다. 이 구분을 놓치면 "브라우저엔 짧게, CDN엔 길게" 같은 설계를 할 수 없어요.
max-age와 s-maxage: 수명을 정하는 두 축
가장 기본은 max-age입니다. RFC 9111은 "max-age 응답 디렉티브는 응답의 나이가 지정된 초 수를 넘으면 그 응답을 stale로 간주하도록 지시한다"고 정의합니다. Cache-Control: max-age=3600이면 3600초, 즉 한 시간 동안 fresh인 셈이죠.
여기에 s-maxage가 더해지면 브라우저와 CDN을 갈라 다룰 수 있습니다. RFC 9111은 "s-maxage는 공유 캐시에 한해 max-age 디렉티브나 Expires 헤더가 지정한 최대 나이를 덮어쓴다"고 규정하고, "s-maxage는 공유 캐시에 대해 proxy-revalidate의 의미를 내포한다"고 덧붙여요. MDN도 "s-maxage는 private 캐시에서는 무시된다"고 명시합니다. 정리하면 이렇습니다. private 캐시(브라우저)는 max-age를 따르고, shared 캐시(CDN)는 s-maxage가 있으면 그 값을 우선 씁니다. 그래서 브라우저엔 짧은 max-age, CDN엔 긴 s-maxage를 주는 설계가 가능하죠.
수명이 겹칠 때 어느 값을 쓰는지도 RFC가 순서로 정해 두었습니다. 공유 캐시라면 s-maxage가 먼저, 없으면 max-age, 그다음 Expires 헤더, 그마저 없으면 heuristic으로 추정합니다. heuristic은 "명시적 만료 시간이 없을 때 Last-Modified 같은 다른 필드로 추정하는" 방식인데, 명시적 만료가 있으면 쓰지 않습니다. 결국 TTL은 명시적으로 주는 편이 안전하다는 뜻이에요.

저장과 재사용을 통제하는 디렉티브들
수명 외에 "저장해도 되는가, 재사용 전에 확인이 필요한가"를 정하는 디렉티브가 있습니다. 가장 혼동되는 짝이 no-cache와 no-store예요. 이름과 달리 no-cache는 저장을 막지 않습니다. RFC 9111은 no-cache를 "재검증을 위해 오리진에 전달하고 성공 응답을 받기 전에는 그 응답을 다른 요청에 사용해서는 안 된다(MUST NOT)"고 정의해요. 저장은 하되 매번 확인하라는 뜻이죠. 반면 no-store는 "캐시가 요청이나 응답의 어떤 부분도 저장해서는 안 된다"입니다. 진짜로 저장을 금지하는 쪽은 no-store입니다.
저장 위치를 가르는 것은 private와 public입니다. RFC 9111 기준 private는 "공유 캐시가 저장해서는 안 되며(단일 사용자용) private 캐시는 저장할 수 있다"는 뜻이고, public은 "원래라면 금지될 응답도 캐시가 저장할 수 있게 명시적으로 표시"합니다. 개인화된 응답이라면 private, 누구에게나 같은 정적 자산이라면 public이 맞죠.
정적 자산에 유용한 것이 immutable입니다. MDN은 immutable을 "응답이 fresh인 동안에는 갱신되지 않을 것임을 나타낸다"고 정의해요. 파일명에 해시가 붙은 app.a1b2c3.js 같은 자산은 내용이 바뀌면 이름도 바뀌니, fresh 동안 재검증조차 하지 않겠다는 immutable이 잘 맞습니다. 한 가지 정확히 해 둘 점은 immutable은 RFC 9111이 아니라 RFC 8246에서 정의된 별도 디렉티브라는 것이에요. 재검증 강제는 공유 캐시 전용인 proxy-revalidate, 캐시 종류를 가리지 않는 must-revalidate가 담당합니다.
만료된 뒤에도 버티는 법: 재검증과 stale 활용
수명이 다했다고 무조건 오리진에서 전부 다시 받아야 하는 것은 아닙니다. RFC 9111은 저장된 응답이 fresh하지 않을 때 조건부 요청(conditional request)으로 재검증하는 길을 둡니다. 두 가지 검증자가 있어요. 하나는 ETag로, If-None-Match 헤더에 실어 보냅니다. 다른 하나는 Last-Modified 타임스탬프로, If-Modified-Since에 실어요. 오리진이 "안 바뀌었다"고 판단하면 304 Not Modified를 돌려주는데, RFC는 "304 응답은 저장된 응답을 갱신하고 재사용할 수 있음을 나타낸다"고 규정합니다. 본문 없이 상태 코드만 오가니 전송량이 크게 줄죠.
여기에 사용자 체감을 지키는 장치가 stale-while-revalidate입니다. MDN은 이를 "캐시가 응답을 재검증하는 동안 stale 응답을 재사용할 수 있음을 나타낸다"고 정의해요. 만료된 콘텐츠라도 일단 옛 버전을 즉시 내보내고, 뒤에서 조용히 새로 받아 두는 방식입니다. 오리진 장애 상황을 버티는 stale-if-error도 있는데, MDN 기준 여기서 에러란 500·502·503·504 응답을 말합니다. 오리진이 캐시로 버티는 이 전략은 Stale-While-Revalidate 글에서, 즉시 무효화가 필요할 때 쓰는 퍼지는 캐시 퍼지 가이드에서 더 다뤘습니다.
콘텐츠 유형별 TTL 설계
디렉티브를 이해했다면 남은 것은 콘텐츠마다 수명을 얼마로 줄지입니다. 스피디 CDN 백서는 콘텐츠 유형별로 캐시 적합도와 전략을 나눠 두었어요. 정적 콘텐츠(JPEG·PNG·WebP·AVIF·CSS·JS·웹폰트)는 캐시 적합도가 가장 높아 장기 TTL 7~30일에 파일명 해싱과 Brotli·Gzip 압축을 함께 씁니다. 파일명 해싱과 immutable이 어울리는 자리죠. 반대로 동적 콘텐츠(API 응답, 개인화 페이지, 검색 결과)는 적합도가 가장 낮아 짧은 TTL 5~60초에 Stale-While-Revalidate를 얹는 식으로 다룹니다.
영상 스트리밍은 조금 특별합니다. 백서 기준 HLS·DASH는 영상을 2~10초 세그먼트로 쪼개 각 세그먼트를 독립적으로 캐싱하고, 대신 매니페스트 파일의 TTL은 짧게 가져갑니다. 백서 본문은 매니페스트 TTL을 라이브 방송이면 2~6초로 짧게 실시간성을 지키고, VOD면 60~120초 이상으로 늘려 캐시 효율을 높인다고 설명해요. 대용량 파일(게임 패치 등)은 Range Request로 부분 전송하되 장기 TTL을 유지해 출시 이후 오리진 부하를 줄입니다. 참고로 DRM 보호 영상 같은 보안 콘텐츠의 고급 인증이나 서명 URL은 캐시 설정과 별개의 솔루션 영역이라는 점도 백서가 선을 그어 둡니다.

이것만 기억하세요
캐시 수명은 Cache-Control로 정하며, max-age는 브라우저를, s-maxage는 CDN 같은 공유 캐시를 겨냥합니다(RFC 9111). no-cache는 저장을 막는 게 아니라 재사용마다 재검증을 요구하고, 진짜 저장 금지는 no-store예요. 정적은 장기 TTL과 immutable, 동적은 짧은 TTL과 재검증(ETag·304)이 기본입니다. immutable은 RFC 9111이 아닌 RFC 8246에서 정의된 디렉티브라는 점도 함께 기억해 두세요.
자주 묻는 질문 (FAQ)
Q. max-age와 s-maxage는 무엇이 다른가요?
적용 대상이 다릅니다. max-age는 모든 캐시에 적용되지만, s-maxage는 CDN 같은 공유 캐시에만 적용되며 그 경우 max-age와 Expires 값을 덮어씁니다. 브라우저 같은 private 캐시는 s-maxage를 무시하고 max-age를 따릅니다. 그래서 브라우저엔 짧게, CDN엔 길게 수명을 다르게 줄 수 있습니다.
Q. no-cache를 붙이면 캐시가 안 되나요?
아닙니다. no-cache는 저장을 막는 것이 아니라, 재사용하기 전에 매번 오리진으로 재검증하도록 요구하는 디렉티브입니다. RFC 9111 기준 저장 자체를 금지하는 것은 no-store입니다. 캐시에 아예 남기고 싶지 않다면 no-store, 저장하되 항상 최신인지 확인하고 싶다면 no-cache를 씁니다.
Q. immutable은 언제 쓰나요?
파일명에 해시가 붙어 내용이 바뀌면 이름도 바뀌는 정적 자산에 적합합니다. immutable은 fresh 상태 동안 갱신되지 않음을 뜻하므로, 브라우저가 만료 전 불필요한 재검증 요청조차 보내지 않게 합니다. 참고로 immutable은 RFC 9111이 아니라 RFC 8246에서 정의된 별도 디렉티브입니다.
Q. TTL이 만료되면 매번 전체를 다시 받아야 하나요?
아닙니다. ETag나 Last-Modified로 재검증하면, 내용이 그대로일 때 오리진이 304 Not Modified만 돌려주고 본문은 다시 보내지 않습니다. 저장된 응답을 그대로 재사용하면서 수명만 갱신되므로 전송량이 줄어듭니다. 갱신 중에도 사용자에게는 옛 응답을 즉시 보여 주려면 stale-while-revalidate를 함께 씁니다.
Q. 콘텐츠마다 TTL을 얼마로 줘야 하나요?
콘텐츠 성격에 따라 크게 다릅니다. 스피디 CDN 백서 기준으로 정적 콘텐츠는 장기 TTL 7~30일, 동적 콘텐츠는 짧은 TTL 5~60초에 Stale-While-Revalidate를 함께 씁니다. 영상은 세그먼트를 길게 캐싱하되 매니페스트는 라이브 2~6초, VOD 60~120초 이상으로 짧게 잡고, 대용량 파일은 Range Request와 장기 TTL을 함께 사용합니다.



