응답에 Cache-Control: no-cache를 넣으면 브라우저가 아무것도 저장하지 않을 것 같지만, 이 지시어가 막는 것은 저장이 아닙니다. 서버에 확인하지 않고 다시 쓰는 일을 막습니다. 이 글을 읽고 나면 no-cache, no-store, max-age 가운데 무엇을 고를지 정하고, 저장되었는지를 서버 로그와 transferSize, 캐시만 읽는 요청으로 직접 확인할 수 있습니다.
예제는 2026-11-01 macOS 27.0.1에서 실행했습니다. 브라우저는 Claude 데스크톱 앱의 내장 브라우저(Chromium 152.0.7977.167)이고, 서버는 Node.js v24.21.0로 만든 로컬 서버(localhost:4391)입니다. 동작은 RFC 9111과 Fetch 표준, MDN으로 확인했습니다. Google Chrome, Safari, Firefox와 페이지 이동, 새로고침, CDN은 실행하지 않았으니 아래 관찰은 이 브라우저의 fetch() 결과입니다. 응답 본문의 내용(v1, v2와 채움 문자)은 가상이고, 상태 코드와 크기, 로그 줄은 2026-11-01 실행의 실제 출력입니다.
no-cache는 저장을 허용하되, 다시 쓸 때마다 확인을 요구합니다
RFC 9111은 no-cache가 붙은 응답을 다른 요청에 쓰려면 먼저 서버로 확인 요청을 보내 성공 응답을 받아야 한다고 정합니다. 저장을 금지하는 문장은 없습니다. MDN의 Cache-Control 문서도 저장을 막으려는 용도가 아니라고 따로 적어 둡니다. 같은 문서는 뒤로 가기 같은 기록 이동에서는 no-cache가 재검증을 보장하지 않는다고도 적습니다. 이 경우는 실행하지 않았습니다.
실험 서버는 같은 크기(2000바이트)의 JSON을 헤더만 바꿔 보냅니다.
'no-cache+ETag': { cc: 'no-cache', etag: true },
'no-store+ETag': { cc: 'no-store', etag: true },
'no-cache only': { cc: 'no-cache', etag: false },
'max-age=60+ETag': { cc: 'max-age=60', etag: true },
'max-age=0+ETag': { cc: 'max-age=0', etag: true },
'Last-Modified only': { lastModified: true, etag: false },
페이지는 지시어마다 같은 순서로 요청합니다. 같은 주소를 두 번 읽고, 서버 내용을 v1에서 v2로 바꾼 뒤 한 번 더 읽고, 마지막으로 캐시만 읽습니다. only-if-cached는 서버로 가지 않고 HTTP 캐시에서 찾는 모드라, 여기서는 저장되어 있는지를 직접 묻는 데 썼습니다.
await get(name, url, 'fetch 1');
await get(name, url, 'fetch 2');
await fetch(`/bump/${run}/${id}`, { method: 'POST', cache: 'no-store' });
await get(name, url, 'fetch 3');
await get(name, url, 'only-if-cached', { cache: 'only-if-cached', mode: 'same-origin' });

no-cache의 둘째 요청을 보면 답이 나옵니다. 브라우저는 If-None-Match "v1"을 보냈고 서버는 304와 본문 0바이트로 답했습니다. 그런데 페이지의 res.status는 200이고 내용은 v1이었습니다. 서버가 본문을 보내지 않았으니 브라우저가 첫 응답을 가지고 있었던 것입니다. Fetch 표준도 재검증에 304가 오면 저장된 응답을 대신 돌려주도록 정합니다. 그래서 2026-11-01 실행에서 코드는 304를 한 번도 보지 못했습니다. transferSize만 2300에서 300으로 줄었습니다. MDN에 따르면 이 값은 본문 크기에 헤더 크기를 대신하는 고정값 300을 더한 것이고 재검증이면 정확히 300입니다. 그러니 여기서 300은 잰 바이트 수가 아니라 본문을 다시 받지 않았다는 표시입니다.
셋째 요청에서는 내용이 v2로 바뀌어 있었고, 서버가 200과 새 본문으로 답했습니다. no-cache의 쓸모가 여기 있습니다. 바뀌지 않은 동안에는 본문을 다시 받지 않고, 바뀌면 곧바로 새 내용을 받습니다.
no-store에는 확인할 값이 없습니다
no-store는 ETag를 함께 보내도 다음 요청에 If-None-Match가 없었고, 세 번 모두 2000바이트를 받았습니다. 캐시만 읽는 요청은 TypeError: Failed to fetch로 끝났습니다. 없는 항목을 찾았을 때 Fetch 표준은 네트워크 오류를 돌려준다고 하고, MDN의 Request.cache 문서는 504 응답이라고 적습니다. 이 실행의 결과는 표준 쪽과 같았습니다.
RFC 9111의 no-store는 일부러 비휘발성 저장소에 넣지 말고, 메모리에서는 가능한 한 빨리 지우려고 노력하라는 뜻입니다. 같은 절은 이 지시어가 프라이버시를 지키는 믿을 만하거나 충분한 수단은 아니라고 덧붙입니다. 악의적이거나 침해된 캐시는 따르지 않을 수 있고, 통신이 도청될 수도 있다는 이유입니다. 이 실험은 HTTP 캐시 조회가 실패한 것을 보았을 뿐이고, 디스크를 직접 열어 보지는 않았습니다.
확인 값이 없으면 서버 로그로는 구분되지 않습니다
잘못 읽기 쉬운 응답은 ETag도 Last-Modified도 없는 no-cache입니다.

브라우저가 보낼 확인 값이 없으니 세 번 모두 no validator, 200, 2000바이트였습니다. 서버 쪽에서 보면 no-store와 똑같습니다. 그런데 only-if-cached는 v2를 돌려주었습니다. 저장되어 있었다는 뜻입니다. 그러니 "매번 본문 전체가 오니 저장되지 않는다"고 판단하면 안 됩니다. 토큰이나 개인 정보처럼 남으면 곤란한 응답에는 no-store를 쓰고, 그래도 위의 한계는 남는다고 보면 됩니다.
지시어가 없어도 서버에 묻지 않을 수 있습니다
max-age=60은 예상대로였습니다. 서버 내용이 v2로 바뀌었는데도 셋째 읽기는 서버 줄 없이 v1을 돌려주었습니다. 놓치기 쉬운 쪽은 Cache-Control 없이 30일 전 Last-Modified만 보낸 응답입니다. 이 응답도 v2로 바뀐 뒤 서버에 묻지 않고 v1을 돌려주었습니다. RFC 9111은 명시적인 만료 시간이 없을 때 캐시가 신선한 기간을 추정해도 된다(MAY)고 하고, Last-Modified 이후 경과 시간의 일정 비율을 넘지 않는 값을 쓰도록 권하며, 그 비율은 10% 정도일 수 있다고 적습니다. 구체적인 계산법은 정하지 않는다고도 적혀 있으니, 이 브라우저가 며칠을 쓰는지는 이 실행으로 알 수 없습니다. 측정하지도 않았습니다.
max-age=0에 ETag를 붙인 응답은 no-cache와 같은 순서로 나왔습니다. 304, 그리고 바뀐 뒤 200입니다. 다만 문서상 차이는 있습니다. RFC 9111에서 no-cache는 오래된 응답을 내주는 일을 금지하지만, 캐시가 서버와 끊겼을 때는 그런 금지가 없는 응답이면 오래된 응답을 내줄 여지가 있습니다. 끊긴 상황은 실행하지 않았습니다.
fetch의 cache 옵션은 같은 단어, 다른 대상입니다
헤더의 지시어는 응답에 붙고, fetch(url, { cache })는 그 요청 하나의 동작을 정합니다. 이름이 같아서 섞이기 쉽습니다.

아직 신선한 max-age=60 응답에도 cache: 'no-cache'는 서버에 확인했습니다. 눈여겨볼 곳은 그다음입니다. cache: 'no-store'로 새 내용 v2를 받았는데, 바로 뒤의 기본 fetch는 옛 v1을 돌려주었습니다. MDN 설명대로 이 모드는 받은 응답으로 캐시를 갱신하지 않기 때문입니다. 캐시까지 바꾸려면 cache: 'reload'를 씁니다. 이 경우 다음 기본 fetch가 v2를 돌려주었습니다.
확인은 브라우저와 서버 양쪽에서 합니다
이 실행에서 curl과 Node.js로는 이 차이가 보이지 않았습니다. 같은 no-cache 주소를 curl 8.7.1로 그냥 부르면 200 2000 bytes이고, -H 'If-None-Match: "v1"'을 직접 넣어야 304 0 bytes가 나왔습니다. Node.js v24.21.0의 fetch도 두 번 모두 200이었고, 이때 서버에 도착한 요청에는 확인 값이 없었습니다. 서버가 304를 잘 내는지는 curl로, 브라우저가 무엇을 다시 썼는지는 서버 로그와 함께 PerformanceResourceTiming.transferSize로 봅니다. MDN에 따르면 이 값이 0이고 본문 크기가 0이 아니면 로컬 캐시에서 온 것이며, 다른 출처 자원은 Timing-Allow-Origin과 CORS 검사를 모두 통과해야 이렇게 판단할 수 있습니다.
개발 노트
막고 싶은 것부터 고른다
같은 로컬 서버로 실행한 세 지시어
저장 자체를 막는다
Cache-Control: no-store- 캐시만 읽는 요청이 실패했다
- 매번 본문 전체를 받았다
- 그래도 RFC는 프라이버시의 충분한 수단이 아니라고 적는다
낡은 값 재사용을 막는다
Cache-Control: no-cache- 저장은 허용되고, 2026-11-01 실행에서도 저장되었다
- ETag가 있으면 안 바뀐 동안 304, 본문 0바이트였다
- 바뀌면 바로 새 본문이 왔다
서버 요청을 줄인다
Cache-Control: max-age=60- 정한 시간 안에는 서버에 묻지 않았다
- 그 사이 바뀐 내용은 보이지 않았다
2026-11-01 Chromium 152.0.7977.167에서 실행한 결과와 RFC 9111을 글쓴이가 정리했다. 다른 브라우저, 페이지 이동, CDN은 실행하지 않았다.
헤더를 고치기 전에 "저장을 막고 싶은가, 낡은 값을 막고 싶은가"를 먼저 정하세요. 낡은 값이 문제라면 no-cache에 ETag를 함께 보내고, 저장 자체가 문제라면 no-store를 보낸 뒤, 그 주소와 같은 출처의 페이지에서 mode: 'same-origin'을 붙인 only-if-cached 요청을 한 번 보내 결과로 확인하면 됩니다. 다른 출처의 주소에는 저장 여부와 상관없이 이 요청이 실패합니다. 같은 브라우저에서 따로 실행했을 때 localhost:4396 페이지에서 127.0.0.1:4396의 저장된 응답과 한 번도 요청하지 않은 주소가 모두 TypeError: Failed to fetch로 끝났습니다. 그 실패로는 아무것도 판단할 수 없습니다.
이 기록에 대화를 더해 주세요.
궁금한 점, 다른 접근, 직접 해 본 결과를 나눠 주세요.
로그인 상태 확인 중…
댓글을 불러오는 중…