빌드노트
no-cache 응답인데 If-None-Match 요청이 다시 간다
no-cache 응답의 ETag를 브라우저가 다시 보내는 것은 재검증이다. 새 Chromium 프로필에서 일반 fetch를 반복해 no-cache의 200·304·304와 no-store의 200·200·200을 비교하고, 저장 허용 여부에 따라 응답 정책을 고르는 기준을 설명한다.
JSON 응답에 Cache-Control: no-cache를 넣었는데, 같은 주소를 다시 읽자 브라우저가 If-None-Match를 보내고 서버는 304를 돌려준다. no-cache는 저장을 금지하는 응답 지시어가 아니다. 저장한 응답을 다시 쓰기 전에 서버에서 검증받으라는 뜻이다. 저장 자체를 막으려는 요구라면 응답에 Cache-Control: no-store를 사용한다.
여기서는 서버가 보내는 응답 헤더를 다룬다. RFC 9111 §5.2.2.4 (새 탭에서 열림)의 인자 없는 no-cache는 다른 요청에 재사용하기 전에 성공적인 검증을 요구한다. §5.2.2.5의 no-store (새 탭에서 열림)는 해당 요청·응답의 캐시 저장과 그 응답의 재사용을 금지한다. 따라서 “매번 최신인지 확인”과 “캐시에 저장하지 않음”은 서로 다른 정책이다.
세 번 모두 서버에 갔지만, 받은 내용은 달랐다
별도 loopback 서버에서 본문을 {"revision":1}, ETag를 "v1"로 고정했다. 서버는 If-None-Match: "v1"을 받으면 304를 보내고, 없으면 같은 본문을 200으로 보냈다. 응답의 Cache-Control만 no-cache와 no-store로 나눠 비교했다. 실제 서비스에서 발생한 장애 기록이 아니라 합성 응답을 사용한 실험이다.
환경은 Windows 11, Python 3.12.10, Playwright 1.59.0, Chromium 147.0.7727.15였다. 정책마다 새 브라우저 프로필로 시작하고, 같은 출처의 페이지 안에서 같은 /item을 세 번 순서대로 fetch했다. 요청에 캐시 옵션이나 조건부 헤더를 직접 넣지 않았다. 개발자 도구를 열지 않았고 캐시 비활성화·요청 가로채기도 사용하지 않았다. 서비스워커는 차단했다.
- no-cache: 첫 요청에는 If-None-Match가 없었고 서버가 200·14바이트를 보냈다. 두 번째와 세 번째에는 브라우저가
"v1"을 붙였다. 서버는 두 번 모두 304·본문 0바이트로 응답했다. - no-store: ETag를 똑같이 보내 줬지만 세 요청 모두 If-None-Match가 없었다. 서버는 매번 200과 14바이트 본문을 보냈다.
페이지의 JavaScript가 읽은 결과는 두 정책에서 모두 아래와 같았다. 앞의 숫자는 호출 순서, 다음은 response.status와 response.text()다.
1 200 {"revision":1}
2 200 {"revision":1}
3 200 {"revision":1}
no-cache 조건에서는 서버가 두 번의 재검증에 새 본문을 보내지 않았는데도 페이지는 본문을 읽었다. 브라우저 HTTP 캐시가 앞서 받은 본문을 재사용한 결과다. Fetch 표준의 HTTP-network-or-cache fetch (새 탭에서 열림)는 재검증 응답이 304일 때 저장 응답의 헤더를 갱신하고 그 저장 응답을 선택한다. 이 실험의 response.status도 저장 응답의 200이었다. 브라우저가 내부에서 재검증한 결과와 JavaScript가 받은 Response를 같은 것으로 읽으면 혼동하기 쉽다.
캐시 기능 자체가 꺼진 비교는 아닌지도 확인했다. ETag와 본문은 유지하고 응답을 Cache-Control: max-age=600으로 바꾼 대조 조건에서는, 세 번 fetch해도 서버에는 첫 요청 한 번만 도달했다. 반대로 no-cache에서 ETag까지 제거하고 Last-Modified도 보내지 않은 추가 조건은 세 번 모두 조건부 헤더 없이 200이었다. 요청 횟수나 200이라는 숫자만으로 저장 여부를 판정할 수는 없다.
자기 서버에서는 요청 헤더와 응답 정책을 함께 확인한다
로컬 테스트 서버에 GET 경로 하나를 준비한다. 예를 들어 /item이 위의 고정 본문과 ETag를 반환하고, 일치하는 If-None-Match에는 304를 반환하도록 한다. 서버 로그에는 경로, If-None-Match, 전송한 상태를 남긴다. 첫 응답부터 Cache-Control: no-cache 또는 no-store 중 비교할 값을 보내야 한다.
그 서버와 같은 출처의 페이지를 새 임시 Chromium 프로필로 열고 Console에서 다음을 실행한다. /item은 준비한 경로로 바꾼다. Network의 Disable cache는 해제하고, 각 정책을 비교할 때는 프로필을 새로 시작한다. 한 조건의 세 번 요청 동안에는 URL을 바꾸지 않는다.
const url = "/item";
for (let i = 1; i <= 3; i++) {
const response = await fetch(url, {
signal: AbortSignal.timeout(5000)
});
console.log(i, response.status, await response.text());
}
캐시 동작을 따로 지정하지 않은 일반 fetch다. Fetch 표준의 cache mode (새 탭에서 열림)는 기본값을 default로 정한다. 여기서 fetch(url, {cache: "no-store"})를 넣으면 클라이언트가 HTTP 캐시를 사용하지 않는 조건까지 더해져, 응답 헤더 두 개의 차이를 그대로 비교할 수 없다. 같은 단어가 보이더라도 요청 옵션과 응답 정책의 위치를 구별한다.
위처럼 ETag가 고정된 서버라면 no-cache의 두 번째 요청에서 서버 로그에 If-None-Match: "v1"과 304가 함께 나타나는지 본다. 콘솔의 200만 보고 재검증이 없었다고 결론내리지 않는다. no-store 조건에서는 새 프로필의 첫 응답부터 그 헤더가 전달됐는지 확인하고, 이후 요청에 조건부 헤더가 붙는지 비교한다.
결과가 다르면 다음 순서로 범위를 좁힐 수 있다.
- no-cache인데 매번 조건부 헤더 없는 200이라면, 응답에 ETag 또는 Last-Modified가 실제로 전달되는지와 URL이 고정됐는지 확인한다. 앞의 검증자 없는 추가 조건도 같은 결과였다. no-cache가 저장을 강제하거나 항상 304를 보장하는 것은 아니다.
- 서버가 If-None-Match를 받았는데 200을 보냈다면 현재 ETag와 요청 값, 서버의 조건부 요청 처리를 확인한다. 본문이 바뀌어 새 표현을 보낸 200은 정상일 수 있다.
- 서버 요청이 아예 늘지 않으면 실제 응답 헤더, 기존 캐시, 서비스워커와 중간 캐시를 확인한다. 시간 초과나 HTTP 오류가 먼저 발생하면 로컬 경로가 정상 응답하는지부터 해결한다.
저장을 허용할지 먼저 결정한다
응답을 보관해도 되지만 다시 사용할 때마다 서버 확인이 필요한 경우에는 no-cache를 선택할 수 있다. ETag가 있고 표현이 그대로라면 이번 관찰처럼 서버가 본문 없이 검증에 답하고 브라우저가 저장 본문을 사용할 수 있다. 따라서 no-cache는 매번 서버에 도달하는 요청 수를 없애려는 설정과는 맞지 않는다.
이 응답을 HTTP 캐시에 저장하지 않도록 해야 한다면 no-store를 선택한다. 브라우저 개인 캐시와 공유 캐시에 모두 적용되는 지시다. 다만 응답 헤더 한 줄을 애플리케이션이 별도로 저장한 데이터까지 지우는 명령으로 해석해서는 안 된다. RFC 9111도 no-store만으로 개인정보 보호를 보장할 수는 없다고 설명한다.
이번에 확인한 범위는 한 버전의 Chromium이 새 프로필에서 loopback 서버로 보낸 연속 GET과 그 본문이다. no-store 조건에서 이후 요청이 ETag를 재사용하지 않은 것을 관찰했으며, 브라우저 내부 디스크를 조사해 흔적이 전혀 없음을 증명한 것은 아니다. 브라우저 재시작 뒤의 동작, 기존 캐시가 있는 상태에서 정책을 바꾸는 경우, 다른 브라우저·CDN·프록시·인증 응답은 확인하지 않았다.