Cloudflare가 캐시하지 않는 3가지 이유: Vary, RSC, 언어 감지
사이트를 Cloudflare 뒤에 두고 캐시 헤더를 제대로 붙였는데도 CF-Cache-Status가 계속 DYNAMIC으로 나오는 일을 겪었습니다. 한 번이 아니라 세 번이었고, 매번 다른 문제처럼 보였습니다.
- 이미지가 캐시되지 않았습니다
- 블로그 글 페이지가 캐시되지 않았습니다
- 회사 소개 페이지가 캐시되지 않았습니다
세 번째를 고치다가 알았습니다. 셋 다 같은 문제였습니다.
부끄러운 부분부터 적겠습니다. 저는 첫 번째를 고치고 나서 "캐시 문제 해결"이라고 결론지었습니다. 확인한 것은 경로 하나였는데 결론은 시스템 전체에 내렸습니다. 며칠 뒤에 다시 재보니 자산 317개 중 315개가 여전히 캐시되지 않고 있었습니다. 제가 고친 쪽이 전체의 0.6%였습니다.
첫 번째: 이미지
파일을 내려주는 경로와 이미지를 내려주는 경로가 따로 있었는데, 저는 파일 쪽만 봤습니다. 그리고 자산의 대부분은 이미지였습니다.
이미지 응답에는 이런 헤더가 붙어 있었습니다.
Cache-Control: public, max-age=31536000, immutable
Vary: Accept캐시해달라고 1년을 요청하고 있는데 DYNAMIC이었습니다. Vary: Accept가 붙은 이유는 있었습니다. 브라우저가 AVIF를 지원하면 AVIF로, 아니면 WebP로 내려주는 자동 협상을 하고 있었으니까요. 같은 URL이 요청자에 따라 다른 응답을 주니 Vary를 다는 것이 HTTP 규격상 옳습니다.
문제는 Cloudflare가 Accept-Encoding 외의 Vary 값을 캐시 키로 다루지 않는다는 점입니다. 그래서 그 응답은 캐시 대상에서 빠집니다.
여기서 선택지가 갈립니다. 협상과 엣지 캐시를 동시에 가질 수는 없습니다. 저는 협상을 포기하는 대신 협상 결과를 URL에 새겼습니다.
/img/{slug}.webp
/img/{slug}.avif포맷이 주소에 이미 적혀 있으면 Vary가 필요 없습니다. 구현은 포맷을 정하는 순서를 명확히 하는 것부터입니다.
let negotiated = false;
if (ext) {
// URL 확장자가 가장 셉니다
options.format = EXT_FORMAT[ext];
} else if (formatParam) {
options.format = formatParam;
} else {
// 여기로 오는 경우에만 응답이 요청자에 따라 갈립니다
const accept = req.headers.get("accept") || "";
if (accept.includes("image/avif")) options.format = "avif";
negotiated = true;
}그리고 Vary는 실제로 협상했을 때만 답니다.
headers: {
"Content-Type": mimeType,
"Cache-Control": "public, max-age=31536000, immutable",
...(negotiated ? { Vary: "Accept" } : {}),
}두 번째: 블로그 HTML
이미지를 고치고 나서 HTML을 봤습니다. 같은 증상이었습니다.
cf-cache-status: DYNAMIC
cache-control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400s-maxage=300은 "공유 캐시는 5분간 저장해달라"는 명시적 요청입니다. 그런데 무시되고 있었습니다.
이번 원인은 달랐습니다. Cloudflare는 HTML을 기본적으로 캐시하지 않습니다. 기본 캐시 대상은 확장자로 판별되는 정적 파일이고 HTML은 여기 들어가지 않습니다. 캐시하려면 Cache Rule을 따로 만들어야 합니다.
여기서 DYNAMIC과 BYPASS의 차이를 알아두면 좋습니다. BYPASS는 캐시 대상으로 보긴 했는데 저장하지 않은 것이고, DYNAMIC은 애초에 대상이 아니라고 판정한 것입니다. 헤더를 더 세게 줘서 고칠 수 있는 건 앞쪽뿐입니다.
그런데 규칙을 만들기 전에 응답을 한 번 더 봤습니다.
curl -s https://example.com/blog | head -c 60
curl -s -H 'RSC: 1' https://example.com/blog | head -c 60<!DOCTYPE html><html lang="ko" ...
1:"$Sreact.fragment" 2:I[12929,[...같은 URL인데 본문이 다릅니다. 아래쪽은 Next.js App Router가 화면 전환에 쓰는 RSC 페이로드로, content-type도 text/x-component로 따로 나갑니다. 그리고 Next.js는 정직하게 알려줍니다.
Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch이미지에서 본 것과 정확히 같은 모양이었습니다. Cloudflare가 이 Vary를 무시하므로, 그대로 캐시를 켜면 엣지가 HTML과 RSC를 같은 것으로 취급합니다. 브라우저가 HTML을 기대하는데 RSC 페이로드가 나가면 화면이 깨집니다.
그래서 규칙에서 RSC 요청을 뺐습니다.
(http.host eq "example.com"
and starts_with(http.request.uri.path, "/blog")
and not any(http.request.headers.names[*] eq "rsc"))RSC 요청은 규칙에 걸리지 않아 예전처럼 오리진으로 갑니다. 캐시 대상이 되는 건 HTML뿐이라 섞일 일이 없습니다.
세 번째: 소개 페이지
메인 사이트 페이지들은 캐시 헤더가 오히려 관대했습니다. 소개·약관 페이지는 s-maxage=31536000, 1년이었습니다. 그런데도 DYNAMIC이었습니다.
응답 헤더를 뜯어보니 이게 있었습니다.
Set-Cookie: NEXT_LOCALE=..
Set-Cookie: __Host-authjs.csrf-token=... (131자)
Set-Cookie: __Secure-authjs.callback-url=...모든 공개 페이지가 인증 라이브러리의 CSRF 토큰을 심고 있었습니다. 미들웨어가 이렇게 돼 있었기 때문입니다.
export default auth((req) => {
if (ADMIN_RE.test(pathname)) { /* 관리자만 세션이 필요 */ }
return intl(req);
});
export const config = {
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"], // 사실상 모든 페이지
};관리자 경로만 세션이 필요한데 전 경로가 인증 미들웨어를 통과하고 있었습니다. 통과할 때마다 응답에 쿠키가 실렸고요.
공유 캐시는 Set-Cookie가 붙은 응답을 저장하면 안 됩니다. 저장하면 한 방문자에게 발급된 CSRF 토큰이 이후 모든 방문자에게 같은 값으로 배포됩니다. 사용자마다 달라야 의미가 있는 값인데 전원이 같은 걸 갖게 되면 방어가 무력화됩니다. Cloudflare가 이런 응답을 캐시하지 않는 건 정확히 이 사고를 막으려는 것입니다.
세션이 필요한 곳에서만 인증을 태우도록 좁혔습니다.
export default function middleware(req: NextRequest, event: NextFetchEvent) {
if (ADMIN_RE.test(req.nextUrl.pathname)) {
return guarded(req, event);
}
return intl(req);
}쿠키 셋이 하나로 줄었습니다. 그런데도 여전히 캐시되지 않았습니다. 남은 하나가 NEXT_LOCALE이었고, 그 뒤에 더 큰 게 있었습니다.
curl -sI -H 'Accept-Language: ko' https://example.com/about # 200
curl -sI -H 'Accept-Language: en' https://example.com/about # 307 → /en/about
curl -sI -H 'Cookie: NEXT_LOCALE=en' https://example.com/about # 307 → /en/about또 같은 문제였습니다. /about 하나가 브라우저 언어에 따라 한국어 본문도 되고 영어로 가는 리다이렉트도 됩니다. 이걸 캐시하면 첫 방문자의 언어가 모두에게 고정됩니다. 영어 사용자가 먼저 오면 이후 한국인 방문자도 전부 /en/about으로 튕깁니다.
언어 자동 감지와 로케일 쿠키를 껐습니다. 이제 /about은 언제나 한국어, /en/about은 언제나 영어입니다. 언어는 오직 URL이 결정합니다.
대가가 있습니다. 외국어 브라우저로 들어와도 첫 화면은 한국어입니다. 전환은 사용자가 언어 스위처로 해야 합니다. 저희는 외국어 유입 비중이 낮아 감수했지만, 이건 트래픽 구성을 보고 정할 문제입니다.
하나의 문장으로 줄이면
세 번 다 이것이었습니다.
하나의 URL이 여러 응답을 가지면 엣지는 그것을 구분하지 못합니다.
Vary: Accept도, RSC 헤더도, Accept-Language도 전부 "같은 주소인데 요청자에 따라 답이 다르다"는 상황입니다. HTTP에는 이걸 표현하는 방법(Vary)이 있지만, Cloudflare를 비롯한 공유 캐시는 Accept-Encoding 외에는 거의 존중하지 않습니다.
Set-Cookie도 결이 같습니다. 방문자마다 다른 값을 응답에 실으면 그 응답은 공유될 수 없습니다.
그래서 얻은 판단 기준
제가 세 번을 각각 다른 문제로 취급하며 시간을 쓴 이유는, 증상이 매번 다른 자리에서 나타났기 때문입니다. 이미지에서 한 번 겪었으면 HTML에서는 바로 알아봤어야 했는데 그러지 못했습니다.
캐시가 안 걸릴 때 헤더를 더 세게 주는 건 대개 헛수고입니다. 먼저 물어야 할 질문은 이겁니다.
이 URL은 누가 요청하든 같은 것을 돌려주는가?
아니라면 방법은 둘입니다. 다르게 만드는 요소를 URL로 끌어올리거나(포맷을 확장자로, 언어를 경로로), 아니면 그 요소를 없애거나(공개 페이지에서 인증 쿠키 제거).
응답 헤더로 해결하려 들면 안 됩니다. 캐시 적격성 판정은 헤더를 읽기 전에 끝나 있습니다.
결과
| 지면 | 전 | 후 |
|---|---|---|
| 블로그 목록 | 397 ms | 158 ms |
| 블로그 글 | 616 ms | 157 ms |
| 문서 목록 | 358 ms | 163 ms |
서버 대기 시간 기준이고, 엣지가 오리진까지 다녀오던 왕복이 사라진 몫입니다.
한 가지 덧붙이면, 캐시를 켜기 전에 그 응답에 인증이 걸려 있는지 반드시 함께 보셔야 합니다. 캐시되지 않는 응답에 붙은 잘못된 Cache-Control은 아무 해가 없어서 조용히 남아 있습니다. 캐시를 켜는 순간 그것이 활성화됩니다. 저는 비공개 이미지에 public, immutable이 붙어 있는 걸 배포 직전에 발견했습니다.
함께 읽기
- 캐시 퍼지 순서: 오리진을 먼저 비워야 하는 이유엣지 캐시를 켜고 나니 대가가 하나 생겼습니다. 글을 고쳐 발행해도 최대 5분 동안 옛 내용이 나갔습니다. 캐시가 없던 때는 즉시 반영되던 일이라 후퇴처럼 느껴졌습니다.
- 한국에서 Cloudflare 엣지가 서울이 아닐 때: 무료 플랜의 POP 배정이미지 엣지 캐시를 손보고 나서 얼마나 빨라졌는지 재고 있었습니다. 캐시에서 나오면 156ms, 오리진까지 가면 622ms. 4배 차이니까 좋아하고 있었는데, 156ms라는 숫자가 자꾸 걸렸습니다.
- 엣지 캐시를 켜기 전 확인할 것: 비공개 응답의 Cache-Control전에 CDN의 파일 경로에 확장자가 없어서 Cloudflare가 캐시하지 않던 문제를 고쳤습니다. 헤더를 아무리 강하게 줘도 CF-Cache-Status가 DYNAMIC으로 나오던 그 이야기입니다.
- 블로그를 서브도메인에서 하위 경로로 옮긴 이유, 그리고 CSP가 애드센스를 막고 있었습니다회사 사이트와 기술 블로그를 따로 운영하는 구성은 흔합니다. 회사는 duolabs.co.kr, 블로그는 blog.duolabs.co.kr.
- 태그 485개를 전부 색인시키고 있었습니다: 블로그 SEO를 다시 손본 기록블로그에는 이미 canonical, sitemap, robots.txt, 글별 Open Graph 이미지, 구조화 데이터가 들어가 있었습니다. RSS와 Atom, JSON Feed도 있었고 관련 글 링크도 붙어 있었습니다. 그래서 처음에는 큰 구멍보다는 메타 설명을 조금 다듬는 정도를 예상했습니다.