배포는 성공했는데, 없는 주소가 200을 돌려주고 있었습니다
글 세 편을 미리 써두고 날짜를 벌려서 하나씩 열리게 해뒀습니다. 고친 내용을 저장소에 올리는 push는 오늘 했지만 공개일은 내일부터라 지금은 아직 안 보이는 게 정상입니다. 앞날 날짜가 붙은 글은 빌드에서 빠지기 때문입니다.

그게 맞는지 확인하려고 주소를 하나 찍어봤습니다.
/posts/hugo-over-astro/ → 200
200이 나왔습니다. 200은 요청이 제대로 처리됐다는 뜻의 응답 번호입니다. 안 열려야 하는데 열립니다.
처음 의심한 것, 전부 아니었습니다

미래 날짜 처리가 안 먹은 줄 알고 제 컴퓨터(로컬)에서 다시 확인했습니다.
$ hugo → public/posts/ 에 그 글 없음
$ hugo --buildFuture → public/posts/ 에 그 글 있음
빌드 결과물(글 파일을 웹페이지로 만들어 낸 것)에 애초에 그 파일이 없으니 로컬은 정상입니다. 그런데 실제로 서비스되는 사이트, 즉 라이브에서는 200이 나옵니다. 없는 파일이 어떻게 200이 되는지 이해가 안 됐습니다.
sitemap(사이트에 어떤 주소들이 있는지 검색엔진에 알려주는 파일)을 보니 새 글 세 편이 없습니다. 아직 공개일이 안 된 글은 빌드에 안 들어가니 sitemap에도 없는 게 맞습니다. 그러니 배포된 결과물은 제대로였습니다.
여기서 없는 파일이 200을 돌려준다는 게 이상하다는 걸 뒤늦게 알아채고 확인 방향을 바꿨습니다.
대조군을 넣어보니 답이 나왔습니다

제가 확인하던 것은 “이 주소가 200인가"였습니다. 그 질문에는 비교할 대상이 없었습니다. 그래서 존재할 리 없는 주소를 하나 만들어 같이 찍었습니다.
/posts/hugo-over-astro/ → 200
/posts/이런글은없다-zzz/ → 200 ← 대조군
대조군도 200입니다. 게다가 둘 다 홈 내용을 돌려줬습니다.
$ curl -s https://birchholt.com/posts/이런글은없다-zzz/ | grep -oE '<title>[^<]*'
<title>birchholt — 직접 해보고 남기는 기록
새 글 문제가 아니었습니다. 사이트 전체가 그렇게 동작하고 있었습니다. 아무 주소나 치면 홈이 200으로 나옵니다.
여기서 제가 놓쳤습니다. “200이 나온다"를 “페이지가 있다"로 읽었는데 200은 페이지가 없어도 나옵니다. 대조군 없이 한 URL만 찍는 건 확인이 아니라 짐작입니다. 없는 주소를 같이 찍었으면 처음 5초에 알았을 겁니다.
원인 — 파일 하나가 없었습니다

빌드 결과물을 봤습니다.
$ ls public/
ads.txt css index.html index.xml posts privacy robots.txt sitemap.xml
404.html이 없습니다.
Cloudflare Pages는 요청한 경로에 파일이 없으면 404.html을 찾습니다.
그것도 없으면 index.html을 200으로 내줍니다. 주소를 옮겨 다녀도 파일 하나로 화면을 그리는
사이트를 위한 동작인데, 주소마다 파일이 따로 있는 정적 블로그에서는 없는 주소 전부가 홈 복사본이 됩니다.
Cloudflare Pages 문서에 그대로 적혀 있습니다 — 「You can define a custom page to be displayed
when Pages cannot find a requested file by creating a 404.html file」, 그리고 「If your project
does not include a top-level 404.html file, Pages assumes that you are deploying a single-page
application」이라 「matches all incoming paths to the root (/)」.
Cloudflare Pages · Serving Pages,
2026-09-22에 확인했습니다. 다만 그때 돌려주는 상태 번호가 200이라는 것까지는 문서에 없습니다.
그건 위 curl 결과, 즉 제가 직접 잰 값입니다.
Hugo는 layouts/404.html이 있어야 public/404.html을 만드는데 저는 그걸 안 만들어뒀습니다.
Hugo 문서의 조건 문장은 「To render a 404 error page in the root of your site, create a 404
template in the root of the layouts directory」입니다 —
Hugo · Custom 404 page.
빈 Hugo 사이트를 하나 만들어 2026-09-22에 재현해 보니, 템플릿이 없을 때는 빌드 결과물에 404.html이
아예 안 생기고 파일을 하나 두자 그때부터 생겼습니다(hugo v0.165.0).
로컬에서 안 보인 이유도 여기 있습니다. hugo server는 없는 주소에 자체 404 화면,
그러니까 그런 주소가 없다는 안내 화면을 띄우니
로컬에서는 멀쩡해 보이고 라이브에서만 다르게 동작합니다.
같은 빈 사이트에서 hugo server를 띄우고 없는 주소를 찍어 보니 상태 번호는 404였고
Hugo가 들고 있는 「Page Not Found」 한 줄짜리 화면이 나왔습니다(2026-09-22, hugo v0.165.0).
라이브에서 홈이 200으로 나오던 것과 다릅니다.
고치면서 걸린 것 둘

1. Hugo가 영문 제목을 자동으로 넣습니다.
layouts/404.html을 만들고 빌드했더니 이렇게 나왔습니다.
<title>404 Page not found — birchholt</title>
본문은 한국어인데 제목만 영어입니다. Hugo가 넣는 기본값입니다.
빈 사이트에 제목만 찍는 404 템플릿을 두고 빌드해 보면 404 Page not found가 그대로 나옵니다
(2026-09-22, hugo v0.165.0). 이 제목을 어떻게 바꾸냐는 질문이 계속 나와서
Hugo 문서 저장소에도 같은 항목이 열려 있습니다.
2. content/404.md로 제목을 바꾸려다 더 나빠졌습니다.
제목을 바꾸려고 content/404.md에 title을 적었는데 제목은 안 바뀌었고
대신 sitemap에 이게 생겼습니다.
<loc>https://birchholt.com/404/</loc>
404 페이지를 색인해달라고, 그러니까 검색 결과에 넣어달라고 sitemap에 올린 꼴입니다. 고치려다 없던 문제를 만들었습니다. 파일을 지우고 원래대로 돌렸습니다.
2026-09-22에 sitemap을 다시 받아 404가 들어간 <loc>만 뽑아 보면 이 글 자신의 주소
하나뿐이고 /404/ 항목은 없습니다.
제목은 페이지마다 <head>를 찍어내는 공용 템플릿에서 처리했습니다.
지금 그리는 페이지가 404일 때만 제목을 갈아끼우는 한 줄입니다.
겸사겸사 404에는 색인 거부를 붙이고
canonical(이 주소가 원본이라고 검색엔진에 알려주는 표시)은 빼도록 했습니다 —
없는 페이지가 자기 자신을 정본이라고 가리킬 이유가 없습니다.
{{- $pageTitle := cond (eq .Kind "404") "주소를 찾지 못했습니다" .Title -}}
다만 커스텀 404 파일은 루트에 있어야 하니 이 페이지는 자기 주소로도 열립니다.
/404 를 직접 찍으면 상태 번호가 200입니다 — 이 글이 다루는 증상의 축소판입니다.
색인 거부가 붙어 있고 sitemap 에도 없어서 그대로 뒀습니다.
_redirects 로 막으면 404 처리 자체가 깨질 위험이 있어 손대지 않았습니다.
2026-09-22 실측 — /404 는 200(6,402바이트), /404/ 와 /404.html 은 308로 /404 에 모입니다.
없는 주소(/nope-xyz-123/)는 404 인데 본문 크기가 6,402바이트로 같습니다. 같은 파일을 내주고 상태 번호만
다릅니다.
확인을 자동화에 넣었습니다

같은 걸 또 놓치지 않으려고 배포 워크플로(배포할 때 자동으로 도는 작업 절차)에 두 줄을 넣었습니다.
빌드 결과에 파일이 있는지:
test -f public/404.html || { echo "🔴 404.html 이 없다" >&2; exit 1; }
그리고 라이브에서 진짜 404가 나오는지:
miss="$(curl -s -o /dev/null -w '%{http_code}' "https://birchholt.com/__deploy_check_404__/")"
[ "$miss" = "404" ] || { echo "🔴 없는 주소가 404 가 아니다 ($miss)" >&2; exit 1; }
지금 돌고 있는 워크플로에서는 두 번째 조각의 curl에 --max-time 20이 함께 붙어 있습니다.
위 조각은 읽기 편하라고 그 부분을 뺀 것이고 하는 일은 같습니다.
없는 주소를 직접 찍어 보는 확인은 2026-09-22에 다시 해도 404가 나옵니다.
진짜 확인은 두 번째 줄이 합니다. 파일이 있는지와 서버가 그 파일을 404로 내주는지는 다른 사실이고 이번에 틀린 건 두 번째 쪽이었습니다.
그때까지 배포 워크플로에 들어 있던 라이브 검증은 /와 /privacy/와 /ads.txt가 200인지만 봤고
없는 페이지가 안 열리는지는 확인하지 않았습니다.
검사 항목이 한쪽 방향만 보고 있었습니다.
정리

200은 “페이지가 있다"가 아닙니다. 서버가 무언가를 돌려줬다는 것까지만 알려줍니다- 대조군 없는 확인은 짐작입니다. 없는 주소를 같이 찍는 데 5초 걸립니다
- 정적 호스팅에 올릴 때
404.html이 결과물에 있는지 보세요. 없으면 홈이 무한 복제됩니다 hugo server의 404는 호스팅의 404와 다릅니다. 로컬에서 멀쩡한 게 근거가 안 됩니다- 검사를 만들 때 “되는가"만 넣고 “안 되어야 하는 것이 안 되는가"를 빼먹기 쉽습니다