해시 JS·CSS의 재검증 정책을 자산 경계에서 수정

이 글의 목차 10개 항목
  • 분야: DevOps
  • 기술 스택: Cloudflare Workers · TypeScript · HTTP 캐시

문제와 원인

기존 Worker의 마지막 전달은 handler.fetch였으며, /_next/static/ 응답의 캐시 정책을 검사하는 분기가 빠져 있었다. 정적 파일을 플랫폼이 먼저 반환하는 경로도 있어 일반 HTML 헤더 처리만으로 해시 JS·CSS의 정책을 제어하기 어려웠다.

수정 전 코드

worker.fetch

return handler.fetch(request, env, ctx);

vite.config.ts — localBindingConfig

main: "./apps/frontend/worker/index.ts",
compatibility_flags: ["nodejs_compat"],
vars: localRuntimeVariables,

수정 후 코드

vite.config.ts — localBindingConfig

빌드 정적 경로를 Worker 우선으로 라우팅하고 GET·HEAD 요청을 ASSETS에 전달했다.

assets: {
  binding: "ASSETS",
  run_worker_first: ["/_next/static/*"],
},

worker.fetch

const assets = (env as Partial<Env>).ASSETS;
if (assets
  && url.pathname.startsWith("/_next/static/")
  && ["GET", "HEAD"].includes(request.method)) {
  return fetchImmutableStaticAsset(request, assets);
}

fetchImmutableStaticAsset

해시 경로, 200·304 상태, JS·CSS MIME이 맞고 private·no-store·Set-Cookie가 없는 응답에 public, max-age=31536000, immutable을 적용한다. 31536000초는 캐시 유효기간이다.

const response = await assets.fetch(request);
const pathname = new URL(request.url).pathname;
if (!isImmutableStaticAssetPath(pathname)
  || ![200, 304].includes(response.status)) return response;

const contentType = response.headers.get("content-type") ?? "";
const expectedType = pathname.endsWith(".css")
  ? /text\/css/iu : /(?:javascript|ecmascript)/iu;
const existingPolicy = response.headers.get("cache-control") ?? "";
if (!expectedType.test(contentType)
  || /(?:private|no-store)/iu.test(existingPolicy)
  || response.headers.has("set-cookie")) return response;

const headers = new Headers(response.headers);
headers.set("Cache-Control", "public, max-age=31536000, immutable");
return new Response(response.body, {
  status: response.status,
  statusText: response.statusText,
  headers,
});

경로 판별은 이름-해시.js, 이름.해시.css 형태와 8–16자의 영문·숫자·밑줄·하이픈 해시에 한정한다. 기존 헤더와 본문 스트림을 전달해 ETag·Content-Encoding·Vary: Accept-Encoding을 보존한다.

staticAssetHeaders

Worker를 거치지 않는 정적 전달에는 같은 판별기로 빌드 파일을 골라 _headers를 생성했다. 정렬한 정확한 URL 규칙을 최대 100개 기록하고 나머지는 Worker가 처리한다. ASSETS 바인딩이 없는 로컬 서버는 기존 전달 경로를 유지한다.

검증 결과

Node에서 원본 fetchImmutableStaticAsset에 합성 응답 11개를 단계마다 1회씩 전달했다. 본문은 safe static bytes, ETag는 "stable", 압축은 br, Vary는 Accept-Encoding을 사용했다.

입력 조건원응답 Cache-Control수정 후
해시 JS·CSS, 정상 MIME, 200public, max-age=0, must-revalidate2종 모두 public, max-age=31536000, immutable
해시 JS, HEAD 200 / 정상 MIME 304public, max-age=02종 모두 immutable, 빈 본문 유지
private, no-storeprivate, no-store같은 Response 반환
Set-Cookie 있음public, max-age=0같은 Response 반환
JS URL의 HTML fallbackno-store같은 Response 반환
JS URL의 404public, max-age=0같은 Response 반환
/api/studyno-store같은 Response 반환
/favicon.icopublic, max-age=0같은 Response 반환
MIME 없는 304public, max-age=0같은 Response 반환

제외 응답 7/7종은 같은 Response 객체를 반환했고, 11/11종에서 본문·ETag·Content-Encoding·Vary를 보존했다.

같은 환경에서 원본 staticAssetHeaders를 입력별 1회 실행했다.

빌드 파일 입력전체 파일해시 JS·CSS생성한 정확한 URL 규칙
기본 입력4개2개2개
해시 파일 100개 추가104개102개100개

두 입력에서 HTML·해시 없는 JS·wildcard 규칙은 각각 0개였다.

Swagger UI, 1280×720 CSS px, DPR 2에서 로컬 서버가 새 합성 응답에 원본 함수를 적용한 세 URL을 각 1회 요청했다. 모두 HTTP 200이었고 JS 전후의 Brotli 본문·ETag·Vary와 private 정책을 보존했다.

원응답 JS: max-age=0, must-revalidate원본 보기 (새 창)

원응답 JS: max-age=0, must-revalidate

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

수정 JS: max-age=31536000, immutable과 검증자 헤더 보존원본 보기 (새 창)

수정 JS: max-age=31536000, immutable과 검증자 헤더 보존

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

개인 응답: private, no-store 유지원본 보기 (새 창)

개인 응답: private, no-store 유지

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.