
# CDN이 아무것도 캐시하지 않는 이유

agentready.md의 정적 파일은 하나같이 같은 줄을 달고 돌아왔습니다.

```
cf-cache-status: BYPASS
```

폰트, 스타일시트, 스크립트, 이미지. `MISS`가 아닙니다. MISS라면 엣지가 아직 그 파일을 본 적이 없다는 뜻이고, 방금 받아온 사본은 보관됩니다. `BYPASS`는 엣지가 응답을 읽고 저장할 수 없다고 판단했다는 뜻이고, 내일도 같은 판단을 합니다. 방문자마다 모든 바이트를 오리진에서 끌어가고 있었고, 그대로 뒀다면 영원히 그랬을 겁니다.

## 원인이 아니었던 설정 네 가지

- **Development Mode** — 꺼짐. 세 시간 동안 엣지 캐시를 통째로 정지시키는 설정이고 켜둔 채 잊기 쉬우니 가장 먼저 확인합니다.
- **Caching Level** — Standard.
- **Page Rules** — Cache Level을 Bypass로 둔 규칙 없음.
- **Browser Cache TTL** — 무난한 값.

대시보드가 캐시에 대해 하는 말은 전부 *이걸 저장하라*였습니다. 엣지는 아무것도 저장하지 않았습니다.

## 답은 대시보드가 아니라 응답 안에 있었다

찾으러 간 그 한 줄만 보지 말고 헤더 블록을 통째로 읽으면 바로 드러납니다.

```bash
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를 약하게 만들지는 않습니다. 토큰을 쓸 수 없는 대상에게 발급을 멈출 뿐입니다.

```js
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 배지가 그렇고, 이것들도 폼을 렌더링하지 않습니다.

그 쿠키가 직접 붙인 게 아니라면 세션 계층을 보세요. 미들웨어를 지나는 요청마다 세션을 열고 그 쿠키를 내려보내는 프레임워크가 적지 않습니다. 안에 아무것도 저장되지 않아도 그렇습니다.

## 어떤 스택에서든 진단하기

첫 페이지 말고 정적 자원을 확인하세요. 첫 페이지는 정당한 이유로 캐시 불가인 경우가 많고, 그 페이지가 끌어오는 서른 개 파일에 대해서는 아무것도 알려주지 않습니다.

```bash
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`로 자기 헤더를 만들어 아무 말 없이 덮어씁니다. 남는 건 일부러 넣은 것처럼 보이는 헤더입니다.

```js
app.register(fastifyStatic, {
  root: join(__dirname, 'public'),
  cacheControl: false,           // 없으면 setHeaders가 덮어쓰기당한다
  setHeaders: setStaticCacheHeaders,
});
```

캐시 설정을 건드리기 전에 응답 하나를 끝까지 읽으세요. 캐시를 망가뜨리는 헤더는 이름에 cache가 들어 있지 않은 경우가 대부분입니다.

[사이트 확인하기](/ko) · [AI 준비 체크리스트](/ko/tools/ai-readiness-checklist) · [봇이 허용돼 있나](/ko/tools/ai-crawlers-checker)
