캐시 무효화: 당신의 ?v=는 아마 아무 일도 하지 않는다
운영 환경의 내비게이션이 어느 날 오후 내내 스타일이 하나도 먹지 않은 채 표시됐습니다. HTML도 맞았고, 서버의 CSS 파일도 맞았고, 배포도 오류 없이 끝났습니다. 제대로 된 곳을 들여다보기까지 두 번이나 잘못 진단했습니다.
app.css에는 ?v=가 아예 붙어 있지 않았습니다. Cloudflare가 네 시간 동안 캐시했고, 브라우저는 그보다 훨씬 오래 들고 있었습니다. 그 릴리스는 CSS 클래스 이름을 한 무더기 바꿨기 때문에, 새 HTML이 이전 빌드의 스타일시트를 여전히 쥐고 있는 브라우저에 도착했습니다. 마크업의 클래스 중 어느 것도 대응되는 규칙이 없었습니다.
배포가 아니라 캐시라는 단서는, 그대로 이 문제를 보기 어렵게 만든 이유이기도 합니다. 강제 새로고침을 하면 해결됐고, 사이트를 처음 여는 사람에게는 멀쩡하게 보였습니다. 차가운 캐시는 정상적인 배포와 구별되지 않으니, 깨끗한 프로필로 테스트한 사람은 아무도 재현하지 못했습니다.
app.js에는 ?v=가 있었고, 그건 없는 것보다 나빴습니다. package.json의 버전을 그대로 끼워 넣었기 때문입니다.
<script src="/js/app.js?v=<%= appVersion %>" defer></script>
이 숫자는 릴리스 사이에 움직이지 않고, 우리는 버전을 올리는 횟수보다 훨씬 자주 배포합니다. 결국 JS에는 템플릿을 읽는 사람 눈에는 캐시 무효화처럼 보이지만 실제로는 아무것도 무효화하지 않는 토큰이 붙어 있었습니다. CSS와 똑같은 버그가, 터지기 한 릴리스 앞에서, 앞에 미끼를 하나 세워 둔 채 기다리고 있었던 셈입니다.
파일을 해시하라
캐시 무효화 토큰의 일은 하나뿐입니다. 파일이 바뀌면 바뀌고, 그 외에는 바뀌지 않는 것. 그 조건을 만족하는 값은 파일 자신의 바이트에서 나온 값밖에 없습니다.
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
function assetVersion(relativePath) {
const buf = readFileSync(join(__dirname, 'public', relativePath));
return createHash('sha1').update(buf).digest('hex').slice(0, 8);
}
const cssVersion = assetVersion('css/app.css');
시작할 때 한 번만 읽습니다. 빌드 산출물이라 실행 중인 프로세스 아래에서 바뀔 수 없습니다. 16진수 여덟 글자면 충분하고, 여기서는 SHA-1로 족합니다. 막으려는 것은 내 파일의 두 버전이 우연히 충돌하는 상황이지, 내용을 골라 넣을 수 있는 공격자가 아니니까요.
그러면 템플릿이 거짓말을 그만둡니다.
<link rel="stylesheet" href="/css/app.css?v=<%= cssVersion %>">
대신 흔히 쓰이는 토큰은 세 가지입니다.
버전 번호(?v=1.4.2)는 사람이 바꿀 생각을 했을 때 바뀝니다. 파일이 바뀌었을 때와는 다른 이야기죠. 우리 app.js가 그랬습니다.
타임스탬프나 빌드 ID는 배포마다 실제로 바뀌므로 오래된 것을 내보내는 일은 없습니다. 다만 아무것도 바뀌지 않았을 때도 바뀝니다. 1바이트도 움직이지 않았더라도 배포할 때마다 모든 방문자의 캐시된 CSS와 CDN 각 엣지의 사본을 버리게 되고, 백엔드만 건드린 릴리스도 사용자마다 재다운로드를 한 번씩 물립니다.
임의의 값(?v=<%= Math.random() %>, 정말로 이렇게 쓰는 사람이 있습니다)은 페이지를 그릴 때마다 새 URL입니다. 이건 캐시를 무효화하는 게 아니라, 모두에게 캐시를 영구히 꺼 버리는 겁니다.
양쪽을 다 만족시키는 건 콘텐츠 해시뿐입니다. 오래된 것을 내보내지 않고, 바뀌지 않은 것을 다시 받게 하지도 않습니다.
쿼리 스트링이냐 파일 이름이냐
app.a1b2c3d4.css는 해시를 쿼리 스트링 대신 파일 이름에 넣어 같은 약속을 합니다. 모든 참조를 다시 쓰는 빌드 단계가 필요하고, 그래서 번들러를 쓰는 프로젝트에는 보통 있고 서버 렌더링 프로젝트에는 보통 없습니다.
파일 이름 쪽의 오래된 근거는, 캐시 키를 계산할 때 쿼리 스트링을 무시하는 캐시가 있다는 것이었습니다. 그러면 ?v=는 장식이 됩니다. 지금도 실수로 켤 수 있는 설정이고, Cloudflare의 캐시 레벨에는 'ignore query string' 항목이 있습니다. 이게 켜져 있으면 사이트의 모든 ?v=가 아무 말 없이 동작을 멈춥니다. 기대기 전에 확인하세요.
immutable은 파일이 아니라 URL에 대한 약속이다
Cache-Control: public, max-age=31536000, immutable은 사용자가 새로고침을 눌러도 재검증하지 말라고 브라우저에 말합니다. 이 말은 URL이 콘텐츠 주소일 때만 참이고, 그렇게 말하고 나면 규칙은 저절로 써집니다.
if (/\.(css|js)$/.test(name)) {
const versioned = /[?&]v=/.test(url) || filePath.includes('/vendor/');
res.setHeader('Cache-Control', versioned
? 'public, max-age=31536000, immutable'
: 'public, max-age=3600');
}
같은 파일이라도 토큰 없이 요청되면 한 시간입니다. 약속을 하는 쪽은 토큰이고, 아무도 하지 않은 약속을 근거로 1년을 캐시하면 안 됩니다.
정적 파일 미들웨어가 이걸 덮어쓰지 않는지도 확인하세요. @fastify/static은 기본으로 max-age=0을 보냅니다. 자체 cacheControl을 끄지 않은 채 헤더를 설정하면, 그 기본값으로 헤더를 다시 만들어 당신 것을 조용히 덮어씁니다. 헤더는 그대로 남아 있고, 여전히 의도적으로 보이고, 틀렸습니다.
폰트는 이름으로 immutable 처리합니다. 서브셋 빌드는 편집이 아니라 이름을 바꿔 교체하니 파일 이름이 이미 콘텐츠 주소입니다. 저장소에 함께 넣은 라이브러리도 마찬가지로, alpine-3.14.8.min.js는 이름에 버전을 달고 있고 그 자리에서 고치는 사람은 없습니다.
이미지는 1년이 아니라 일주일입니다. 버전이 붙어 있지 않고, 로고나 파비콘은 실제로 같은 주소에서 교체됩니다. 일주일이면 값을 하기에 충분히 길고, 실수를 되돌리기에 충분히 짧습니다.
짧게 유지해야만 하는 파일
우리는 남의 페이지 안에서 돌아가는 배지 스크립트를 제공합니다. 그쪽 HTML도, 그쪽이 삽입하는 URL도 우리 손에 없습니다. 거기에 1년짜리 immutable을 걸면 1년 동안 손댈 수 없는 버그를 안는 셈입니다. 무효화할 방법도 없고, 태그를 붙인 모든 사이트에 바꿔 달라고 부탁할 방법도 없습니다. 그래서 한 시간을 줍니다. 파일이 거의 바뀌지 않더라도 이게 맞습니다.
버전을 붙일 수 없는 URL에 놓인 것은 전부 이렇습니다. 캐시 수명을 정하는 것은 얼마나 자주 바뀌느냐가 아니라, 얼마나 빨리 고칠 수 있어야 하느냐입니다.
확인하기
HTML의 토큰이 디스크의 파일과 맞습니까?
curl -s https://yoursite.com/ | grep -o 'app\.css?v=[a-f0-9]*'
shasum -a 1 public/css/app.css | cut -c1-8
같은 문자열이어야 하고, 아니라면 HTML은 자신이 설명하지 않는 것을 가리키고 있습니다. CSS를 바꾼 두 번의 배포를 거쳐도 토큰이 그대로라면, 당신 손에 있는 것이 우리 app.js입니다.
그다음 자산 자체가 무엇을 응답하는지 물어보세요.
curl -sI 'https://yoursite.com/css/app.css?v=a1b2c3d4' | grep -i 'cache-control\|last-modified\|cf-cache-status'
오래된 엣지를 잡아내는 건 last-modified입니다. 그 파일을 마지막으로 배포한 시각보다 이르다면, 서버와 브라우저 사이의 무언가가 아직 옛 사본을 쥐고 있는 겁니다. 그게 피해가 되느냐 마느냐를 결정하는 것은 당신 HTML 안의 토큰입니다.