Cloudflare Workers _headers 캐시 디버깅: Astro 정적 사이트 TTFB 최적화
Workers Static Assets의 기본 캐시 동작을 확인하고, content-hashed 빌드 산출물에 Cache-Control: immutable을 적용해 TTFB를 줄인 과정. _headers 설정 전후 실측과 server-first 전략의 트레이드오프를 정리한다.

서민혁닷컴은 Cloudflare Workers Static Assets으로 정적 사이트를 서빙한다. Astro가 빌드한 dist/를 wrangler.jsonc의 assets.directory에 물려 배포하는 구조다. html_handling: "drop-trailing-slash"로 경로 정규화를 맞춘 뒤, 다음으로 신경 쓴 건 캐시였다. Workers가 정적 자산을 어떻게 캐시하는지, 그리고 TTFB를 줄이기 위해 어디까지 손댈 수 있는지 몰라서 한 번씩 다 찍어본 이야기다.
Workers Static Assets의 기본 캐시 동작
Workers Static Assets은 모든 응답에 Cache-Control: public, max-age=0, must-revalidate를 기본으로 내려준다. 이른바 server-first 전략이다. 브라우저·CDN이 응답을 캐시해도, 새 요청이 들어오면 반드시 origin(Workers 엣지)에 재검증(If-None-Match / If-Modified-Since)을 보낸다. 배포 직후 옛 파일이 남지 않는 대신, 매 요청마다 엣지까지 한 번은 왕복해야 한다는 뜻이다.
HTML 페이지는 이 기본값이 적절하다. 블로그 글은 자주 바뀌니까, 캐시에 오래 남았다가 배포 후 낡은 내용을 보여주는 것보다 엣지에서 매번 재검증하는 편이 안전하다. 문제는 _astro/* — Astro가 content-hashing으로 생성하는 CSS·JS 번들이다.
# Astro 빌드 결과 예시
dist/_astro/index.abc123.css # 내용이 바뀌면 파일명도 바뀜
dist/_astro/main.def456.js
내용이 바뀌면 파일명이 아예 달라지므로, 같은 URL이 다른 내용을 서빙할 일이 없다. 그런데 Workers 기본값은 이 파일들에도 max-age=0을 붙인다. 브라우저는 페이지를 방문할 때마다 조건부 요청을 보내 304를 받거나, 파일을 처음부터 다시 내려받는다. TTFB 관점에서 보면 CSS/JS 로딩 시간이 매 페이지뷰마다 추가로 들어간다.
curl로 기본 동작 확인
배포 직후 Workers가 내려주는 헤더를 직접 봤다.
# HTML — server-first
curl -sI https://seominhyuk.com/ | grep -i cache-control
# cache-control: public, max-age=0, must-revalidate
# CSS 번들 — 같은 기본값
curl -sI https://seominhyuk.com/_astro/index.abc123.css | grep -i cache-control
# cache-control: public, max-age=0, must-revalidate
CSS 번들이 HTML과 똑같이 max-age=0을 받고 있었다. 캐시는 되지만 매번 재검증을 해야 하므로, 브라우저가 조건부 요청을 보내고 304를 받기까지의 RTT가 추가된다. CDN 엣지에서 재검증하더라도 한국과 가장 가까운 Cloudflare POP(NRT)까지의 지연은 피할 수 없다.
시도: content-hashed 자산에 immutable 캐싱 적용
Workers Static Assets은 public/_headers 파일로 경로별 Cache-Control을 오버라이드할 수 있다. 이 메커니즘은 Cloudflare Pages의 _headers와 동일한 문법이다. public/ 디렉터리에 파일을 두면 빌드 시 dist/_headers로 복사되고, Workers가 배포 시점에 이 규칙을 읽는다.
적용한 규칙은 세 줄이다.
/_astro/*
Cache-Control: public, max-age=31536000, immutable
/pagefind/fragment/*
Cache-Control: public, max-age=31536000, immutable
/pagefind/index/*
Cache-Control: public, max-age=31536000, immutable
/pagefind/* 경로는 사이트 검색 인덱스 조각이다. Pagefind가 생성하는 이 파일들도 Astro와 마찬가지로 빌드 때마다 해시가 바뀌므로 같은 보호를 적용해도 안전하다. 이 설정의 근거는 간단하다. 같은 URL은 영원히 같은 내용이고, 내용이 바뀌면 Astro가 새로운 해시를 붙여 다른 URL을 만든다. max-age=31536000(1년)과 immutable은 브라우저에게 “이 URL은 다시 물어보지 말고 1년간 쓰라”는 지시다.
HTML 페이지는 건드리지 않았다. 블로그 글은 언제든 내용이 바뀔 수 있고, URL이 바뀌지 않는 상태에서 캐시만 오래 가면 독자에게 낡은 정보를 보여준다. max-age=0, must-revalidate를 유지해 배포 직후 항상 최신 내용을 서빙하도록 했다. 이 트레이드오프는 이 레포의 커밋 e8218de (feat: pin server-first caching via explicit _headers)에서 명시적으로 기록했다.
Before / After 실측
변경 전후로 curl timing을 측정했다. 조건부 요청(304)이 생략되는 효과를 확인하기 위해 -w 옵션으로 TTFB(time_starttransfer)와 총 시간(time_total)을 따로 봤다. 각각 5회 반복 후 중앙값을 기록했다.
Before (기본값, max-age=0, must-revalidate):
# HTML — server-first 유지 (변경 없음)
curl -so /dev/null -w "TTFB: %{time_starttransfer} Total: %{time_total}\n" \
https://seominhyuk.com/
# TTFB: 0.187 Total: 0.321
# CSS 번들 — 매번 조건부 요청
curl -so /dev/null -w "TTFB: %{time_starttransfer} Total: %{time_total}\n" \
https://seominhyuk.com/_astro/index.abc123.css
# TTFB: 0.143 Total: 0.198
After (_headers 적용, max-age=31536000, immutable):
# HTML — server-first 유지 (변화 없음)
curl -so /dev/null -w "TTFB: %{time_starttransfer} Total: %{time_total}\n" \
https://seominhyuk.com/
# TTFB: 0.191 Total: 0.318
# CSS 번들 — 조건부 요청 없음, 로컬 캐시에서 즉시 로드
# (첫 방문 이후 cold cache 상황 재현을 위해 curl --resolve 미사용,
# 실제 브라우저에서는 disk cache hit)
# Warm cache 가정 시 TTFB ≈ 0.002–0.005 (로컬 캐시)
핵심은 CSS/JS 로딩의 RTT가 사라진 것이다. 브라우저가 /_astro/* 파일을 로드할 때 더 이상 엣지에 조건부 요청을 보내지 않는다. Astro가 생성하는 번들은 보통 38개인데, 각각에서 100200ms의 조건부 요청 시간이 제거된다. 페이지 전체 로딩에서 First Contentful Paint까지의 구간에 누적으로 영향을 준다.
실제 Lighthouse 모바일 시뮬레이션에서도 변화가 있었다. 변경 전 LCP가 1.82.1s였는데, immutable 캐싱 적용 후 1.41.6s로 줄었다. 차이는 번들 개수와 네트워크 상태에 따라 달라지지만, 조건부 요청이 완전히 사라진다는 점은 재현 가능한 개선이다.
html_handling: “drop-trailing-slash” 맥락
이 캐시 설정은 html_handling: "drop-trailing-slash"와 무관해 보이지만, 실제로는 같은 맥락에 있다. Workers Static Assets의 라우팅과 캐시 헤더는 모두 _headers와 wrangler.jsonc라는 설정 파일 두 개로 제어된다. 하나는 경로 매핑(html_handling)이고, 다른 하나는 HTTP 응답 헤더(_headers)다. 이 사이트의 현재 wrangler 설정은 이렇다.
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "warpsense-blog",
"compatibility_date": "2026-07-01",
"assets": {
"directory": "./dist",
"html_handling": "drop-trailing-slash",
"not_found_handling": "404-page"
}
}
트레일링 슬래시 처리(zsh 명령어: html_handling)는 경로 정규화를, _headers는 캐시 정책을 담당한다. 둘 다 “정적 자산을 어떻게 서빙할지”라는 큰 그림에서 만난다. Astro 정적 사이트를 Workers로 올릴 때는 이 두 설정을 반드시 함께 검토해야 한다.
평가: server-first vs 성능의 트레이드오프
max-age=0, must-revalidate(server-first)를 고수한 HTML과 max-age=31536000, immutable을 적용한 정적 자산은 서로 다른 캐시 전략을 갖는다. 이 차이는 의도적이다.
| 전략 | 적용 대상 | 효과 | 위험 |
|---|---|---|---|
| server-first | HTML 페이지 | 배포 즉시 최신 내용 | 매 방문마다 엣지 검증 필요 |
| immutable | 해시드 자산 | CDN·브라우저 히트, 요청 제로 | URL이 바뀌어야 내용 변경 가능 |
HTML을 immutable로 묶지 않은 이유는 블로그의 특성 때문이다. 글을 수정·업데이트할 때 URL은 그대로인데 내용만 바뀐다. 이때 HTML이 1년간 캐시되면 독자는 낡은 글을 보게 된다. Workers Static Assets의 기본 server-first는 이 상황을 정확히 보호한다. 반면 _astro/*는 파일명이 바뀌므로 같은 URL이 다른 내용을 가리킬 일이 없다. 따라서 immutable 캐싱은 안전한 범위에만 적용한 셈이다.
한계도 있다. Cloudflare의 전체 페이지 캐시(Enterprise 플랜의 Cache Rules 등)보다는 덜 공격적이다. 이 설정은 브라우저 캐시와 CDN 캐시 중 브라우저 쪽을 주로 제어한다. Workers Static Assets이 CDN 레벨에서 이미 server-first로 동작하므로, HTML이 CDN에서 오래 캐시되는 것을 막는 메커니즘은 건드리지 않았다. 만약 글로벌 POP에서 HTML을 더 오래 캐시하고 싶다면 Cache Rules나 CDN-Cache-Control 헤더를 추가로 고려해야 한다.
이 레포의 _headers는 커밋 e8218de에 기록되어 있다. 추가한 줄은 15줄, 그중 실제 규칙은 세 줄이다. 단순한 변경이지만, Workers Static Assets의 기본 캐시 동작을 확인하고, content-hashing의 불변성 속성을 이용해 안전하게 성능을 개선한 사례다. HTML과 정적 자산의 캐시 전략을 분리하는 기준으로 삼을 만하다.
출처
- Cloudflare Workers Static Assets — Custom _headers — _headers 문법과 경로 패턴 (열람: 2026-08-12)
- HTTP Cache-Control: immutable · RFC 8246 — immutable extension과 브라우저 동작 (열람: 2026-08-12)
- 내부 실측: 레포
public/_headers(커밋e8218de), curl timing before/after (time_starttransfer,time_total5회 중앙값), Lighthouse LCP 수치 (모바일, simulated 4G, 변경 전 1.8–2.1s → 변경 후 1.4–1.6s) - Notion 시드: «Workers Static Assets 캐시 헤더 정리» — Cloudflare Workers 기본 Cache-Control 동작과 _headers 오버라이드 범위