Cache-Control 완전 정리: 브라우저 캐시 제대로 쓰기
max-age·no-cache·no-store·immutable의 진짜 의미와 리소스 유형별 권장 캐시 정책.
Cache-Control은 웹 성능에서 가장 가성비가 좋은 헤더입니다. 한 줄만 제대로 설정하면 재방문 사용자는 CSS·JS·이미지를 네트워크 없이 디스크에서 바로 읽고, 서버 트래픽과 CDN 비용은 뚝 떨어집니다. 반대로 잘못 설정하면 “배포했는데 사용자 화면이 안 바뀐다”거나, 캐시하면 안 되는 개인 정보가 공유 캐시에 남는 사고가 납니다.
문제는 이 헤더의 지시어 이름이 직관과 어긋난다는 점입니다. no-cache는 “캐시하지 말라”는 뜻이 아니고, must-revalidate는 “매번 재검증하라”는 뜻이 아닙니다. 이 가이드는 각 지시어의 진짜 의미를 하나씩 바로잡고, 리소스 유형별 권장 정책 표와 실제 사이트에 그대로 적용할 수 있는 헤더 세트 예시까지 정리합니다. 값 조합이 헷갈리면 Cache-Control 생성기로 옵션을 체크하면서 완성된 헤더를 만들어 볼 수 있습니다.
지시어의 진짜 의미 — no-cache는 “캐시 금지”가 아니다
지시어는 크게 “얼마나 오래 신선한가(freshness)”, “누가 저장할 수 있는가(scope)”, “만료 후 어떻게 할 것인가(revalidation)” 세 축으로 나뉩니다.
max-age=N: 응답을 받은 시점부터 N초 동안은 “신선함”. 그동안 브라우저는 서버에 묻지도 않고 캐시본을 그대로 씁니다.max-age=31536000이면 1년입니다.no-cache: 저장은 하되, 쓸 때마다 서버에 재검증하라는 뜻입니다. “캐시 금지”가 아닙니다. 사실상max-age=0+ 항상 재검증과 같아서, 내용이 안 바뀌었으면304한 번으로 끝나 본문 전송 없이 캐시본을 재사용합니다.no-store: 이것이 진짜 “캐시 금지”입니다. 브라우저·CDN 어디에도 응답을 기록하지 말라는 뜻으로, 계좌 잔액·의료 기록처럼 디스크에 흔적이 남으면 안 되는 응답에만 씁니다.privatevspublic:private은 최종 사용자의 브라우저만 저장 가능(CDN·프록시 같은 공유 캐시 금지). 로그인 사용자별 응답에 필수입니다.public은 공유 캐시도 저장 허용 — 단,max-age가 있으면 대부분 기본적으로 공유 캐시가 저장하므로 명시가 꼭 필요한 경우는Authorization요청의 응답을 캐시시킬 때 정도입니다.s-maxage=N: 공유 캐시(CDN) 전용 수명. 브라우저는max-age를, CDN은s-maxage를 따르므로 “브라우저는 짧게, CDN은 길게” 같은 이원화가 가능합니다.immutable: 신선한 동안에는 사용자가 새로고침을 눌러도 재검증 요청조차 보내지 말라는 힌트. 파일명에 해시가 박혀 내용이 절대 안 바뀌는 자산에 붙입니다.must-revalidate: “매번 재검증”이 아니라, 만료된 뒤에는 서버 확인 없이 낡은 캐시본을 쓰지 말라는 뜻입니다. 신선한 동안에는 아무 영향이 없습니다. 서버 연결이 안 될 때 낡은 응답을 내주는 예외 동작을 막습니다.stale-while-revalidate=N: 만료 후 N초 동안은 일단 낡은 캐시본을 즉시 보여 주고, 뒤에서 조용히 새 버전을 받아 다음 요청부터 반영. 체감 속도와 신선도를 동시에 잡는 지시어로, 적정 값 설계는 SWR 플래너가 도와줍니다.
재검증의 원리 — ETag와 304 Not Modified
no-cache나 만료된 캐시가 값싸게 동작하는 비결이 조건부 요청입니다. 서버가 처음 응답에 ETag: "abc123"(내용 지문) 또는 Last-Modified를 실어 보내면, 브라우저는 다음 요청에 If-None-Match: "abc123"을 붙입니다. 내용이 그대로면 서버는 본문 없이 304 Not Modified만 돌려주고, 브라우저는 갖고 있던 캐시본을 다시 씁니다. 100KB짜리 HTML도 재검증은 수백 바이트로 끝나는 셈입니다.
그래서 no-cache(또는 max-age=0)를 쓸 때는 반드시 ETag나Last-Modified가 함께 나가는지 확인해야 합니다. 검증자가 없으면 재검증이 불가능해 매번 200으로 전체 본문을 다시 받게 됩니다. 실제 응답에 어떤 캐시 헤더와 검증자가 붙어 나가는지는 HTTP 헤더 조회로 바로 확인할 수 있습니다.
핵심 패턴 — 해시 파일명 자산은 1년, HTML은 항상 재검증
현대 웹 캐싱 전략의 뼈대는 단 두 줄입니다. 빌드 도구(웹팩·Vite 등)가 파일 내용의 해시를 파일명에 박아app.3f9a2c.js처럼 만들면, 내용이 1바이트라도 바뀌는 순간 파일명 자체가 바뀝니다. 즉 같은 URL의 내용은 영원히 동일하므로 마음 놓고 1년을 캐시할 수 있습니다.
- 해시 파일명 자산:
Cache-Control: public, max-age=31536000, immutable— 1년 캐시 + 새로고침 시 재검증 요청도 생략. - HTML(진입점):
Cache-Control: no-cache— 항상 서버에 확인하되 304로 싸게. HTML이 새 해시 파일명을 참조하므로, HTML만 신선하면 배포가 즉시 반영됩니다.
HTML이 문지기, 해시 자산이 창고인 구조입니다. 문지기(HTML)는 매번 신분 확인(재검증)을 하고, 창고의 물건 (해시 자산)은 이름표가 바뀌기 전까지 절대 안 바뀌니 검사가 필요 없습니다.
| 리소스 유형 | 권장 Cache-Control | 이유 |
|---|---|---|
app.3f9a2c.js 등 해시 자산 | public, max-age=31536000, immutable | 내용이 바뀌면 URL이 바뀌므로 영구 캐시 안전 |
| HTML 문서 | no-cache (+ ETag) | 배포 즉시 반영, 재검증은 304로 저렴 |
| 이미지·폰트(해시 없음) | public, max-age=86400, stale-while-revalidate=604800 | 하루 캐시, 만료 후 1주는 낡은 것 먼저 + 백그라운드 갱신 |
| 공개 API 응답 | public, max-age=60, s-maxage=300 | 브라우저 1분, CDN 5분 — CDN 무효화로 통제 가능 |
| 로그인 사용자 데이터 | private, no-cache | 공유 캐시 저장 금지, 브라우저는 재검증 후 사용 |
| 민감 정보(금융·의료) | no-store | 어디에도 기록 자체를 남기지 않음 |
흔한 실수 3가지
- no-cache를 “캐시 금지”로 오해: 민감 데이터에
no-cache만 걸면 응답이 디스크에 저장됩니다(재검증만 강제될 뿐). 저장 자체를 막으려면no-store가 맞습니다. 반대로 “항상 최신을 보여 주되 빠르게”가 목적인데no-store를 걸면 304 재사용까지 포기하게 되어 손해입니다. - HTML에 긴 max-age:
index.html에max-age=86400을 걸면, 배포를 해도 사용자 브라우저는 하루 동안 옛 HTML을 그대로 씁니다. 옛 HTML은 이미 삭제된 옛 해시 자산을 참조하다 404를 만나 화면이 깨지기도 합니다. “배포했는데 반영이 안 돼요”의 최다 원인입니다. private+s-maxage동시 사용:s-maxage는 공유 캐시용 수명인데private은 공유 캐시 저장을 금지합니다. 서로 모순이라s-maxage가 무의미해집니다. 사용자별 응답이면private만, CDN 캐시를 태우려면public(또는 생략) +s-maxage로 정리하세요.
실전 예시 — 전형적인 사이트의 헤더 세트
정적 사이트 + API를 CDN 뒤에 두는 흔한 구성이라면 이 네 줄이 사실상 정답에 가깝습니다.
| 경로 | 헤더 |
|---|---|
/assets/* (해시 파일명) | Cache-Control: public, max-age=31536000, immutable |
/*.html, / | Cache-Control: no-cache + ETag |
/images/* | Cache-Control: public, max-age=86400, stale-while-revalidate=604800 |
/api/public/* | Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=60 |
/api/me 등 개인 데이터 | Cache-Control: private, no-cache |
적용 후에는 HTTP 헤더 조회로 각 경로의 실제 응답 헤더를 확인하고, 조합을 바꿔 볼 때는 Cache-Control 생성기로 지시어 충돌 없이 헤더 문자열을 만들어 붙여 넣으면 됩니다. API에 SWR을 얹을 때 만료·유예 구간을 시뮬레이션해 보려면 SWR 플래너를 활용하세요.
자주 묻는 질문
no-cache와 no-store의 차이는 무엇인가요?
배포했는데 사용자 화면이 안 바뀝니다. 왜 그런가요?
immutable은 언제 붙이나요?
max-age와 s-maxage를 같이 쓰면 어떻게 되나요?
must-revalidate는 매번 재검증하라는 뜻 아닌가요?
이 가이드와 함께 쓰면 좋은 도구
관련 가이드
- 사이트가 느릴 때: TTFB 원인 진단과 개선 순서첫 바이트까지의 시간(TTFB)이 늘어지는 원인 — DNS·TLS·서버·DB — 을 구간별로 진단하고 줄이는 법.
- gzip·brotli 압축 적용: 전송량 70% 줄이기텍스트 리소스 압축이 주는 효과와 nginx·Apache 설정법, 적용 확인과 흔한 실수.
- Core Web Vitals(LCP·CLS·INP) 개선 체크리스트LCP·CLS·INP가 무엇이고 무엇을 측정하는지, 점수를 올리는 실전 체크리스트.