Cache-Control 헤더 정리: max-age부터 stale-while-revalidate까지

Cache-Control 헤더 정리: max-age부터 stale-while-revalidate까지

웹 페이지를 두 번째 열 때 첫 번째보다 훨씬 빨리 그려지는 경험을 한 번쯤 해보셨을 겁니다. 어딘가에서 그 자원을 미리 들고 있다가 다시 꺼내준 결과인데, 이 “미리 들고 있다”가 곧 캐시이고요. 브라우저, 회사 프록시, CDN, 원본 서버 앞 모두 캐시가 자리할 수 있어 한 자원이 여러 곳에 사본을 가질 수 있는 셈입니다.

문제는 이렇게 흩어진 캐시들이 서로 다른 규칙으로 동작하면 곤란하다는 점입니다. 한 캐시는 1시간을 보관하고 다른 캐시는 영원히 보관하는 식이면 운영자가 원하는 정책을 만들 수가 없죠. 그래서 HTTP는 모든 캐시가 따라야 할 공통 약속을 하나 정해 두었는데, 그 약속을 담는 응답 헤더가 바로 Cache-Control입니다. 이번 글에서는 Cache-Control 헤더에 어떤 디렉티브들이 있고 각각 어떤 일을 하는지를 한 번에 풀어보겠습니다.

Cache-Control이 동작하는 무대

Cache-Control 디렉티브를 살펴보기 전에, 이 헤더가 어떤 캐시들에게 말을 거는지 짚고 가면 이해가 쉽습니다.

가장 가까운 캐시는 브라우저 캐시입니다. 같은 사람이 같은 페이지를 다시 열 때, 디스크에 저장해 둔 파일을 그대로 꺼내 쓰는 자리죠. 그 다음이 회사 프록시나 ISP, CDN(Content Delivery Network) 같은 공유 프록시 캐시입니다. Cloudflare, Akamai, CloudFront 같은 서비스가 전 세계에 분산된 엣지 노드에 자원을 캐시해 두고 가까운 사용자에게 응답하죠. 마지막으로 원본 서버 앞에 둔 리버스 프록시로드 밸런서 캐시(nginx의 proxy_cache, Varnish 등)도 같은 헤더를 해석합니다.

Cache-Control 헤더는 이 모든 캐시에 한꺼번에 말을 거는 도구라, 응답에 한 줄을 잘 적어두면 흩어진 캐시들이 통일된 정책으로 움직여 줍니다.

신선도 정하기: max-age

Cache-Control에서 가장 자주 보는 디렉티브가 max-age입니다. Cache-Control: max-age=3600이라고 적으면 “이 자원은 1시간 동안 신선하다고 봐도 된다”는 뜻이라, 그 시간 동안은 캐시된 사본을 그대로 써도 됩니다.

기본적인 max-age 응답
HTTP/1.1 200 OK
Cache-Control: max-age=3600
Content-Type: text/html

시간이 지나면 사본이 만료(stale)됩니다. 이때부터 캐시는 원본 서버에 다시 물어봐서 새 사본을 받든지 기존 것을 갱신하든지 결정해야 하죠. “신선하다”는 기간이 곧 사용자가 옛 버전을 보아도 괜찮은 시간이고, 운영자가 정책으로 정하는 가장 굵직한 결정거리이기도 합니다.

값이 작을수록 안전하지만 원본 서버의 부담이 커지고, 클수록 빠르지만 새 버전이 늦게 도달합니다. 정적 자산은 길게, HTML이나 자주 바뀌는 데이터는 짧게 가는 것이 일반적인 출발점이에요.

누가 캐시할 수 있나: public, private, no-store

응답을 어디에 캐시해도 되는지를 정하는 디렉티브가 따로 있습니다. public은 CDN이나 프록시 같은 공유 캐시에 저장해도 좋다는 신호이고, private은 브라우저 캐시처럼 한 사용자만 쓰는 자리에만 저장하라는 뜻입니다.

로그인 사용자의 마이페이지
Cache-Control: private, max-age=60

로그인한 사용자의 페이지처럼 사용자마다 다른 응답에는 private을 꼭 붙여야 합니다. 이 한 줄이 빠지면 한 사람의 마이페이지가 CDN에 캐시되어 다음 사용자에게 그대로 흘러가는 사고가 일어날 수 있는데, HTTP 캐시 사고 중 가장 위험한 부류이기도 하죠.

아예 캐시하면 안 되는 자원에는 no-store를 붙입니다. 은행 거래 내역, 비밀번호 변경 화면, 일회성 토큰 같은 민감 정보가 여기에 해당합니다. no-store는 어떤 캐시도, 심지어 디스크에도 응답을 남기지 말라는 강한 약속이라 정말 필요한 자리에만 사용하는 것이 좋습니다.

매번 묻게 만들기: no-cache와 ETag

이름만 보면 no-store와 비슷해 보이는 no-cache는 사실 의미가 다릅니다. “캐시는 해도 되지만 쓸 때마다 서버에 검증을 받으라”는 약속이라, 캐시를 막는 게 아니라 매번 새로 확인하게 만드는 옵션이죠.

검증을 위해 함께 쓰는 헤더가 ETag입니다. 서버가 응답을 보낼 때 본문의 해시 같은 식별자를 ETag에 적어두면, 캐시는 다음 요청 때 If-None-Match 헤더에 그 값을 담아 서버에 보냅니다. 서버는 현재 자원의 ETag와 비교해 같으면 304 Not Modified만 돌려주고 본문은 보내지 않습니다.

검증 요청과 304 응답
GET /style.css HTTP/1.1
If-None-Match: "abc123"

HTTP/1.1 304 Not Modified
Cache-Control: no-cache
ETag: "abc123"

본문이 없는 응답이라 네트워크 비용이 거의 들지 않는 게 핵심입니다. 파일이 클수록 검증으로 절약되는 트래픽이 커지죠. Last-Modified 헤더도 비슷한 일을 시각으로 처리하지만, 더 정밀한 ETag가 보편화되면서 보조적으로 쓰이는 경우가 많습니다.

절대 묻지 않게 만들기: immutable

검증조차 보내지 않게 만들고 싶을 때가 있습니다. 파일 이름에 내용 해시가 박힌 정적 자산처럼 절대 변하지 않는 파일이 그렇죠.

해시가 박힌 정적 자산
Cache-Control: max-age=31536000, immutable

max-age=31536000, immutable이면 “1년 동안 신선하고, 만료되기 전엔 검증조차 보내지 마라”는 뜻입니다. 사용자가 새로고침을 해도 캐시 사본을 그대로 쓰니, 같은 자원에 대한 네트워크 요청이 거의 일어나지 않게 되죠.

immutable이 효과를 발휘하려면 자원이 정말 안 변해야 합니다. 파일 이름에 해시를 박는 패턴이 짝꿍처럼 함께 쓰이는 이유인데, 이 부분은 뒤에서 따로 다뤄보겠습니다.

공유 캐시만 다르게: s-maxage

자원이 브라우저와 CDN에서 다른 캐시 시간을 가져야 할 때가 있습니다. 사용자 브라우저에는 짧게 두고 CDN에는 길게 두고 싶을 때 같은 경우인데요. 이때 쓰는 디렉티브가 s-maxage입니다.

공유 캐시만 길게 잡기
Cache-Control: max-age=60, s-maxage=600

브라우저는 max-age 값인 60초만 캐시하고, CDN 같은 공유 캐시는 s-maxage 값인 600초를 따릅니다. 원본 서버가 받는 요청 수를 크게 줄이면서, 사용자 화면은 비교적 자주 갱신되도록 만들어 주는 조합이죠. CDN 쪽에서 변경 즉시 반영하고 싶다면 무효화(purge) API와 함께 쓰면 효과가 큽니다.

만료 이후의 회색지대: stale-while-revalidate

전통적인 캐시 동작은 만료가 되면 사용자가 기다리는 동안 새 사본을 받아 와야 했습니다. 이 흐름을 부드럽게 만들어 주는 디렉티브가 stale-while-revalidate입니다.

만료 이후에도 부드럽게
Cache-Control: max-age=60, stale-while-revalidate=600

1분 동안은 신선한 사본을 그대로 쓰고, 그 뒤 10분 동안은 만료된 사본을 우선 돌려주면서 백그라운드로 새 사본을 받는 식으로 동작합니다. 사용자는 응답을 기다리지 않고 바로 화면을 보고, 새 사본은 다음번 요청에 반영되는 구조죠. 약간의 신선도를 양보하는 대신 응답 시간을 크게 줄여 주는 도구라, 자주 바뀌지만 약간 옛날 버전이어도 큰 문제가 없는 자원(블로그 목록, 추천 상품 등)에 잘 어울립니다.

비슷한 디렉티브로 stale-if-error도 있습니다. 서버가 일시적으로 죽었을 때, 만료된 캐시 사본을 일정 시간 동안 대신 돌려주도록 만드는 옵션이죠. 서비스 장애 상황에서도 사용자에게 빈 화면 대신 이전 화면이라도 보여 줄 수 있어, 회복탄력성을 끌어올리는 작은 안전망이 됩니다.

Cache-Control 옆에 함께 쓰는 헤더: Vary

같은 URL이라도 응답이 사용자별로 달라질 수 있습니다. 한국어 사용자에게는 한국어 페이지를, 영어 사용자에게는 영어 페이지를 돌려주는 식이죠. 이때 캐시가 한 사용자의 응답을 다른 사용자에게 그대로 돌려주면 곤란합니다.

이 문제를 풀기 위한 헤더가 Vary입니다. Cache-Control이 “얼마나 캐시할지”를 결정한다면, Vary는 “캐시 키를 어떻게 나눌지”를 결정한다고 보면 쉽습니다.

언어별로 캐시 분리
Cache-Control: public, max-age=3600
Vary: Accept-Language

이렇게 적으면 같은 URL이라도 Accept-Language 값이 다르면 별도의 캐시 항목으로 취급됩니다. 사용자별로 캐시가 분리되니 각자 맞는 응답을 받을 수 있죠.

흔히 함께 쓰이는 값이 Vary: Accept-Encoding입니다. gzip을 받을 수 있는 클라이언트와 그렇지 못한 클라이언트에게 같은 자원을 다른 형태로 돌려주기 때문에, 인코딩별로 캐시를 나눠 둬야 사고가 나지 않습니다. 다만 Vary가 늘어날수록 캐시 항목이 잘게 쪼개져 적중률이 떨어지므로, 꼭 필요한 헤더만 Vary 대상으로 두는 것이 좋습니다.

캐시 무효화: 파일 이름에 해시 박기

Cache-Control을 길게 잡을수록 빠르지만, 새 버전이 영영 도달하지 못하는 위험도 커집니다. 이 충돌을 푸는 가장 흔한 방법이 파일 이름에 해시를 박는 것이에요. app.css가 아니라 app.abc123.css처럼 내용 해시가 들어간 파일명을 쓰면, 내용이 바뀌면 파일명도 바뀌니 사용자는 자연스럽게 새 URL의 자원을 받게 됩니다.

이렇게 만들고 나면 정적 자산에 immutable과 1년짜리 max-age를 자신 있게 붙일 수 있습니다. 파일 자체는 영영 안 바뀌는 자원이 되고, HTML이 새 파일명을 가리키게 되는 순간 새 버전이 자연스럽게 흘러가죠. 대부분의 모던 번들러(Webpack, Vite, esbuild 등)가 빌드 결과물에 해시를 박아 주는 게 이런 이유 때문입니다.

API 응답이나 동적 페이지처럼 URL이 바뀌지 않는 자원은 다른 방식이 필요합니다. CDN의 무효화(purge) API를 호출해 캐시를 명시적으로 비우거나, 응답 헤더에 짧은 max-age를 두고 자주 갱신되도록 만드는 식이죠. 어느 쪽이든 캐시 길이는 “이 자원이 얼마나 빨리 바뀌느냐”와 “사용자가 옛 버전을 얼마나 견딜 수 있느냐” 사이의 균형을 맞춰 잡습니다.

Cache-Control이 일으키는 흔한 사고

디렉티브가 강력한 만큼 잘못 다루면 사고를 일으킵니다. 가장 자주 마주치는 함정이 로그인된 사용자 정보가 공유 캐시에 들어가는 경우입니다. Cache-Control: private을 빠뜨리면 한 사람의 마이페이지가 CDN에 캐시되어 다음 사용자에게 그대로 흘러갈 수 있고, 한 번 잘못 캐시되면 무효화 조치를 취해도 이미 노출된 정보는 되돌릴 수 없습니다.

또 다른 함정이 너무 긴 캐시 시간입니다. HTML 파일에 1년짜리 max-age를 붙이면 새 배포가 사용자에게 영영 도달하지 못하죠. HTML은 보통 max-age를 짧게(또는 0으로) 두고, 그 안에서 가리키는 정적 자산만 길게 캐시하는 구성이 안전합니다. “진입점은 짧게, 진입점이 가리키는 자원은 길게”라는 패턴이 익숙해지면 대부분의 캐시 사고를 피할 수 있습니다.

마지막으로 자주 헷갈리는 게 no-cache와 no-store의 의미 차이입니다. 앞에서 짚었듯 no-cache는 캐시는 하되 매번 검증을 받으라는 뜻이고, no-store가 진짜로 캐시를 막는 옵션입니다. 정말 민감한 정보라면 no-store를 써야 하고, 단지 항상 최신을 보장하고 싶을 뿐이라면 no-cache로 충분하다는 점을 기억해 두면 좋습니다.

마치며

Cache-Control 헤더는 한 줄 안에 신선도, 저장 위치, 검증, 만료 이후 동작까지 굵직한 정책을 모두 담을 수 있는 도구입니다. 디렉티브 하나하나의 의미만 손에 익으면, 응답에 어떤 조합을 적어둘지가 곧 캐시 정책 그 자체가 되는 셈이죠.

처음 도입한다면 정적 자산에 max-age=31536000, immutable을 붙이고 사용자별 페이지에 private을 빠뜨리지 않는 것부터 시작해 보시길 권합니다. 모든 디렉티브의 정확한 정의가 궁금하다면 MDN의 Cache-Control 문서를 추천합니다.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord