HTTP 캐시 제대로 쓰기: Cache-Control, ETag, no-cache와 no-store의 차이
no-cache는 '캐시하지 마라'가 아니다. Cache-Control의 max-age, no-cache, no-store, s-maxage, immutable이 각각 어떻게 동작하는지 실제 브라우저로 서버에 요청이 몇 번 가는지 확인하고, ETag와 304, 배포해도 옛날 파일이 보이는 문제, Vercel CDN 캐시까지 정리했다.
배포했는데 왜 옛날 화면이 보이지?
배포를 마쳤는데 사용자는 여전히 예전 화면을 본다. 반대로 "캐시하지 말라"고 no-cache를 붙였는데, 서버 로그를 보면 요청이 생각보다 적게 온다. 둘 다 HTTP 캐시를 잘못 이해해서 생기는 일이다.
HTTP 캐시는 응답을 저장해뒀다가 다시 쓰는 것이고, 그 규칙을 정하는 게 서버가 보내는 Cache-Control 헤더다. 캐시는 브라우저에도, 중간의 CDN에도 있다.
같은 요청을 두 번 보내보면
같은 JSON을 돌려주는 서버에 Cache-Control만 바꿔가며, 브라우저(Chromium)에서 같은 주소를 두 번 요청해봤다. 서버 로그에는 실제로 서버까지 온 요청만 찍힌다.
### Cache-Control: max-age=60
[server] /max-age -> 200 ← 첫 번째만 서버로 옴
← 두 번째는 서버에 오지도 않음 (브라우저 캐시 사용)
### Cache-Control: no-cache
[server] /no-cache -> 200
[server] /no-cache if-none-match="661f9b33..." -> 304 ← 두 번째는 "바뀌었나요?"라고 확인만
### Cache-Control: no-store
[server] /no-store -> 200
[server] /no-store -> 200 ← 매번 처음부터 다시 받음
세 경우 모두 자바스크립트에서는 두 번 다 200과 같은 본문을 받았다. 차이는 서버까지 요청이 갔는지, 응답 본문을 다시 받았는지에 있다.
| 설정 | 두 번째 요청 | 의미 |
|---|---|---|
| max-age=60 | 서버에 가지 않음 | 60초 동안은 묻지 않고 저장본을 쓴다 |
| no-cache | 서버에 확인만 함 → 304 | 저장은 하되, 쓸 때마다 최신인지 확인한다 |
| no-store | 서버에서 전부 다시 받음 | 아예 저장하지 않는다 |
이름 때문에 가장 많이 헷갈리는 부분이다. **no-cache는 "캐시하지 마라"가 아니라 "확인 없이 쓰지 마라"**다. 정말 저장 자체를 막고 싶다면(개인정보, 결제 정보) no-store를 써야 한다.
ETag와 304: 바뀌었을 때만 다시 받기
no-cache의 두 번째 요청에서 일어난 일이 조건부 요청(Conditional Request)이다.
- 서버가 첫 응답에 내용의 "지문"인 ETag를 담아 보낸다. (ETag: "661f9b33...")
- 다음 요청에서 브라우저가 If-None-Match: "661f9b33..."로 "내가 가진 게 이 버전인데 바뀌었나요?"라고 묻는다.
- 바뀌지 않았다면 서버는 본문 없이 304 Not Modified 만 보내고, 브라우저는 저장해둔 본문을 쓴다.
// Node.js에서 ETag 처리
import crypto from "node:crypto";
const body = JSON.stringify(posts);
const etag = `"${crypto.createHash("sha1").update(body).digest("hex").slice(0, 16)}"`;
res.setHeader("Cache-Control", "no-cache");
res.setHeader("ETag", etag);
if (req.headers["if-none-match"] === etag) {
res.statusCode = 304; // 본문 없이 "그대로 써도 돼"
res.end();
return;
}
res.end(body);
응답 본문이 크거나 모바일 환경일수록 304로 아끼는 데이터가 커진다. 대부분의 웹 프레임워크와 정적 파일 서버는 ETag를 자동으로 붙여준다.
Cache-Control 지시어 한눈에 보기
| 지시어 | 의미 |
|---|---|
| max-age=초 | 이 시간 동안은 서버에 묻지 않고 저장본을 쓴다 |
| no-cache | 저장은 하되, 쓸 때마다 서버에 확인한다 |
| no-store | 어디에도 저장하지 않는다 |
| private | 브라우저에만 저장. CDN 같은 공유 캐시는 저장 금지 (사용자별 데이터) |
| public | 공유 캐시(CDN)에도 저장해도 된다 |
| s-maxage=초 | 공유 캐시(CDN)에만 적용되는 max-age. 브라우저는 무시 |
| immutable | 유효 기간 동안 절대 바뀌지 않으니 새로고침해도 확인하지 마라 |
| stale-while-revalidate=초 | 만료된 저장본을 일단 보여주고, 뒤에서 새 버전을 받아온다 |
로그인한 사용자의 정보(마이페이지 API 등)에 public이나 s-maxage를 붙이면, CDN이 한 사용자의 응답을 다른 사용자에게 보여주는 사고가 날 수 있다. 사용자마다 다른 응답에는 private(또는 no-store)를 쓰자.
실전 조합: 파일 종류별로 다르게
캐시 설정의 핵심은 "이 응답이 언제 바뀌는가" 에 맞추는 것이다.
| 대상 | 추천 설정 | 이유 |
|---|---|---|
| 파일명에 해시가 붙은 JS/CSS (app.3f9a1c.js) | public, max-age=31536000, immutable | 내용이 바뀌면 파일명이 바뀌므로, 같은 이름은 영원히 같은 내용 |
| HTML 문서 | no-cache (+ ETag) | 새 배포의 새 JS 파일명을 가리켜야 하므로 항상 확인 |
| 이미지 (해시 없는 이름) | public, max-age=86400 정도 | 가끔 바뀌니 적당한 유효 기간 |
| 공개 API (게시글 목록) | public, s-maxage=60, stale-while-revalidate=300 | CDN이 1분간 대신 응답, 갱신 중에도 빠르게 |
| 사용자별 API | private, no-cache 또는 no-store | 공유 캐시에 저장되면 안 됨 |
"배포했는데 옛날 화면" 문제의 원인
대부분 HTML에 긴 max-age를 줬기 때문이다. HTML이 캐시되면 브라우저는 옛날 HTML을 쓰고, 그 HTML은 옛날 JS 파일을 불러온다. 해결 공식은 이렇다.
- HTML은 항상 확인(no-cache)
- JS/CSS는 파일명에 해시를 붙이고 영원히 캐시(immutable)
Vite, Nuxt, Next.js 같은 빌드 도구는 JS/CSS 파일명에 해시를 자동으로 붙여준다. 그래서 HTML 캐시만 조심하면 된다.
CDN 캐시와 Vercel
Vercel 같은 플랫폼에서는 브라우저와 서버 사이에 CDN이 있다. 서버(함수)가 보낸 s-maxage가 있으면 CDN이 그 시간 동안 응답을 저장해두고, 서버를 거치지 않고 대신 응답한다. 서버리스 함수 호출 횟수와 응답 속도에 직접 영향을 준다.
// API 응답: 브라우저는 매번 확인, CDN은 60초간 대신 응답
res.setHeader("Cache-Control", "public, max-age=0, s-maxage=60, stale-while-revalidate=300");
Nuxt라면 routeRules로 경로별 캐시 전략을 선언할 수 있다.
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
"/": { swr: 600 }, // 10분간 캐시, 만료 후 첫 요청은 저장본을 주고 뒤에서 갱신
"/api/posts": { headers: { "cache-control": "public, s-maxage=60" } },
"/mypage/**": { headers: { "cache-control": "private, no-store" } },
},
});
캐시 설정을 바꾼 뒤에는 개발자 도구 Network 탭에서 응답 헤더를 직접 확인하자. Cache-Control, ETag, Age(CDN에서 몇 초째 저장 중인지), 그리고 (disk cache), 304 같은 표시로 실제로 어떻게 동작하는지 알 수 있다.
정리: 왜 알아두면 좋은가
| 오해 / 문제 | 사실 / 해결 |
|---|---|
| no-cache는 캐시 안 하는 것 | 저장은 하고 매번 확인하는 것. 저장 금지는 no-store |
| 배포 후에도 옛날 화면 | HTML은 no-cache, 해시 붙은 JS/CSS만 오래 캐시 |
| 매번 전체 응답을 다시 받음 | ETag + 304로 바뀌었을 때만 받기 |
| 개인 정보가 다른 사람에게 보임 | 사용자별 응답은 private 또는 no-store |
| 서버(함수) 부하가 큼 | 공개 응답에 s-maxage + stale-while-revalidate |
캐시는 잘 쓰면 서버 비용과 응답 시간을 동시에 줄이고, 잘못 쓰면 "고쳤는데 안 고쳐진" 버그와 개인정보 사고를 만든다. 헤더를 붙이기 전에 "이 응답은 언제 바뀌고, 누구에게 같은 응답인가?" 두 가지만 먼저 물어보자.