birchholt 직접 해보고 남기는 기록

Cloudflare Pages에서 내린 글이 내 도메인에서만 계속 열렸습니다

글을 정리하다가 몇 편을 draft: true로 돌렸습니다. Hugo는 draft 글을 빌드에서 빼니 배포가 끝나면 그 주소는 404(없는 페이지)가 되는 게 맞습니다. 그런데 같은 날 그 주소를 아무것도 붙이지 않고 다시 요청해 보니 옛 본문이 그대로 나왔습니다.

불이 꺼진 빈 가게 진열창에 아직 지난 계절의 상품 하나가 놓여 있고 행인이 들여다보는 저녁 거리

배포는 성공했고 빌드 결과물에도 그 글은 없었습니다. 없는 파일이 어디서 나오는지 좁혀 간 과정과, 결국 배포 한 번으로 덮은 방법을 적습니다. Cloudflare Pages에 커스텀 도메인을 붙여 쓰는 분이라면 같은 일을 겪을 수 있습니다.

pages.dev는 404인데 내 도메인은 200이었습니다

같은 주소가 적힌 두 개의 우편함 중 하나는 비어 있고 다른 하나에는 오래된 편지가 꽂혀 있는 장면

Pages 프로젝트에는 <프로젝트명>.pages.dev라는 기본 주소가 있고 커스텀 도메인은 그 위에 붙습니다. 같은 경로를 양쪽에서 찍었습니다(2026-09-20).

https://birchholt.pages.dev/posts/<내린-글>/       → 404
https://main.birchholt.pages.dev/posts/<내린-글>/  → 404
https://birchholt.com/posts/<내린-글>/             → 200, 옛 본문 전체

원본은 깨끗했습니다. 문제는 커스텀 도메인 쪽에서만 났습니다.

그런데 저는 처음에 이걸 놓쳤습니다. 배포 직후 확인할 때 저는 주소 뒤에 ?cb=임의값을 붙여서 찍었습니다. 캐시를 피하려고 붙이는 이런 값을 캐시버스터라고 부르는데 이걸 붙이면 404가 나왔습니다.

GET /posts/<내린-글>/          → 200 (cache-control: public, s-maxage=604800, age: 27890)
GET /posts/<내린-글>/?cb=...   → 404

캐시버스터는 오리진(원본 서버) 상태만 보여줍니다. 방문자와 검색 크롤러는 붙이지 않은 주소로 옵니다. 저는 원본만 보고 “404 확인"이라고 적어뒀던 셈입니다.

Purge도, Development Mode도 듣지 않았습니다

빗자루로 가게 앞을 다 쓸었는데 진열창 안쪽의 물건은 그대로 남아 있는 상점 주인

캐시 문제라고 보고 하나씩 지워 갔습니다.

도메인에 걸린 캐시 설정 쪽은 전부 아니었습니다. 남은 후보는 Pages 쪽이었습니다.

남은 응답에만 붙어 있던 헤더

정리된 책장 사이에 혼자 다른 색 띠지가 붙은 책 한 권을 집어 드는 사서

정상 글과 남아 있는 글의 응답 헤더를 나란히 놓으니 차이가 보였습니다.

정상 글   cache-control: public, max-age=0, must-revalidate
남은 글   cache-control: public, s-maxage=604800
          x-robots-tag: noindex
          age: 27890

s-maxage=604800은 공유 캐시가 7일 동안 들고 있어도 된다는 뜻입니다(604,800초 = 7일). 정상 글에는 이 값도, age도 없습니다. 게다가 같은 시각 그 글에 들어 있던 이미지는 404였습니다. 남은 건 HTML 한 종류뿐이었습니다.

Cloudflare 문서에 맞아떨어지는 문장이 있습니다.

Assets have a time-to-live (TTL) of one week but can also disappear at any time. If you do a new deploy, the assets could exist in that data center up to one week. — Cloudflare Pages · Serving Pages

같은 문서에서 오래된 파일이 보이면 Purge Everything을 하라고 안내하는데, 제 경우에는 듣지 않았습니다. 그래서 이렇게 봤습니다. 배포는 새 배포에 있는 경로만 갱신하고, 사라진 경로의 사본은 TTL까지 남는다. 문서가 이 동작을 그대로 적어 두지는 않았으니 관찰로 세운 가설입니다.

두 가지는 끝내 설명하지 못했습니다. 남은 응답에 붙은 x-robots-tag: noindex는 문서에서 프리뷰 주소에만 붙는다고 하는 헤더입니다. age 값은 데이터센터마다 32172, 32742, 34286, 35928로 제각각이었고 캐시본에는 age가 가리키는 시각보다 나중에 배포한 마크업이 들어 있었습니다. age로 언제 캐시됐는지 추정하지 않는 게 좋습니다.

그 주소를 다시 존재하게 만들었습니다

빈 집 문 앞에 새 주소로 가는 길을 가리키는 작은 나무 팻말을 박아 두는 사람

가설이 맞다면 사라진 경로를 새 배포 안에 다시 넣으면 그 배포가 덮어씁니다. 글을 되살릴 수는 없으니 홈으로 보내는 페이지를 그 자리에 두기로 했습니다.

Hugo의 aliases가 정확히 그 일을 합니다. 지정한 경로마다 목적지로 즉시 이동하는 작은 HTML을 만들어 줍니다. 홈으로 보낼 거라 content/_index.md에 적었습니다.

---
title: "..."
aliases:
  - /posts/<내린-글>/
---

배포 직후 확인은 이번엔 캐시버스터 없이 했습니다. 폴링 2회차에 교체됐고, 일반 GET 10회 중 옛 본문은 0번이었습니다(응답한 데이터센터는 HKG·NRT가 섞여 있었습니다). 헤더도 public, max-age=0, must-revalidate로 돌아왔고 s-maxage와 age는 사라졌습니다.

Purge로 못 지운 것을 배포 한 번이 지웠습니다.

alias 템플릿에서 걸린 것 둘

문에 붙인 안내문을 다른 문들에도 똑같이 붙여 버린 것을 뒤늦게 발견하는 관리인

1. Hugo 기본 alias에는 색인 거부가 없습니다.

빈 사이트를 만들어 기본 템플릿 결과를 찍어 봤습니다(hugo v0.165.0).

<!DOCTYPE html>
<html lang="en">
	<head>
		<title>https://example.com/</title>
		<link rel="canonical" href="https://example.com/">
		<meta charset="utf-8">
		<meta http-equiv="refresh" content="0; url=https://example.com/">
	</head>
</html>

canonical은 있지만 robots 메타가 없습니다. 검색엔진에 다시 잡히지 않게 하려고 layouts/alias.html로 덮어썼습니다.

<!DOCTYPE html>
<html lang="{{ site.Language.Lang }}">
<head>
  <meta charset="utf-8">
  <title>{{ .Permalink }}</title>
  <link rel="canonical" href="{{ .Permalink }}">
  <meta name="robots" content="noindex, follow">
  <meta http-equiv="refresh" content="0; url={{ .Permalink }}">
</head>
<body>
  <p>이 주소는 더 이상 쓰지 않습니다. <a href="{{ .Permalink }}">{{ .Permalink }}</a> 으로 이동합니다.</p>
</body>
</html>

2. 이 템플릿은 내가 적은 alias에만 쓰이지 않습니다.

처음엔 본문에 “이 주소의 글은 내렸습니다"라고 적었습니다. 그런데 Hugo는 목록의 1페이지(/posts/page/1/)도 /posts/로 보내는 alias로 만들고, 같은 템플릿을 씁니다. 같은 빈 사이트에서도 public/posts/page/1/index.html이 똑같이 생겼습니다. 그래서 두 경우 모두 맞는 문장으로 바꿨습니다.

하나 더 확인했습니다. 내렸던 글을 나중에 draft: false로 되살리면 어떻게 될까요. 빈 사이트에서 해 보니 경고 없이 실제 글이 그 경로를 차지했습니다(WARN 0줄, 종료 코드 0). 충돌로 빌드가 깨지지는 않지만 되살릴 땐 aliases에서도 지워 두는 편이 헷갈리지 않습니다.

지금 다시 재보면

아침 햇살 속에 팻말만 서 있는 빈 집 앞을 지나며 고개를 끄덕이는 우체부

이 글을 쓰면서 alias를 둔 주소들을 캐시버스터 없이 다시 찍었습니다(2026-10-04).

$ curl -sS -D - -o body.html https://birchholt.com/posts/<내린-글>/
HTTP/2 200
cache-control: public, max-age=0, must-revalidate
cf-cache-status: DYNAMIC

$ grep -o '<meta name=robots[^>]*>' body.html
<meta name=robots content="noindex, follow">

커스텀 도메인과 pages.dev 양쪽 모두 같은 alias 본문이었고 s-maxage·age·x-robots-tag는 어디에도 없었습니다. sitemap에서 그 경로들을 찾아도 0건입니다. 비교용으로 찍은 없는 주소는 404에 cache-control: no-store였습니다.

alias는 200입니다. “없는 페이지"가 아니라 “옮겨 간 페이지"로 보이게 됩니다. 그래도 noindex와 즉시 이동이 붙어 있으니, 옛 본문이 7일 동안 200으로 나가는 것보다는 낫다고 판단했습니다.

다음에 글을 내릴 때의 순서

지도 위 옛 길에 연필로 새 길을 이어 그리는 손과 옆에 놓인 나침반
  1. draft: true로 돌리는 같은 커밋에 그 경로를 aliases에 넣습니다. 비워 두면 커스텀 도메인에서 옛 사본이 최대 7일 남을 수 있습니다
  2. layouts/alias.html에 **noindex**가 있는지 봅니다. 기본 템플릿에는 없습니다
  3. 배포 뒤에는 캐시버스터 없이 커스텀 도메인 주소를 찍습니다. ?cb=는 원본만 보여줍니다
  4. 헤더에 s-maxage나 age가 보이면 아직 남은 사본입니다
  5. 글을 되살릴 땐 aliases에서 그 줄을 지웁니다

Purge부터 누르고 싶어지는데 제 경우엔 경로를 새 배포에 다시 넣는 쪽이 들었습니다.