CDN이 아무것도 캐시하지 않는 이유
agentready.md의 정적 파일은 하나같이 같은 줄을 달고 돌아왔습니다.
cf-cache-status: BYPASS
폰트, 스타일시트, 스크립트, 이미지. MISS가 아닙니다. MISS라면 엣지가 아직 그 파일을 본 적이 없다는 뜻이고, 방금 받아온 사본은 보관됩니다. BYPASS는 엣지가 응답을 읽고 저장할 수 없다고 판단했다는 뜻이고, 내일도 같은 판단을 합니다. 방문자마다 모든 바이트를 오리진에서 끌어가고 있었고, 그대로 뒀다면 영원히 그랬을 겁니다.
원인이 아니었던 설정 네 가지
- Development Mode — 꺼짐. 세 시간 동안 엣지 캐시를 통째로 정지시키는 설정이고 켜둔 채 잊기 쉬우니 가장 먼저 확인합니다.
- Caching Level — Standard.
- Page Rules — Cache Level을 Bypass로 둔 규칙 없음.
- Browser Cache TTL — 무난한 값.
대시보드가 캐시에 대해 하는 말은 전부 이걸 저장하라였습니다. 엣지는 아무것도 저장하지 않았습니다.
답은 대시보드가 아니라 응답 안에 있었다
찾으러 간 그 한 줄만 보지 말고 헤더 블록을 통째로 읽으면 바로 드러납니다.
curl -sI https://agentready.md/fonts/ibm-plex-sans-latin.woff2
HTTP/2 200
content-type: font/woff2
cache-control: public, max-age=31536000, immutable
set-cookie: _csrf=8f3c…; Path=/; SameSite=Strict
cf-cache-status: BYPASS
셋째 줄은 1년을 요구합니다. 넷째 줄이 아무도 그걸 들어주지 않는 이유입니다.
Set-Cookie를 실은 응답은 공유 캐시에 저장할 수 없습니다. 쿠키는 방문자 한 사람의 것입니다. 그 응답을 저장해 다음 사람에게 내주면 남의 쿠키를 건네는 셈이 됩니다. 어느 CDN이든 거부합니다. Cloudflare는 그 거부에 BYPASS라는 이름을 붙였을 뿐이고, 다른 CDN은 아무 말 없이 그냥 저장하지 않습니다.
쿠키는 우리가 붙인 것이었습니다. 사이트 어디에서 폼을 렌더링하든 토큰이 준비돼 있도록, 미들웨어가 요청마다 CSRF 토큰을 만들어 함께 내려보내고 있었습니다. 그 '요청마다'에는 woff2 폰트 요청도 들어 있었습니다. 폼을 그리지도, 토큰을 읽지도, 코드를 실행하지도 않는 파일입니다.
수정
CSRF를 약하게 만들지는 않습니다. 토큰을 쓸 수 없는 대상에게 발급을 멈출 뿐입니다.
const ASSET_PREFIXES = ['/css/', '/js/', '/fonts/', '/images/'];
const COOKIELESS_FILES = new Set([
'/favicon.ico', '/badge.js', '/robots.txt', '/sitemap.xml', '/llms.txt',
]);
function servesWithoutCookie(path) {
return COOKIELESS_FILES.has(path)
|| ASSET_PREFIXES.some((prefix) => path.startsWith(prefix))
|| path.endsWith('.md');
}
확장자가 아니라 경로로 판단하세요. 캐시가 가장 절실한 응답 가운데 일부는 디스크에서 읽는 게 아니라 템플릿에서 만들어집니다. 페이지마다 있는 마크다운 사본, SVG 배지가 그렇고, 이것들도 폼을 렌더링하지 않습니다.
그 쿠키가 직접 붙인 게 아니라면 세션 계층을 보세요. 미들웨어를 지나는 요청마다 세션을 열고 그 쿠키를 내려보내는 프레임워크가 적지 않습니다. 안에 아무것도 저장되지 않아도 그렇습니다.
어떤 스택에서든 진단하기
첫 페이지 말고 정적 자원을 확인하세요. 첫 페이지는 정당한 이유로 캐시 불가인 경우가 많고, 그 페이지가 끌어오는 서른 개 파일에 대해서는 아무것도 알려주지 않습니다.
for u in / /css/app.css /js/app.js /fonts/ibm-plex-sans-latin.woff2; do
echo "== $u"
curl -sI "https://example.com$u" \
| grep -iE 'cache-control|set-cookie|cf-cache-status|x-cache|^age'
done
같은 URL을 두 번 요청하세요. 두 번째는 적중이어야 합니다. Cloudflare는 cf-cache-status: HIT, CloudFront는 x-cache: Hit from cloudfront, Varnish나 nginx는 올라가는 age: 헤더입니다. 두 번째도 실패라면 객체가 보관되지 않는 것이고, 이유는 방금 출력한 헤더 안에 있습니다.
나머지 단골 용의자
Cache-Control: private은 브라우저 전용이라 공유 캐시에는 절대 들어가지 않습니다. no-store는 브라우저조차 저장하지 않습니다. no-cache는 '캐시하지 말라'가 아니라 재사용 전에 매번 재검증하라는 뜻이고, 파일마다 왕복이 한 번씩 생기는 건 그대로입니다.
Vary: *는 그것만으로 응답을 캐시 불가로 만듭니다. Vary: Cookie도 거의 같습니다. 캐시 키에 방문자마다 다른 값이 들어가니 공유되는 게 없습니다. Vary: User-Agent는 파일 하나를 수천 개 변형으로 쪼갭니다. 여기에 들어가도 되는 건 Accept-Encoding입니다.
쿼리 문자열. 대부분의 캐시는 URL 전체를 키로 쓰기 때문에 ?utm_source=, ?fbclid=가 붙은 변형은 각각 별개의 객체가 되어 오리진에서 받아옵니다. 캠페인 파라미터는 엣지에서 떼거나 정규화하세요.
그리고 프레임워크 기본값. 바이패스는 아니지만 비용은 비슷합니다. 정적 파일마다 max-age=0이면 페이지 이동마다 자원 수만큼 재검증 요청이 나갑니다.
버틸 수 있는 캐시 정책
기준은 파일이 무엇인지가 아니라 URL이 무엇을 약속하는지입니다.
콘텐츠 해시를 담은 URL(app.css?v=9f2ac41b, app.9f2ac41b.css)은 콘텐츠 주소입니다. 파일이 바뀌면 URL이 바뀝니다. 여기에는 public, max-age=31536000, immutable을 주면 되고, 비울 대상이 애초에 생기지 않습니다. 같은 파일이라도 토큰 없이 요청되면 한 시간입니다. 토큰이 곧 약속이고, 아무도 하지 않은 약속 위에 캐시를 쌓을 이유는 없습니다. 로고나 소셜 커버처럼 같은 자리에서 교체되는 이미지는 일주일.
그리고 예외가 있습니다. 가진 파일 중 가장 캐시하기 좋아 보이기 때문에 다들 여기서 틀립니다. 남의 페이지에 심기고, 버전을 붙일 수 없는 URL로 나가는 파일입니다. badge.js를 한 시간으로 두는 이유는 오직 그것입니다. HTML도 URL도 우리 손 밖인 사이트에서 돌아가니, 1년짜리 immutable은 1년 동안 손댈 수 없는 버그라는 뜻이 됩니다.
@fastify/static으로 파일을 서빙한다면
오후 하나를 날린 두 가지. 기본값으로 Cache-Control: public, max-age=0을 보냅니다. 그리고 setHeaders로 헤더를 직접 넣는다면 cacheControl: false도 같이 넘겨야 합니다. 그러지 않으면 라이브러리가 기본 maxAge로 자기 헤더를 만들어 아무 말 없이 덮어씁니다. 남는 건 일부러 넣은 것처럼 보이는 헤더입니다.
app.register(fastifyStatic, {
root: join(__dirname, 'public'),
cacheControl: false, // 없으면 setHeaders가 덮어쓰기당한다
setHeaders: setStaticCacheHeaders,
});
캐시 설정을 건드리기 전에 응답 하나를 끝까지 읽으세요. 캐시를 망가뜨리는 헤더는 이름에 cache가 들어 있지 않은 경우가 대부분입니다.