birchholt 직접 해보고 남기는 기록

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

정리하면 이렇습니다.

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건으로 유지하기로 했습니다.

다시 확인한다면 이 순서로

현관 열쇠고리에 작은 도구 세 개를 순서대로 꿰어 거는 손
  1. 라이브에서 curl -sI https://내도메인/posts/글 과 끝에 /를 붙인 주소를 둘 다 찍어 응답 번호를 비교합니다
  2. hugo --minify로 빌드한 public/에 위 스크립트를 돌립니다. 원고가 아니라 결과물이어야 템플릿 링크까지 봅니다
  3. 0건이 나오면 틀린 링크를 일부러 하나 넣고 다시 돌려 잡히는지 봅니다. 그다음 지웁니다
  4. 원고의 내부 링크는 손으로 적지 말고 relref로 바꿉니다
  5. 종료 코드가 1이면 실패하게 해뒀으니 배포 절차에 한 줄로 끼워 넣을 수 있습니다

hugo server도, 브라우저도, 라이브 사이트도 이 링크들을 조용히 받아줍니다. 받아준다는 것과 바로 열린다는 것은 다른 일이라 확인은 응답 번호로 합니다.