엣지 캐시를 마저 켜자 비공개 이미지 문제가 드러났습니다
전에 CDN의 파일 경로에 확장자가 없어서 Cloudflare가 캐시하지 않던 문제를 고쳤습니다. 헤더를 아무리 강하게 줘도 CF-Cache-Status가 DYNAMIC으로 나오던 그 이야기입니다.
고치고 나서 저는 캐시 문제가 끝났다고 생각했습니다. 아니었습니다. 며칠 뒤에 다시 재보니 CDN이 들고 있는 자산 317개 중 315개가 여전히 캐시되지 않고 있었습니다.
파일은 고쳤는데 이미지는 잊었습니다
CDN에는 경로가 둘입니다. 원본 파일을 내려주는 /f와 이미지를 내려주는 /img입니다. 저는 /f만 고쳤습니다.
문제는 자산의 대부분이 이미지라는 점입니다. 파일은 2개, 이미지는 315개였습니다. 고친 쪽이 전체의 0.6%였던 셈입니다.
확인은 헤더만 보면 됩니다.
curl -sI https://cdn.duolabs.co.kr/f/{slug}.pdf | grep -i cf-cache-status
# cf-cache-status: HIT
curl -sI https://cdn.duolabs.co.kr/img/{slug} | grep -i cf-cache-status
# cf-cache-status: DYNAMIC한 번만 부르면 안 됩니다. 첫 요청은 캐시에 없으니 당연히 MISS입니다. 두 번 이상 부른 다음의 상태를 봐야 합니다.
확장자를 붙여도 안 되는 두 번째 이유가 있었습니다
이미지 경로에는 확장자가 없었으니 같은 처방을 쓰면 될 줄 알았습니다. 그런데 응답 헤더를 다시 보다가 파일 쪽에는 없고 이미지 쪽에만 있는 헤더를 발견했습니다.
Cache-Control: public, max-age=31536000, immutable
Vary: AcceptVary: Accept가 붙어 있었습니다. 이미지 라우트가 브라우저의 Accept 헤더를 보고 AVIF를 지원하면 AVIF로, 아니면 WebP로 내려주는 자동 협상을 하고 있었기 때문입니다. 같은 URL이 요청자에 따라 다른 응답을 주니 Vary를 다는 것이 HTTP 규격상 맞습니다.
문제는 Cloudflare가 Accept-Encoding 외의 Vary 값을 캐시 키로 다루지 않는다는 점입니다. 그래서 이 응답은 캐시 대상에서 빠집니다.
여기서 깨달은 것은 이 둘이 양립할 수 없다는 사실이었습니다. Accept로 포맷을 협상하면서 동시에 깨끗한 엣지 캐시를 갖는 방법은 없습니다. 하나를 포기해야 합니다.
포맷을 URL로 옮겼습니다
협상을 포기하는 대신 협상 결과를 URL에 새겼습니다. 어떤 포맷을 원하는지 주소가 이미 말하고 있으면 Vary가 필요 없습니다.
/img/{slug}.webp
/img/{slug}.avif
/img/{slug}.webp?w=400구현은 주소에서 확장자를 떼어 실제 식별자를 얻는 것부터 시작합니다. 식별자가 UUID라 점이 들어갈 일이 없어서 마지막 점을 기준으로 자르면 됩니다.
const EXT_FORMAT: Record<string, string> = {
webp: "webp",
avif: "avif",
jpeg: "jpeg",
jpg: "jpeg", // 흔한 별칭이라 함께 받습니다
png: "png",
};
function parseSlugParam(param: string): { slug: string; ext: string | null } {
const dot = param.lastIndexOf(".");
if (dot <= 0) return { slug: param, ext: null };
return { slug: param.slice(0, dot), ext: param.slice(dot + 1).toLowerCase() };
}그다음 포맷을 정하는 순서를 명확히 했습니다. 확장자가 가장 세고, 그다음이 쿼리 파라미터, 마지막이 Accept 협상입니다.
let negotiated = false;
if (ext) {
const mapped = EXT_FORMAT[ext];
if (!mapped) {
return NextResponse.json({ error: "Format not allowed" }, { status: 400 });
}
options.format = mapped;
} else if (formatParam) {
if (!ALLOWED_FORMATS.includes(formatParam)) {
return NextResponse.json({ error: "Format not allowed" }, { status: 400 });
}
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" } : {}),
}확장자 없는 옛 주소는 일부러 살려뒀습니다. 어딘가에 붙여넣어 둔 주소가 있을 수 있어서입니다. 그 경로는 예전처럼 동작하되 캐시 혜택은 받지 못합니다. 느리지만 깨지지는 않습니다.
캐시를 켜자 잠자던 버그가 깨어났습니다
여기까지 하고 배포하려다가 응답 헤더를 만드는 부분을 다시 봤습니다.
// 고치기 전
"Cache-Control": viaShareToken
? "private, no-store"
: "public, max-age=31536000, immutable",만료되는 공유 토큰으로 들어온 요청만 캐시를 막고 있었습니다. 그러면 관리자 세션으로 받은 비공개 이미지에도 public, immutable이 붙습니다.
지금까지는 아무 일도 없었습니다. /img 자체가 엣지에 캐시되지 않았으니까요. 그런데 제가 방금 그 캐시를 켜려던 참이었습니다. 배포했다면 관리자가 열어본 비공개 이미지가 엣지에 저장되고, 그 뒤로는 로그인하지 않은 사람에게도 같은 주소로 나갔을 겁니다.
판단 기준을 접근 경로가 아니라 이미지 자체의 공개 여부로 바꿨습니다.
// 고친 뒤
"Cache-Control": image.isPublic && !viaShareToken
? "public, max-age=31536000, immutable"
: "private, no-store",공유 토큰 경유를 여전히 막는 이유는 따로 있습니다. 토큰은 만료되지만 캐시된 사본은 만료되지 않기 때문입니다. 토큰을 회수해도 엣지에 남은 파일은 계속 나갑니다.
솔직히 말하면 이 시스템에는 비공개 이미지가 한 장도 없었습니다. 그래서 실제로 새어나간 것은 없습니다. 다만 비공개 이미지를 한 장 올리는 순간 문제가 되는 상태였고, 그때는 아무 경고 없이 조용히 새어나갔을 겁니다.
얼마나 빨라졌나
연결 수립 시간을 빼고 서버가 첫 바이트를 보내기까지의 시간만 다섯 번 재서 중앙값을 봤습니다.
| 경로 | 중앙값 | 범위 |
|---|---|---|
| 확장자 있음 (엣지 HIT) | 154 ms | 150 ~ 161 ms |
| 확장자 없음 (오리진까지) | 436 ms | 309 ~ 632 ms |
빨라진 것도 의미가 있지만 저는 범위 쪽이 더 중요하다고 봅니다. 엣지에서 나가면 11ms 안에서 흔들리는데, 오리진까지 내려가면 300ms대와 600ms대를 오갑니다. 사용자가 체감하는 것은 평균보다 느린 쪽의 경험입니다.
부수 효과도 있습니다. 캐시된 요청은 오리진에 도달하지 않으므로 서버가 이미지를 읽고 변환하는 일 자체가 사라집니다.
두 가지를 배웠습니다
부분 수정은 착각을 만듭니다. /f를 고치고 HIT를 확인한 순간 저는 "캐시 문제 해결"이라고 결론지었습니다. 확인한 것은 경로 하나였는데 결론은 시스템 전체에 내렸습니다. 같은 성질의 코드가 여러 곳에 있다면, 고친 곳이 아니라 고치지 않은 곳을 세어봐야 합니다.
성능 작업이 인증 결함을 깨울 수 있습니다. 캐시되지 않는 응답에 붙은 잘못된 Cache-Control은 아무 해가 없습니다. 그래서 조용히 남아 있었습니다. 캐시를 켜는 순간 그것이 활성화됩니다. 캐시 적격성을 바꾸는 작업을 할 때는 그 응답에 인증이 걸려 있는지 함께 봐야 합니다.
확인할 때 쓰는 명령
마지막으로 실제로 썼던 확인 방법을 남겨둡니다. 상태만 보는 것보다 캐시 정책과 Vary를 함께 보는 편이 원인을 빨리 좁혀줍니다.
# 두 번 이상 호출한 뒤의 상태를 봐야 합니다
url="https://cdn.duolabs.co.kr/img/{slug}.webp"
curl -sI "$url" > /dev/null
curl -sI "$url" | grep -iE "^(cf-cache-status|cache-control|vary|age):"
# cf-cache-status: HIT
# cache-control: public, max-age=31536000, immutable
# age: 7231age가 올라가고 있으면 엣지가 실제로 들고 있다는 뜻입니다. DYNAMIC이 보이면 헤더를 더 세게 주기 전에 URL 모양과 Vary부터 확인해보시길 권합니다. 저는 그 순서를 몰라서 두 번 헤맸습니다.
함께 읽기
- 한국에서 접속하는데 Cloudflare가 로스앤젤레스에서 응답했습니다이미지 엣지 캐시를 손보고 나서 얼마나 빨라졌는지 재고 있었습니다. 캐시에서 나오면 156ms, 오리진까지 가면 622ms. 4배 차이니까 좋아하고 있었는데, 156ms라는 숫자가 자꾸 걸렸습니다.
- Cache-Control: immutable인데 왜 Cloudflare는 계속 DYNAMIC이었을까요?CDN을 붙이고 응답 헤더에 긴 캐시 시간을 설정했는데도 CF-Cache-Status가 계속 DYNAMIC으로 표시되는 경우가 있습니다. 저희도 파일 제공 기능을 점검하면서 같은 상황을 만났습니다.
- 블로그를 서브도메인에서 하위 경로로 옮긴 이유, 그리고 CSP가 애드센스를 막고 있었습니다회사 사이트와 기술 블로그를 따로 운영하는 구성은 흔합니다. 회사는 duolabs.co.kr, 블로그는 blog.duolabs.co.kr.
- Next.js + Postgres 프로젝트 호스팅 비용 비교: 관리형 플랫폼과 VPSNext.js와 PostgreSQL로 만든 웹 애플리케이션을 운영할 때 관리형 플랫폼과 VPS 중 어느 쪽이 더 저렴한지는 월 기본요금만으로 판단하기 어렵습니다. 관리형 플랫폼은 배포와 확장을 대신 처리하고, VPS는 낮은 인프라 비용 대신 서버 운영을 직접 맡아야 합니다.
- 운영에서만 AI 답변이 한 번에 나온다면, nginx가 스트림을 삼키고 있습니다챗봇 답변을 한 글자씩 흘려보내는 것은 기술적 과시가 아닙니다. 사람이 기다릴 수 있게 만드는 장치입니다. 같은 5초라도 빈 화면을 보는 5초와 글자가 차오르는 5초는 완전히 다른 시간입니다.