birchholt 직접 해보고 남기는 기록

Hugo 글을 draft로 내렸는데, 그 글로 가는 링크는 빌드가 아무 말도 안 했습니다

글을 정리하다가 몇 편을 draft: true로 돌려 내렸습니다. draft는 초안 표시라서 이게 붙은 글은 빌드 결과물에서 빠집니다. 문제는 남아 있는 글들이 그 글을 링크로 가리키고 있었다는 점입니다.

책장에서 책 몇 권을 빼낸 사서와 다른 책들 사이에 그대로 꽂혀 빈자리를 가리키는 쪽지들

그때는 손으로 찾아서 걷어냈습니다. 끝내고 나니 걸리는 게 있었습니다. 하나라도 놓쳤다면 빌드가 알려줬을까? 같은 상황을 다시 만들어서 확인해봤습니다. 아래 결과는 전부 Hugo v0.165.0+extended로 2026-10-04에 잰 값입니다.

일반 링크는 대상이 사라져도 빌드가 조용합니다

주인이 떠난 빈집을 향해 그대로 서 있는 길가 이정표와 아무 일 없다는 듯 지나가는 우편 수레

빈 Hugo 사이트를 하나 만들고 글 A가 글 B를 세 가지 방식으로 가리키게 했습니다.

일반 링크: [B 글](/posts/b/)
ref 링크: [B 글]({{< ref "b" >}})
relref 링크: [B 글]({{< relref "posts/b.md" >}})

ref와 relref는 Hugo에 들어 있는 숏코드(본문 안에서 부르는 작은 템플릿)입니다. 주소 대신 글 파일을 이름으로 찾아서 링크를 만들어 줍니다. ref는 도메인까지 붙은 전체 주소, relref는 /posts/b/처럼 도메인을 뺀 주소를 냅니다.

B가 공개된 상태에서는 셋 다 B로 갑니다.

href="/posts/b/"
href="https://example.org/posts/b/"
href="/posts/b/"

B를 draft: true로 바꾸고 다시 빌드했습니다. 일반 링크 줄에서는 아무 로그도 나오지 않았습니다. 출력 HTML에도 href="/posts/b/"가 그대로 남습니다. 이제 아무것도 없는 주소입니다.

Hugo 입장에서 /posts/b/는 그냥 글자일 뿐이어서 그렇습니다. 그 주소에 실제로 페이지가 있는지 확인하는 단계가 기본 빌드에는 없습니다.

ref 숏코드는 빌드를 멈춥니다

지도 위 목적지 표시가 지워지자 출발 직전 열차 앞에서 깃발을 들어 세우는 역무원

같은 빌드에서 ref와 relref는 이렇게 나왔습니다.

ERROR [en] REF_NOT_FOUND: Ref "b": ".../content/posts/a.md:7:21": page not found
ERROR [en] REF_NOT_FOUND: Ref "posts/b.md": ".../content/posts/a.md:9:24": page not found
ERROR error building site: logged 2 error(s)

종료 코드는 1입니다. 배포 워크플로가 빌드 실패에서 멈추게 되어 있다면 깨진 링크가 라이브로 나가지 않습니다. 어느 파일 몇 번째 줄인지도 같이 찍혀서 고칠 곳을 바로 찾을 수 있습니다.

이 동작을 정하는 설정이 refLinksErrorLevel(못 찾았을 때의 로그 수준)입니다. Hugo 설정 문서에 ERROR와 WARNING 두 값만 있고 기본값은 ERROR이며 ERROR가 하나라도 나면 빌드가 실패한다고 적혀 있습니다. 그러니 따로 설정하지 않아도 이미 막히는 쪽입니다.

빌드가 실패해도 출력 폴더에는 파일이 써져 있었습니다. 실패한 빌드 뒤에 A의 HTML을 열어 보니 ref 링크 자리가 href=""로 들어가 있었습니다. 그래서 결과물이 있는지만 보면 안 되고 빌드의 종료 코드로 배포를 막아야 합니다.

WARNING으로 낮추면 빈 링크가 나갑니다

경보를 끈 공방에서 주소 칸이 빈 소포가 조용히 컨베이어를 따라 출구로 나가는 장면

글을 옮기는 도중처럼 잠깐 빌드를 통과시켜야 할 때가 있습니다. 이 값은 명령행 플래그로는 줄 수 없었습니다.

$ hugo --refLinksErrorLevel WARNING
ERROR command error: unknown flag: --refLinksErrorLevel

설정 파일을 하나 더 만들어 덧씌우는 방법은 됐습니다.

# warn.toml
refLinksErrorLevel = 'WARNING'
$ hugo --config hugo.toml,warn.toml
WARN  [en] REF_NOT_FOUND: Ref "b": ".../a.md:7:21": page not found
WARN  [en] REF_NOT_FOUND: Ref "posts/b.md": ".../a.md:9:24": page not found

종료 코드는 0이고 링크는 href=""로 나갑니다. 링크 모양은 남아 있는데 갈 곳이 비어 있는 상태로 배포되는 셈입니다.

refLinksNotFoundURL을 같이 주면 그 빈자리에 정한 주소가 들어갑니다.

refLinksErrorLevel = 'WARNING'
refLinksNotFoundURL = '/broken-link/'
href="/posts/b/"  href="/broken-link/"  href="/broken-link/"

배포된 결과물에서 /broken-link/만 검색하면 남은 자리를 찾을 수 있습니다. 다만 이건 임시로만 둘 설정이라고 봅니다. 경고는 빌드 로그 사이로 금방 묻히기 때문입니다. 경고를 실패로 바꾸는 --panicOnWarning도 해봤습니다. 막기는 했지만 첫 경고에서 바로 멈춰서 두 번째 깨진 링크는 보고되지 않았습니다. 한 번에 다 보려면 ERROR로 두는 게 낫습니다.

하나 더 있습니다. -D(초안 포함 빌드)로 돌리면 이 검사가 전부 통과합니다. draft 글도 빌드에 들어가니 B가 그대로 있기 때문입니다. 오류 0줄, 링크도 전부 B로 나갑니다. 확인용 빌드는 -D 없이 돌려야 합니다.

마크다운 링크는 그대로 두고 훅으로 검사했습니다

주소록과 봉투의 받는 곳을 한 장씩 맞춰보는 우체국 창구 직원과 옆에 쌓여 가는 반송 봉투

이미 써 둔 글의 링크를 전부 ref로 바꾸는 건 일이 큽니다. 그래서 render hook(마크다운이 HTML로 바뀔 때 링크 하나하나를 받아서 처리하는 템플릿)으로 일반 링크도 같은 방식으로 검사하게 했습니다.

먼저 확인한 것이 있습니다. Hugo에는 내장 링크 훅이 있는데, useEmbedded = 'always'로 켜도 /posts/b/에 대해서는 아무 말이 없었습니다. 링크 훅 문서에도 대상을 못 찾아도 오류나 경고를 내지 않는다고 적혀 있습니다. 그래서 직접 만들었습니다.

공개된 페이지 주소를 모아 두는 부분 템플릿입니다.

{{/* layouts/_partials/known-paths.html */}}
{{- $known := dict -}}
{{- range site.Pages -}}
  {{- $known = merge $known (dict (urls.PathUnescape .RelPermalink) true) -}}
{{- end -}}
{{- return $known -}}

링크 훅입니다. /로 시작하는 사이트 안 링크만 봅니다.

{{/* layouts/_markup/render-link.html */}}
{{- $u := urls.Parse .Destination -}}
{{- if and (not $u.IsAbs) (hasPrefix $u.Path "/") (not (hasPrefix .Destination "//")) -}}
  {{- $known := partialCached "known-paths.html" . -}}
  {{- $ok := or (index $known $u.Path) (os.FileExists (path.Join "static" $u.Path)) -}}
  {{- if not $ok -}}
    {{- errorf "깨진 내부 링크 %q — %s" .Destination .Position -}}
  {{- end -}}
{{- end -}}
<a href="{{ .Destination | safeURL }}"{{ with .Title }} title="{{ . }}"{{ end }}>{{ .Text }}</a>

draft 글은 site.Pages에 들어가지 않습니다. 그래서 B를 내리면 B의 주소가 목록에서 빠지고 B로 가는 링크가 걸립니다.

ERROR 깨진 내부 링크 "/posts/b/" — "content/posts/c.md:5:12"
ERROR 깨진 내부 링크 "/posts/b/" — "content/posts/a.md:5:16"
ERROR 깨진 내부 링크 "/posts/b/#둘째" — "content/posts/a.md:13:16"
ERROR error building site: logged 5 error(s)

(나머지 두 줄은 같은 글의 ref 링크에서 나온 오류입니다.) errorf는 첫 오류에서 멈추지 않고 깨진 링크를 전부 모아서 보고합니다. #둘째 같은 앵커가 붙은 링크도 앞의 경로로 판정됩니다. B를 다시 공개하면 static/ 아래 파일 링크, 앵커 링크, 외부 링크까지 모두 통과합니다.

처음 버전은 한글 주소를 깨진 링크로 잘못 잡았습니다. slug가 한글인 글의 .RelPermalink는 /posts/%ed%95%9c.../처럼 퍼센트 인코딩된 값인데, urls.Parse로 꺼낸 링크 경로는 디코딩된 한글이라 서로 맞지 않았습니다. 그래서 주소 목록을 만들 때 urls.PathUnescape로 풀어서 넣습니다. 이렇게 하면 한글 그대로 쓴 링크와 인코딩해서 쓴 링크가 둘 다 통과합니다.

이 훅은 페이지와 static/ 파일만 압니다. 페이지 번들 안의 파일처럼 다른 곳을 링크한다면 $ok에 조건을 더 넣어야 합니다.

실제로 내렸던 순간을 다시 돌려봤습니다

되감은 필름을 다시 돌려 보다 두 장면에서 멈춰 빨간 표시를 붙이는 영사실의 사람

테스트 사이트만으로는 부족해서 이 블로그 저장소를 복사해 와서 확인했습니다. 글을 내리기 직전 커밋으로 되돌리고 내린 글들만 draft: true로 바꾼 뒤 링크는 손대지 않은 상태로 빌드했습니다.

훅이 없을 때는 종료 코드 0, 경고 0줄이었습니다. 출력 HTML에는 사라진 글로 가는 href가 두 개 남아 있었습니다.

훅을 넣었을 때는 이렇게 나왔습니다.

ERROR 깨진 내부 링크 "/posts/<내린-글-1>/" — "content/posts/<남은-글-1>.md:28:1"
ERROR 깨진 내부 링크 "/posts/<내린-글-2>/" — "content/posts/<남은-글-2>.md:88:1"
ERROR error building site: logged 2 error(s)

내린 글과 링크가 있던 글의 이름은 가렸고, 줄 번호는 원문 그대로입니다. 제가 손으로 찾아 걷어낸 두 자리와 파일도 줄도 같았습니다. 글을 내리기 전 상태에 훅만 넣은 빌드는 오류 0개였습니다. 홈과 다른 페이지로 가는 링크들도 잘못 걸리지 않았습니다.

여기서 하나 더 걸렸습니다. 내린 글 주소에 aliases를 두면 (그 주소로 들어온 사람을 다른 곳으로 보내는 페이지를 Hugo가 만들어 줍니다) 사라진 글 자리에 HTML 파일이 다시 생깁니다. 홈으로 보내는 alias를 둔 상태로 빌드해 보니 /posts/<내린-글-1>/index.html이 meta refresh로 홈에 보내는 페이지로 만들어졌습니다.

이러면 빌드 결과물 폴더를 훑는 링크 검사기는 파일이 있으니 통과시킬 상태입니다. 독자는 글 대신 홈에 도착하는데도 그렇습니다. 훅은 alias 페이지를 site.Pages로 보지 않아서 같은 두 건을 그대로 잡았습니다.

다시 한다면

책을 빼기 전에 색인 카드부터 넘겨보며 그 책을 가리키는 카드에 표시를 붙이는 사서

글을 내리는 순서를 바꾸겠습니다. 다음에는 이 순서로 하겠습니다.

  1. 링크 훅을 먼저 넣어 둡니다. 평소 빌드가 오류 0개인지 확인합니다
  2. 내릴 글에 draft: true를 붙이고 -D 없이 빌드합니다
  3. 오류에 찍힌 파일·줄을 하나씩 고칩니다. 링크를 빼거나 다른 글로 바꿉니다
  4. 오류 0개가 된 다음에 alias를 붙입니다. 순서를 거꾸로 하면 alias가 빈자리를 가립니다

새로 쓰는 글에서 링크가 꼭 살아 있어야 하는 곳은 {{< relref "파일명.md" >}}로 씁니다. 기본 설정만으로도 대상이 사라지면 빌드가 멈춥니다. B의 slug를 bb로 바꿔 봤더니 ref·relref 링크는 /posts/bb/로 따라갔고, 일반 링크 /posts/b/는 훅에 걸렸습니다.

훅 없이 grep으로 찾는 방법도 해 봤습니다. 내린 글 두 편의 주소로 grep -rn 을 돌리니 content/ 에서 26줄이 나왔습니다. alias 선언, 이미지 경로, 내린 글끼리의 링크, 코드블록 속 URL이 섞여 있었고 실제로 고칠 링크는 그중 2줄이었습니다. 한 번 찾아보는 데는 쓸 만하지만 매번 사람이 걸러야 하니 빌드에 넣을 수는 없었습니다.