- 분야: 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, 200 | public, max-age=0, must-revalidate | 2종 모두 public, max-age=31536000, immutable |
| 해시 JS, HEAD 200 / 정상 MIME 304 | public, max-age=0 | 2종 모두 immutable, 빈 본문 유지 |
private, no-store | private, no-store | 같은 Response 반환 |
| Set-Cookie 있음 | public, max-age=0 | 같은 Response 반환 |
| JS URL의 HTML fallback | no-store | 같은 Response 반환 |
| JS URL의 404 | public, max-age=0 | 같은 Response 반환 |
/api/study | no-store | 같은 Response 반환 |
/favicon.ico | public, max-age=0 | 같은 Response 반환 |
| MIME 없는 304 | public, 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 정책을 보존했다.