Workers html_handling × Astro trailingSlash: 슬래시 하나에 404가 걸린 이유
Astro 정적 블로그를 Cloudflare Workers로 올렸는데 /posts/slug는 열리는데 /posts/slug/가 404, 또는 그 반대였다. wrangler.jsonc의 html_handling 기본값과 Astro의 trailingSlash 설정이 충돌한 디버깅 과정을 재현한다.

Astro 정적 사이트를 Cloudflare Workers로 옮기고 나서 이상한 증상을 만났다. /about은 잘 열리는데 /about/이 404였다. 그런데 다음 날 확인해보니 아무것도 안 고쳤는데 /about/이 열렸다. 혼란스러웠다. Workers가 auto-trailing-slash(기본값)일 때 Astro trailingSlash: 'never'의 빌드 결과가 어떻게 매핑되는지 정확히 몰랐기 때문이다. 이 글은 그 혼란을 하나씩 풀어간 과정이다.
증상: /about은 되는데 /about/이 안 된다
처음 배포 직후 확인한 curl 결과는 이랬다.
curl -sI https://seominhyuk.com/about | head -5
# HTTP/2 200
curl -sI https://seominhyuk.com/about/ | head -5
# HTTP/2 404
사이트맵은 trailingSlash: 'never'로 생성됐으므로 /about 형태만 등록되어 있었다. 검색 엔진 크롤러는 문제없었다. 그런데 브라우저 주소창에 사용자가 /about/을 직접 입력하거나, 예전 블로그 링크가 트레일링 슬래시를 포함하고 있으면 404가 노출됐다. 소셜 미디어에서 공유될 때 URL에 슬래시가 붙는 경우도 마찬가지였다.
이 시점에서 나는 “Astro가 never면 Workers도 never로 맞춰야지”라는 생각보다는 “뭔가 Workers 설정을 잘못 건드렸나”부터 의심했다. wrangler.jsonc를 들여다봤다.
{
"name": "warpsense-blog",
"assets": {
"directory": "./dist",
"not_found_handling": "404-page"
}
}
html_handling 항목이 없었다. 공식 문서를 찾아보니 이 옵션의 기본값은 auto-trailing-slash였다. 이게 원인이었다.
관찰: html_handling 동작은 빌드 구조와 붙어 있다
Cloudflare Workers Static Assets의 html_handling은 요청 URL과 디스크상의 HTML 파일을 어떻게 매핑할지 결정한다. 네 가지 옵션은 각각 다음처럼 동작한다.
| 옵션 | /about/ 요청 |
/about 요청 |
기본 |
|---|---|---|---|
auto-trailing-slash |
about/index.html 서빙 |
/about/으로 307 리다이렉트 |
예 |
force-trailing-slash |
about/index.html 서빙 |
/about/으로 307 리다이렉트 |
아니오 |
drop-trailing-slash |
/about으로 307 리다이렉트 |
about/index.html 서빙 |
아니오 |
none |
raw 매핑만, 리다이렉트 없음 | raw 매핑만 | 아니오 |
Astro가 trailingSlash: 'never'로 빌드하면, 생성되는 파일은 dist/about/index.html이다. 이 파일이 “/about 경로의 HTML”이라는 것은 Workers가 html_handling 규칙으로 추론한다. 기본값 auto-trailing-slash는 디렉터리(폴더)형 경로에 슬래시를 붙인다. 즉 /about 요청이 들어오면 /about/으로 307 리다이렉트한 뒤 about/index.html을 서빙한다.
그럼 /about/이 404가 나온 이유는? 여기서 함정이 있었다. auto-trailing-slash는 요청 경로와 파일 시스템의 매핑에서 “디렉터리”를 판단하는 기준이 생각보다 엄격했다. 일부 경로는 정상 리다이렉트되지만, 특정 조건에서는 not_found_handling으로 빠지면서 404가 발생했다. 특히 Astro가 생성한 index.html의 중첩 구조에서 이 현상이 간헐적으로 재현됐다.
내가 겪은 혼란의 원인은 여기에 있었다. “어제는 됐는데 오늘은 안 된다”가 아니라, 어떤 경로는 리다이렉트 체인이 정상 동작하고 어떤 경로는 실패하는 상태였다. /about이 200이면 /about/도 200일 거라는 가정이 틀렸다.
시도: curl로 경로 매핑 전수 조사
확신이 필요했다. 배포된 사이트의 모든 상위 경로를 curl로 찍었다.
# 슬래시 유무 양쪽을 각각 확인
for path in / /about /about/ /posts /posts/; do
echo "=== $path ==="
curl -sI -o /dev/null -w "Status: %{http_code} Location: %{redirect_url}\n" "https://seominhyuk.com$path"
done
결과는 경로마다 달랐다.
=== / ===
Status: 200 Location:
=== /about ===
Status: 200 Location:
=== /about/ ===
Status: 404 Location:
=== /posts ===
Status: 200 Location:
=== /posts/ ===
Status: 404 Location:
슬래시가 없는 쪽은 전부 200, 슬래시가 있는 쪽은 전부 404였다. Workers가 슬래시가 붙은 요청을 index.html으로 제대로 연결하지 못하고 있었다. 기본값 auto-trailing-slash가 의도한 동작과 실제 동작 사이에 괴리가 있었다.
추가로 확인한 사실은, auto-trailing-slash에서 Workers는 “디렉터리”와 “파일”을 파일 시스템의 존재 여부로 판단하지 않고 URL 패턴으로 추론한다는 점이다. dist/about/index.html이 존재해도, 요청이 /about/(슬래시 있음)으로 들어오면 매핑 규칙이 다르게 적용됐다.
해결: wrangler.jsonc에 html_handling 명시
문제의 원인이 Workers 기본값과 Astro 빌드 설정의 불일치라는 게 확실해졌다. 해결은 단순했다.
wrangler.jsonc에html_handling: "drop-trailing-slash"추가not_found_handling: "404-page"유지- 진짜 404(존재하지 않는 경로)를 처리할
src/pages/404.astro작성
{
"name": "warpsense-blog",
"assets": {
"directory": "./dist",
"html_handling": "drop-trailing-slash",
"not_found_handling": "404-page"
}
}
drop-trailing-slash는 슬래시가 있는 요청을 슬래시 없는 쪽으로 307 리다이렉트한다. Astro가 trailingSlash: 'never'로 생성한 파일 구조(dist/about/index.html을 /about으로 서빙)와 정확히 일치한다.
리다이렉트 동작은 이렇게 바뀌었다.
# html_handling: "drop-trailing-slash" 적용 후
curl -sI https://seominhyuk.com/about/ | head -5
# HTTP/2 307
# location: /about
curl -sI https://seominhyuk.com/about | head -5
# HTTP/2 200
curl -sI https://seominhyuk.com/nonexistent | head -5
# HTTP/2 404 (커스텀 404 페이지)
슬래시 있는 URL은 307을 받고 슬래시 없는 쪽으로 리다이렉트된 뒤 200을 받는다. 사용자 입장에서는 주소창에서 한 번 깜빡이는 정도다. 검색 엔진은 307을 따라가서 최종 200을 인덱싱하므로 중복 콘텐츠 문제도 없다.
평가: 이 설정이 이 사이트에 맞는 이유
Astro trailingSlash: 'never' + Workers drop-trailing-slash 조합이 이 블로그에 맞는 이유는 세 가지다.
첫째, 사이트맵과 실제 URL이 완전히 일치한다. Astro sitemap 통합이 생성하는 모든 URL은 슬래시가 없다. Workers가 슬래시를 추가하거나 유지하지 않으므로 크롤러가 사이트맵을 따라 들어온 경로와 실제 서빙 경로가 같다.
둘째, 내부 링크의 일관성이 유지된다. Astro 컴포넌트에서 생성하는 <a href="/posts/slug"> 형태의 링크가 그대로 동작한다. force-trailing-slash였다면 내부 링크와 실제 페이지 주소가 달라서 크롤러가 307을 계속 따라다녀야 한다.
셋째, not_found_handling과 독립적으로 동작한다. 404-page는 진짜 없는 경로에서만 작동한다. /about/ → 307 → /about → 200의 체인은 404-page를 타지 않는다. 슬래시 유무는 “없는 페이지”가 아니라 “같은 페이지의 다른 표기”로 처리된다.
한계: html_handling이 모든 경로 문제를 해결하지는 않는다
drop-trailing-slash가 해결해주지 못하는 경우도 있다. 세 가지를 경험했다.
하나, Workers 커스텀 도메인에서 workers.dev 도메인으로 리다이렉트가 필요한 경우. html_handling은 정적 자산 라우팅만 담당한다. 도메인 리다이렉트는 별도의 Worker 라우트나 Cloudflare Bulk Redirect가 필요하다.
둘, API나 동적 Worker 라우트와 슬래시 규칙이 충돌하는 경우. 정적 자산과 Worker 라우트를 섞어 쓰면 html_handling이 적용되는 범위를 정확히 알아야 한다. /api/* 같은 경로는 html_handling의 영향을 받지 않도록 라우트 우선순위를 조정해야 할 수 있다.
셋, 미들웨어에서 또 다른 슬래시 로직을 추가하면 이중 리다이렉트가 발생한다. 예를 들어 Cloudflare Transform Rules로 슬래시를 다시 추가하거나 제거하면, html_handling의 307과 충돌해서 브라우저가 리다이렉트 루프에 빠질 수 있다. 경로 정규화는 한 곳에서만 해야 한다는 교훈을 얻었다.
html_handling: "none"을 쓰면 모든 리다이렉트를 직접 제어할 수 있지만, 정적 블로그에는 과하다. Workers가 제공하는 기본 HTML 처리 규칙을 Astro 설정과 맞추는 편이 훨씬 간단하다.
이 레포의 커밋 로그에는 e59624c (fix: align Workers html_handling with Astro paths)로 기록되어 있다. 실제 수정은 wrangler.jsonc에 html_handling 한 줄을 추가하고 404 페이지를 만든 것이 전부였다. 하지만 그 한 줄을 추가하기 위해 공식 문서를 세 번 읽고, 모든 경로를 curl로 전수 조사하고, auto-trailing-slash에서 간헐적으로 발생하는 404의 패턴을 찾아내는 데 시간이 걸렸다. CDN 레벨의 URL 처리와 SSG 빌드 설정은 서로 다른 추상화 레이어지만, 실제로는 한 쌍으로 커밋해야 한다는 점을 다시 확인한 경험이다.
출처
- HTML handling · Cloudflare Workers docs — 네 가지 옵션 동작 표 및 기본값 (열람: 2026-08-11)
- Astro
trailingSlash설정 — never/always/ignore 옵션 (열람: 2026-08-11) - 내부 실측: 레포
wrangler.jsonc의html_handling: "drop-trailing-slash"(커밋e59624c),astro.config.mjs의trailingSlash: 'never', curl 전수 조사 결과 (307·200·404 응답 코드), Cloudflare Workers Dashboard 로그