flowoodz.
← blog
8분 읽기글 flowoodz

no-cache를 걸었는데 4시간 캐시가 됩니다: Cloudflare Browser Cache TTL

three.js로 만든 웹게임을 nginx로 배포하다가 옛 코드가 캐시된 채 새 에셋을 404로 찾는 문제를 겪었습니다. no-cache 헤더를 걸어도 안 됐고, 원인은 Cloudflare의 기본 Browser Cache TTL 4시간이었습니다.

목차
  1. 게임이 깨졌습니다
  2. no-cache를 걸었는데 그대로 재현됩니다
  3. 원인: Browser Cache TTL 기본값 4시간
  4. 해결: no-store로 전환
  5. 덤: gltf가 gzip이 안 먹던 문제

EBB & GRAB(썰물의 도둑)은 제가 만든 three.js 3D 쿼터뷰 게임입니다. 빌드 스텝 없이 순수 정적 파일이고, ebb.flowoodz.com에서 nginx 컨테이너 하나로 돌아갑니다. 도메인은 Cloudflare를 거쳐 나갑니다.

정적 파일이라 배포가 간단할 줄 알았습니다. 실제로는 같은 날 nginx.conf를 다섯 번 고쳤습니다. 그중 캐시 헤더 문제 하나가 원인을 찾는 데 제일 오래 걸렸습니다.

EBB & GRAB 시작 화면. 조작키 안내와 최고 기록이 보인다


게임이 깨졌습니다#

1차 완성 커밋을 배포하고 몇 시간 뒤 다시 접속했더니 화면이 하얗게 멈춰 있었습니다. 콘솔을 열어 보니 main.js가 존재하지 않는 에셋 경로를 404로 요청하고 있었습니다.

원인은 단순했습니다. 저는 그사이 에셋 파일명을 바꾸는 배포를 한 번 더 했는데, 브라우저는 여전히 옛 main.js를 캐시에서 쓰고 있었습니다. 옛 코드는 옛 파일명을 참조하니 404가 나고, 게임은 부팅도 못 한 채 멈췄습니다.

바로 nginx 설정에 캐시 헤더를 추가했습니다.

location ~* \.(html|js)$ {
  add_header Cache-Control "no-cache";
}
 
location /assets/ {
  add_header Cache-Control "public, max-age=604800";
}

코드와 html은 매번 재검증하고, 내용이 바뀌면 파일명도 바뀌는 에셋은 7일(604800초) 캐시했습니다. 여기까지는 흔한 정적 자산 캐시 전략입니다.

no-cache를 걸었는데 그대로 재현됩니다#

다음 배포에서 같은 문제가 또 나타났습니다. no-cache를 분명히 걸었는데 브라우저는 여전히 옛 js를 쓰고 있었습니다.

처음엔 nginx 설정이 반영되지 않았다고 의심했습니다. curl -I로 응답 헤더를 직접 찍어 봤는데, origin은 Cache-Control: no-cache를 정확히 보내고 있었습니다. 문제는 origin과 브라우저 사이, 즉 Cloudflare 엣지였습니다.

원인: Browser Cache TTL 기본값 4시간#

Cloudflare 공식 문서를 열어 확인했습니다.

The Browser Cache TTL sets the expiration for resources cached in a visitor's browser. By default, Cloudflare honors the cache expiration set in your Expires and Cache-Control headers but overrides those headers if the value of the Expires or Cache-Control header from the origin web server is less than the Browser Cache TTL Cloudflare setting.

Free/Pro/Business 플랜의 기본 Browser Cache TTL은 4시간입니다(공식 문서).

no-cache는 사실상 max-age=0과 같은 취급을 받습니다. 이건 4시간보다 작은 값입니다.

Cloudflare는 origin 값이 자기 설정값보다 작으면 자기 설정값으로 덮어씁니다. 그 결과 origin은 분명 no-cache를 보냈는데, 브라우저가 실제로 받는 헤더는 4시간짜리 max-age였습니다.

배포 직후 몇 시간은 옛 js가 그대로 살아 있었던 이유가 여기 있었습니다.

no-cache 덮어쓰기와 no-store 통과를 비교한 다이어그램

해결: no-store로 전환#

no-cache를 no-store로 바꿨습니다.

server {
  listen 80;
  root /usr/share/nginx/html;
 
  gzip on;
  gzip_types application/javascript text/css application/json application/octet-stream;
 
  # 버전이 고정된 것들은 오래 캐시한다 (^~ 가 아래 정규식 블록보다 우선)
  location ^~ /vendor/ { add_header Cache-Control "public, max-age=604800"; }
  location ^~ /assets/ { add_header Cache-Control "public, max-age=604800"; }
 
  # 게임 코드와 html은 캐시하지 않는다.
  # 옛 코드가 캐시된 채 새 에셋을 요청하면 게임이 404로 죽는다.
  # CDN이 no-cache를 4시간 캐시로 덮어써서 no-store를 쓴다.
  location ~* \.(html|js)$ { add_header Cache-Control "no-store"; }
}

바꾼 뒤로는 배포 직후에도 옛 js가 잡히지 않았습니다. vendor/(three.js 번들)와 assets/(glTF 프롭, 오디오)는 내용이 바뀌면 파일명도 바뀌니 그대로 7일 캐시를 유지했습니다.

미결: Cloudflare 문서에서 "no-store는 이 덮어쓰기 대상에서 빠진다"는 문장을 직접 찾지는 못했습니다. no-store는 애초에 캐시 자체를 만들지 않으니 덮어쓸 max-age 값이 없어서 그런 것으로 추정합니다.

다만 이건 문서로 확인한 사실이 아니라 제 추정입니다. 실제로 확인한 건 설정을 바꾼 뒤 문제가 재현되지 않았다는 것뿐입니다.

덤: gltf가 gzip이 안 먹던 문제#

같은 날 겪은 또 다른 문제입니다. nginx가 기본적으로 .gltf, .glb 확장자의 mime type을 모릅니다. 처음엔 이렇게 고쳤습니다.

types { application/json gltf; }

이러면 .gltf는 application/json으로 나가서 gzip 대상에 포함됩니다. 그런데 이 types 블록이 nginx의 기본 mime 맵 전체를 덮어써 버립니다. 그러면 .html이 매핑을 잃고 application/octet-stream으로 나가면서, 브라우저가 HTML을 렌더링하지 않고 다운로드하려고 합니다.

최종적으로는 types 블록을 아예 걷어내고, .glb가 octet-stream으로 나가는 걸 받아들이는 대신 gzip 대상에 application/octet-stream을 추가했습니다.

gzip_types application/javascript text/css application/json application/octet-stream;

glb 안은 바이너리지만 gzip이 어느 정도 먹습니다. mime type을 정확히 맞추기보다 gzip 대상을 넓히는 편이 부작용이 없었습니다.


정적 파일 하나 서빙하는 데도 nginx 설정과 CDN 캐시 정책이 겹치면 예상 못 한 자리에서 문제가 납니다. 특히 no-cache라는 이름만 보고 "캐시 안 함"으로 읽으면 이번처럼 틀립니다. RFC상 no-cache는 "캐시하되 매번 재검증"이고, no-store가 "아예 캐시하지 않음"입니다.

이름과 실제 의미가 미묘하게 다른 헤더라 CDN을 하나 더 거치면 반드시 실제 응답 헤더를 찍어서 확인해야 한다는 걸 이번에 다시 배웠습니다.