들어가며 — 배포했는데 사용자는 옛날 번들을 본다
배포가 끝났고, 서버에는 새 파일이 올라가 있고, 시크릿 창에서는 새 화면이 나옵니다. 그런데 일부 사용자는 여전히 옛날 화면을 봅니다. 강력 새로고침을 안내하면 해결되지만, 그 안내가 필요한 시점에 이미 캐싱 설계가 잘못됐다는 뜻입니다.
HTTP 캐싱은 지시어 몇 개를 외우는 문제가 아니라, 어느 계층이 무엇을 얼마나 오래 들고 있고 그것을 어떻게 되돌릴 수 있는지를 아는 문제입니다. 그리고 되돌릴 수 없는 계층이 하나 있습니다. 사용자의 브라우저입니다.
Cache-Control 지시어의 정확한 의미
가장 많이 오해되는 지시어부터 정리합니다.
| 지시어 | 정확한 의미 | 흔한 오해 |
|---|---|---|
| max-age=N | 응답 생성 시점부터 N초간 신선. 신선한 동안 원 서버에 묻지 않음 | 브라우저가 N초마다 확인한다고 생각 |
| s-maxage=N | 공유 캐시에만 적용되며 그곳에서 max-age를 덮어씀 | 브라우저에도 적용된다고 생각 |
| no-cache | 저장은 하되 재사용 전에 반드시 원 서버에 검증 | 캐시하지 말라는 뜻으로 이해 |
| no-store | 어떤 캐시에도 저장 금지. 디스크에도 남기지 않음 | no-cache와 같은 것으로 이해 |
| must-revalidate | 신선도가 끝난 뒤에는 검증 없이 제공 금지. 오류 시 stale 제공도 금지 | 항상 검증하라는 뜻으로 이해 |
| private | 브라우저 등 단일 사용자 캐시에만 저장 가능 | 암호화나 접근 제어로 이해 |
| public | 평소라면 저장되지 않을 응답도 공유 캐시에 저장 가능하다고 표시 | 캐싱을 켜는 스위치로 이해 |
| immutable | 신선한 동안에는 새로고침에도 재검증하지 않음 | 영원히 캐시된다고 이해 |
| stale-while-revalidate=N | 만료 후 N초간 stale 응답을 즉시 주고 백그라운드로 갱신 | max-age를 늘리는 것과 같다고 이해 |
no-cache와 no-store의 차이가 결정적입니다. 로그인한 사용자의 계좌 페이지에 no-cache를 붙이면 응답은 디스크에 저장됩니다. 공용 PC에서 뒤로 가기를 누르거나 캐시 디렉터리를 뒤지면 내용이 남아 있습니다. 저장 자체를 막으려면 no-store여야 합니다.
반대로 자주 보는 것이 이 조합입니다.
Cache-Control: no-cache, no-store, must-revalidate
Pragma: no-cache
Expires: 0
no-store가 있으면 저장이 안 되므로 나머지는 의미가 없습니다. must-revalidate는 저장된 응답에 대한 규칙이고, Pragma는 HTTP/1.0 시절의 요청 헤더라 응답에 붙여도 현대 캐시는 보지 않습니다. 관행처럼 복사되어 다니지만 Cache-Control: no-store 한 줄이면 충분합니다.
또 하나 알아야 할 것은 아무 캐시 헤더도 안 보내는 것이 캐시 금지를 뜻하지 않는다는 점입니다. 명시적 신선도 정보가 없으면 캐시는 휴리스틱으로 수명을 추정할 수 있고, 관행적으로 Last-Modified로부터 경과한 시간의 10퍼센트 정도를 씁니다. 한 달 전에 수정된 파일이라면 3일가량 캐시될 수 있다는 뜻입니다. "캐시 설정을 안 했으니 캐시되지 않을 것"이라는 가정은 성립하지 않습니다.
조건부 요청과 304 — 절약하는 것과 절약하지 못하는 것
신선도가 끝난 응답을 캐시가 버리지는 않습니다. 검증자를 들고 원 서버에 물어봅니다.
GET /assets/logo.svg HTTP/1.1
Host: cdn.example.com
If-None-Match: "8f3c1a20-4e2"
If-Modified-Since: Wed, 09 Jul 2026 11:20:14 GMT
HTTP/1.1 304 Not Modified
ETag: "8f3c1a20-4e2"
Cache-Control: max-age=600
Date: Sun, 26 Jul 2026 04:20:11 GMT
본문이 없습니다. 12KB짜리 SVG를 다시 받지 않았으니 대역폭을 아꼈습니다.
여기가 중요한 지점입니다. 304는 대역폭을 아끼지만 왕복 시간은 그대로 듭니다. 왕복 지연이 180밀리초인 모바일 회선에서 자산 30개를 검증하면, 아무것도 바뀌지 않았는데도 상당한 시간을 씁니다. HTTP/2에서 다중화되어 병렬로 처리되더라도 최소 한 번의 왕복은 남습니다.
그래서 조건부 요청은 목적이 다릅니다. 자주 바뀌는 리소스에서 전송량을 줄이는 데는 좋고, 정적 자산의 로딩 시간을 줄이는 데는 부족합니다. 정적 자산은 요청 자체를 없애야 합니다.
ETag를 쓸 때 실무에서 걸리는 문제도 있습니다. 여러 대의 서버가 같은 파일을 서빙하는데 ETag가 서버마다 다르면, 사용자가 다른 노드로 라우팅될 때마다 304가 아니라 200이 돌아옵니다. 파일의 inode 번호를 ETag 계산에 포함하는 기본 설정이 이 문제를 일으킵니다. 내용 해시 기반으로 바꾸거나 해당 요소를 빼야 합니다.
압축도 영향을 줍니다. 응답을 gzip이나 brotli로 인코딩하면 바이트가 달라지므로 강한 ETag를 그대로 쓰면 안 됩니다. 약한 ETag를 쓰거나 인코딩별로 값을 분리해야 하고, Vary: Accept-Encoding도 함께 필요합니다.
Last-Modified는 초 단위 해상도라는 한계가 있습니다. 같은 초에 두 번 바뀌면 구분하지 못합니다. 둘 다 보낼 수 있으면 보내되, 정확성이 필요하면 ETag가 우선입니다.
해시 파일명과 immutable — 프런트엔드 배포의 표준 조합
번들러가 app.4f2a1c9e.js 같은 파일명을 만드는 이유가 여기 있습니다. 내용이 바뀌면 해시가 바뀌고, 해시가 바뀌면 URL이 바뀝니다. URL이 다르면 캐시 키가 다르므로 옛 캐시와 충돌할 일이 없습니다.
그러면 이 파일은 영원히 캐시해도 안전합니다.
# 해시가 들어간 정적 자산
Cache-Control: public, max-age=31536000, immutable
# 진입점 HTML
Cache-Control: no-cache
immutable이 없으면 사용자가 새로고침을 눌렀을 때 브라우저는 신선한 자산에 대해서도 조건부 요청을 보냅니다. 자산이 40개면 새로고침마다 304가 40개 오갑니다. immutable은 그 재검증까지 생략시킵니다.
이 조합의 핵심은 무효화 대상이 딱 하나로 줄어든다는 것입니다. HTML만 매번 검증하고, HTML이 참조하는 자산 URL이 바뀌면 새 자산이 자동으로 받아집니다. 캐시 무효화가 어렵다는 문제를 무효화하지 않는 설계로 치환한 것입니다.
주의할 점이 두 가지 있습니다. HTML에 긴 max-age를 붙이면 이 구조가 통째로 무너집니다. 사용자가 옛 HTML을 들고 있으면 옛 자산 URL을 계속 참조하기 때문입니다. 그리고 배포할 때 옛 자산 파일을 즉시 지우면, 옛 HTML을 들고 있는 사용자가 404를 만납니다. 이전 몇 개 버전의 자산은 남겨 두어야 합니다.
세 개의 캐시 계층과 서로 다른 무효화 방법
같은 응답이 여러 곳에 동시에 저장됩니다. 각 계층은 통제 수단이 다릅니다.
브라우저 캐시는 사용자의 기기에 있습니다. 여기에는 무효화 명령을 보낼 방법이 없습니다. 한 번 max-age를 1년으로 내보낸 URL은 그 사용자가 캐시를 지우기 전까지 되돌릴 수 없습니다. 그래서 브라우저에 내보내는 TTL은 되돌릴 필요가 없다고 확신하는 경우에만 길게 잡습니다. 해시 파일명이 그 확신을 만들어 줍니다.
CDN 캐시는 우리 통제 아래 있습니다. 퍼지 API로 URL이나 태그 단위로 지울 수 있고, 대부분의 CDN이 서로게이트 키나 캐시 태그를 지원합니다. 상품 하나가 바뀌면 그 상품 태그가 붙은 모든 페이지를 한 번에 지우는 식입니다. 다만 전 세계 엣지에 반영되는 데 수 초에서 수십 초가 걸립니다.
리버스 프록시 캐시는 우리 인프라 안에 있습니다. nginx의 캐시 퍼지, Varnish의 ban이나 purge로 지웁니다. 정규식으로 범위를 지정할 수 있어 유연하지만, 넓은 범위를 한 번에 지우면 원 서버로 요청이 몰립니다.
네 번째 계층이 하나 더 있는데 자주 잊힙니다. 서비스 워커 캐시입니다. 이것은 Cache-Control을 따르지 않고 우리가 작성한 코드가 정한 대로 동작합니다. 배포했는데 특정 사용자만 계속 옛 화면을 본다면 서비스 워커를 먼저 의심해야 합니다.
계층별로 다른 TTL을 주고 싶을 때가 많습니다. 브라우저에는 짧게, CDN에는 길게 주면 배포 시 퍼지로 즉시 반영하면서 원 서버 부하는 낮게 유지할 수 있습니다.
Cache-Control: public, max-age=60
CDN-Cache-Control: public, max-age=86400
CDN-Cache-Control은 CDN만 해석하고 브라우저는 무시합니다. s-maxage로도 비슷한 효과를 내지만, 그 값은 모든 공유 캐시에 적용되므로 회사 프록시 같은 중간 캐시에도 함께 걸린다는 차이가 있습니다.
Vary와 캐시 키 폭발
캐시 키는 기본적으로 메서드와 URL입니다. 같은 URL인데 요청 헤더에 따라 응답이 달라진다면 그 사실을 알려야 합니다.
Vary: Accept-Encoding, Origin
이것은 필수입니다. gzip 응답이 압축을 지원하지 않는 클라이언트에게 나가거나, CORS 헤더가 다른 오리진용으로 저장된 채 재사용되는 사고가 여기서 나옵니다.
문제는 Vary에 무엇을 넣느냐입니다. 나열한 헤더의 값 조합마다 별도의 캐시 항목이 생기기 때문입니다.
Vary: User-Agent를 붙이면 사실상 캐시가 꺼집니다. 브라우저 버전과 OS 조합만큼 항목이 생기고, 실제 트래픽에서 그 종류는 수만 가지입니다. 적중률이 0에 수렴합니다. 기기 종류에 따라 다른 응답이 필요하다면 엣지에서 UA를 모바일과 데스크톱 같은 소수의 값으로 정규화한 뒤 그 값을 기준으로 나누어야 합니다.
Vary: Cookie는 더 위험합니다. 분석 도구가 심어 놓은 쿠키 하나만 있어도 사용자마다 값이 달라지므로 항목이 사용자 수만큼 생깁니다. 캐시가 꺼지는 정도면 다행이고, 저장 공간이 폭발해서 다른 콘텐츠까지 밀려납니다. 캐시 키에 넣을 쿠키만 화이트리스트로 지정하는 기능이 대부분의 CDN에 있으니 그것을 씁니다.
Vary: Accept-Language도 언어 협상 헤더의 조합이 다양해서 생각보다 항목이 많이 생깁니다. 경로에 언어를 넣어 URL로 구분하는 편이 캐시 친화적입니다.
stale-while-revalidate와 API 응답 캐싱의 함정
만료된 캐시를 만나면 사용자는 원 서버 응답을 기다려야 합니다. 인기 있는 페이지의 캐시가 만료되는 순간 동시에 들어온 요청들이 전부 원 서버로 가는 문제도 있습니다.
Cache-Control: public, max-age=60, stale-while-revalidate=600, stale-if-error=86400
60초 동안은 신선합니다. 그 이후 600초 동안은 캐시된 응답을 즉시 돌려주면서 뒤에서 새 응답을 받아 저장합니다. 사용자 입장에서는 항상 캐시 적중 속도이고, 콘텐츠는 최대 60초 남짓 오래됐을 뿐입니다. stale-if-error는 원 서버가 5xx를 내거나 도달 불가일 때 하루 동안 옛 응답이라도 돌려줍니다. 장애 시간을 사용자에게 감추는 값싼 방법입니다.
이 조합은 목록 페이지, 홈 화면, 공개 API의 읽기 응답처럼 초 단위 신선도가 필요 없는 곳에 잘 맞습니다. 반대로 재고 수량이나 잔액처럼 오래된 값이 곧 오류가 되는 데이터에는 쓰면 안 됩니다.
인증된 응답을 공유 캐시에 흘리는 사고
API 응답 캐싱에서 가장 큰 사고는 성능이 아니라 데이터 노출입니다.
Authorization 헤더가 실린 요청의 응답은 공유 캐시가 저장하지 않는 것이 원칙입니다. 명시적으로 public이나 s-maxage를 붙이지 않는 한 그렇습니다. 그런데 쿠키로 인증하는 요청에는 이 보호가 적용되지 않습니다. 쿠키 기반 로그인 사이트가 사용자별 HTML에 Cache-Control: max-age=300을 붙이면, CDN이 사용자 A의 페이지를 저장했다가 사용자 B에게 그대로 돌려줍니다. 실제로 여러 서비스에서 발생했고 원인 파악이 늦어지기 쉬운 유형입니다.
방어는 단순합니다. 사용자별 응답에는 반드시 private을 붙이고, 민감한 것은 no-store로 갑니다.
# 사용자별 응답
Cache-Control: private, no-store
# 공개 목록 응답
Cache-Control: public, max-age=30, stale-while-revalidate=300
더 나은 방법은 애초에 경로를 분리하는 것입니다. 공개 데이터와 사용자별 데이터를 다른 엔드포인트로 나누면, 공개 쪽은 마음 놓고 CDN에 태우고 사용자별 쪽은 캐시를 끄면 됩니다. 한 응답 안에 둘을 섞으면 전체를 가장 보수적인 정책에 맞춰야 합니다.
API에 ETag를 붙여 클라이언트의 폴링 비용을 줄이는 것도 유용하지만, 조건부 요청이 오면 서버는 여전히 ETag를 계산해야 합니다. 갱신 시각 컬럼이나 버전 카운터처럼 값싸게 얻을 수 있는 근거가 있을 때만 실익이 있습니다. 전체 응답을 만들어서 해시를 뜬 뒤 304를 돌려주면 대역폭만 아끼고 서버 부하는 그대로입니다.
마치며 — 무효화가 어려우면 무효화하지 않는 설계로
캐시 무효화가 어렵다는 말은 과장이 아닙니다. 브라우저 캐시에는 명령을 보낼 수 없고, CDN 퍼지는 전파에 시간이 걸리고, 계층마다 방법이 다르고, 서비스 워커는 규칙을 따르지 않습니다. 그래서 잘 만든 시스템은 무효화를 정교하게 하는 대신 무효화할 일을 줄입니다.
내용이 바뀌면 URL이 바뀌게 만들고, 그 URL은 영원히 캐시합니다. 바뀌는 것은 짧게 검증하는 진입점 하나로 모읍니다. 초 단위 신선도가 필요 없는 것은 stale-while-revalidate로 지연 없이 서빙합니다. 사용자별 데이터는 경로부터 분리해서 캐시 정책이 섞이지 않게 합니다.
캐싱 설계에서 먼저 정할 것은 TTL 숫자가 아니라 이 응답을 나중에 되돌려야 할 가능성이 있는가입니다. 되돌릴 일이 없다고 확신할 수 있는 구조를 만들면, 나머지 설정은 저절로 따라옵니다.
현재 단락 (1/73)
배포가 끝났고, 서버에는 새 파일이 올라가 있고, 시크릿 창에서는 새 화면이 나옵니다. 그런데 일부 사용자는 여전히 옛날 화면을 봅니다. 강력 새로고침을 안내하면 해결되지만, 그 ...