Cloudflare Pages에서 끝 슬래시 없는 링크가 308을 한 번 더 타고 있었는지 확인했습니다
글을 정리하다가 내부 링크 주소를 끝 슬래시 없이 적은 모양으로 터미널에서 찍어봤습니다.
브라우저로 열면 멀쩡히 글이 뜨는 주소인데 curl은 다른 걸 보여줬습니다.

$ curl -sI https://birchholt.com/posts/soft-404-on-pages
HTTP/2 308
location: /posts/soft-404-on-pages/
주소 끝에 슬래시(/) 하나가 빠졌을 뿐인데 서버가 한 번 돌려보냈습니다.
308은 “영구적으로 다른 주소로 옮겼으니 그쪽으로 다시 요청하라"는 응답입니다.
브라우저는 이걸 알아서 따라가니 화면에서는 아무 일도 없어 보입니다.
이 글은 그 한 번의 우회, 즉 리다이렉트 홉(hop, 목적지까지 거치는 한 단계)이 사이트 안 어디에 숨어 있는지 Hugo 빌드 결과물에서 찾는 방법입니다.
슬래시 하나에 따라 응답이 갈립니다

같은 글을 주소 모양만 바꿔 라이브에서 찍었습니다. 2026-10-04 측정입니다.
/posts/soft-404-on-pages/ 200
/posts/soft-404-on-pages 308 → /posts/soft-404-on-pages/
/posts/soft-404-on-pages/index.html 308 → /posts/soft-404-on-pages/
/posts/soft-404-on-pages/index 308 → /posts/soft-404-on-pages/
/posts/soft-404-on-pages.html 404
/posts 308 → /posts/
/index.html 308 → /
/posts/soft-404-on-pages?x=1 308 → /posts/soft-404-on-pages/?x=1
정리하면 이렇습니다.
- 끝 슬래시가 없으면 308로 슬래시 붙은 주소에 보냅니다
index.html을 붙여도 308입니다. 파일은 실제로 그 이름으로 있는데도 그렇습니다글이름.html은 404입니다. Hugo가 글을글이름/index.html폴더 구조로 만들기 때문에 그런 파일 자체가 없습니다- 쿼리 문자열(
?x=1처럼 주소 뒤에 붙는 값)은 리다이렉트 뒤에도 그대로 붙어 갑니다
Location 헤더(어디로 가라는 주소)가 https://...로 시작하지 않고 /posts/.../인 상대경로라는 것도 보였습니다.
http://로 들어오면 한 단계가 더 붙습니다.
http://birchholt.com/posts/soft-404-on-pages
→ 301 https://birchholt.com/posts/soft-404-on-pages
→ 308 https://birchholt.com/posts/soft-404-on-pages/
→ 200 (redirects=2)
공식 문서에는 절반만 적혀 있습니다

Cloudflare Pages 문서의 경로 처리(route matching) 부분은 이렇게 적혀 있습니다.
「Pages will also redirect HTML pages to their extension-less counterparts: for instance,
/contact.html will be redirected to /contact, and /about/index.html will be redirected to /about/.」 —
Cloudflare Pages · Serving Pages,
2026-10-04에 확인했습니다.
index.html을 떼어내는 쪽은 문서와 측정이 맞습니다.
그런데 /posts/x에 슬래시를 붙여주는 동작과 그때의 상태 번호 308은 이 문서에 없습니다.
308은 위 curl 결과, 그러니까 제가 직접 잰 값입니다.
비슷한 표가 Cloudflare Workers의 정적 파일 문서에는 있습니다.
기본값(auto-trailing-slash)에서 /folder는 /folder/로 보낸다고 적혀 있는데 거기 적힌 번호는 307입니다.
Workers · Static Assets · HTML handling, 2026-10-04 확인. Pages와는 다른 제품의 문서입니다. 제 Pages 사이트에서 실제로 나온 번호는 308이라 이 표를 Pages에 그대로 옮겨 읽으면 안 됩니다.
같은 문서에 이런 문장도 있습니다 — 「search engines often treat URLs with and without trailing slashes as different, separate pages」. 슬래시 유무가 겉보기 문제만은 아니라는 뜻입니다.
로컬에서는 이 차이가 안 보입니다

hugo server로 같은 주소를 찍어봤습니다(hugo v0.165.0).
/posts/deploy-automation/ 200
/posts/deploy-automation 301 → http://localhost:1319/posts/deploy-automation/
/posts/deploy-automation/index.html 301 → http://localhost:1319/posts/deploy-automation/
/posts/deploy-automation.html 404
로컬도 리다이렉트로 받아줍니다. 번호만 301로 다를 뿐 결과는 같은 글입니다. 그래서 미리보기에서 링크를 눌러봐도, 배포 후 라이브에서 눌러봐도 틀린 링크가 틀렸다는 신호가 어디에도 안 나옵니다. 눈으로 확인하는 방법으로는 못 잡는다는 뜻이라 검사를 따로 만들었습니다.
빌드 결과물에서 슬래시 없는 내부 링크 찾기

원고(content/)가 아니라 빌드 결과물(public/)을 봐야 합니다.
메뉴·푸터·이전 글 링크처럼 템플릿이 만드는 링크는 원고에 없기 때문입니다.
처음에는 grep으로 하려다 그만뒀습니다.
hugo --minify로 빌드하면 속성의 따옴표가 지워져 href=/posts/deploy-automation/처럼 나오는데
따옴표 유무를 다 맞추는 정규식보다 HTML 파서(문서 구조를 읽어주는 도구)가 낫습니다.
파이썬 기본 라이브러리만 씁니다.
#!/usr/bin/env python3
# 사용: python3 check_slash.py public https://내도메인
import sys, pathlib
from html.parser import HTMLParser
from urllib.parse import urlsplit
root = pathlib.Path(sys.argv[1])
host = urlsplit(sys.argv[2]).netloc
class Hrefs(HTMLParser):
def __init__(self):
super().__init__(); self.found = []
def handle_starttag(self, tag, attrs):
for k, v in attrs:
if k == "href" and v:
self.found.append((tag, v, self.getpos()[0]))
def verdict(path):
last = path.rsplit("/", 1)[-1]
if path.endswith("/index.html"):
return "index.html 로 끝남 → 308"
if last.endswith(".html"):
return ".html 로 끝남 → 308 또는 404"
if path and not path.endswith("/") and "." not in last:
return "끝 슬래시 없음 → 308"
return None
bad = total = 0
for f in sorted(root.rglob("*.html")):
p = Hrefs(); p.feed(f.read_text(encoding="utf-8"))
for tag, href, line in p.found:
u = urlsplit(href)
if u.scheme in ("mailto", "tel", "javascript", "data"):
continue
if u.netloc and u.netloc != host: # 외부 링크
continue
if not u.netloc and not href.startswith("/"):
continue # #앵커, 상대경로
total += 1
why = verdict(u.path)
if why:
bad += 1
print(f"{f.relative_to(root)}:{line} <{tag}> {href} ← {why}")
print(f"내부 링크 {total}개 검사, 리다이렉트 유발 {bad}개", file=sys.stderr)
sys.exit(1 if bad else 0)
판정 기준은 위에서 잰 그대로입니다.
마지막 조각에 점(.)이 없는데 슬래시로 안 끝나면 폴더 주소로 보고 308로 칩니다.
/robots.txt나 CSS 파일처럼 확장자가 있는 주소는 건너뜁니다.
<a>만이 아니라 <link rel="canonical"> 같은 href도 같이 봅니다.
저장소 사본을 빌드하고 돌린 결과입니다.
$ hugo --gc --minify
$ python3 check_slash.py public https://birchholt.com
내부 링크 182개 검사, 리다이렉트 유발 0개
$ echo $?
0
0건이었습니다. 라이브 sitemap에 실린 15개 주소의 HTML을 받아 같은 검사를 돌려도
내부 링크 136개 검사, 리다이렉트 유발 0개였고 sitemap의 <loc> 15개도 전부 슬래시로 끝나며 200이었습니다.
일부러 틀린 링크를 넣어서 검사를 검사했습니다

0건이라는 결과는 검사가 아무것도 못 잡는 경우에도 똑같이 나옵니다. 그래서 사본에 테스트 글을 하나 넣었습니다. 틀린 모양 여섯 개와 맞는 모양 다섯 개입니다.
- [슬래시 없음](/posts/deploy-automation)
- [슬래시 있음](/posts/deploy-automation/)
- [index.html](/posts/deploy-automation/index.html)
- [.html](/posts/deploy-automation.html)
- [절대주소 슬래시 없음](https://birchholt.com/privacy)
- [쿼리 붙음](/posts?x=1)
- [앵커 붙음](/posts/pagination-pitfalls#top)
- [앵커 슬래시 있음](/posts/pagination-pitfalls/#top)
- [파일](/robots.txt)
- [relref]({{< relref "deploy-automation.md" >}})
- [외부](https://gohugo.io/content-management/urls)
내부 링크 212개 검사, 리다이렉트 유발 6개
posts/zz-slash-test/index.html:5 <a> /posts/deploy-automation ← 끝 슬래시 없음 → 308
posts/zz-slash-test/index.html:5 <a> /posts/deploy-automation/index.html ← index.html 로 끝남 → 308
posts/zz-slash-test/index.html:5 <a> /posts/deploy-automation.html ← .html 로 끝남 → 308 또는 404
posts/zz-slash-test/index.html:5 <a> https://birchholt.com/privacy ← 끝 슬래시 없음 → 308
posts/zz-slash-test/index.html:5 <a> /posts?x=1 ← 끝 슬래시 없음 → 308
posts/zz-slash-test/index.html:5 <a> /posts/pagination-pitfalls#top ← 끝 슬래시 없음 → 308
틀린 여섯 개를 전부 잡았고 맞는 다섯 개는 하나도 안 잡았습니다.
줄 번호가 전부 5인 건 --minify가 HTML을 몇 줄로 합쳐서입니다. 어느 파일인지까지만 믿으면 됩니다.
앵커 붙은 경우(#top)는 라이브에서도 확인했습니다. # 뒤는 서버로 가지 않으니
/posts/pagination-pitfalls로 요청이 가고 그대로 308을 받습니다.
고치는 쪽은 relref가 제일 편했습니다.
원고에 주소를 손으로 적지 않고 {{< relref "deploy-automation.md" >}}처럼 파일을 가리키면
Hugo가 href=/posts/deploy-automation/로, 슬래시까지 붙여서 만들어줍니다.
가리킨 파일이 없으면 빌드가 멈추니 끊긴 링크도 같이 막힙니다.
ERROR [ko] REF_NOT_FOUND: Ref "no-such-post.md": "…/content/posts/zz-ref.md:6:5": page not found
ERROR error building site: logged 1 error(s)
없는 파일을 가리키는 relref를 넣고 빌드해 본 결과입니다(종료 코드 1, hugo v0.165.0).
relref 설명은 Hugo · relref shortcode에 있습니다.
슬래시가 붙어 나온 것은 위 테스트 빌드에서 본 값입니다.
검색엔진은 이 리다이렉트를 어떻게 보나

이 부분은 제가 측정한 게 아니라 문서에 적힌 것만 옮깁니다.
Google 문서는 308을 301과 같은 영구 리다이렉트로 분류하고 이때 색인 처리가 「the redirect target should be canonical」, 즉 도착한 주소를 대표 주소로 삼는 신호로 쓴다고 적습니다. Google Search Central · Redirects and Google Search, 2026-10-04 확인.
Search Console의 페이지 색인 보고서에는 「Page with redirect」 항목이 있고 설명은 「This is a non-canonical URL that redirects to another page. As such, this URL will not be indexed.」입니다. Search Console 도움말 · Page indexing report, 2026-10-04 확인.
같은 항목에는 「The target URL of the redirect might or might not be indexed」라는 문장이 이어집니다. 두 문서를 합쳐 읽으면 색인에서 빠지는 것은 리다이렉트하는 쪽 주소이고 도착 주소는 대표 주소 신호를 받되 색인 여부는 따로 판단됩니다. 그래도 사이트가 제 손으로 돌아가는 주소를 가리킬 이유는 없어서 저는 내부 링크의 리다이렉트를 0건으로 유지하기로 했습니다.
다시 확인한다면 이 순서로

- 라이브에서
curl -sI https://내도메인/posts/글과 끝에/를 붙인 주소를 둘 다 찍어 응답 번호를 비교합니다 hugo --minify로 빌드한public/에 위 스크립트를 돌립니다. 원고가 아니라 결과물이어야 템플릿 링크까지 봅니다- 0건이 나오면 틀린 링크를 일부러 하나 넣고 다시 돌려 잡히는지 봅니다. 그다음 지웁니다
- 원고의 내부 링크는 손으로 적지 말고
relref로 바꿉니다 - 종료 코드가 1이면 실패하게 해뒀으니 배포 절차에 한 줄로 끼워 넣을 수 있습니다
hugo server도, 브라우저도, 라이브 사이트도 이 링크들을 조용히 받아줍니다.
받아준다는 것과 바로 열린다는 것은 다른 일이라 확인은 응답 번호로 합니다.