Hugo RSS를 전문으로 바꿨더니 이미지 주소는 멀쩡했고, 포럼식 CDATA에서만 깨졌습니다
Hugo 블로그의 RSS 피드(새 글을 구독기에 알려주는 XML 파일)는 기본으로 글 앞부분 요약만 내보냅니다.
구독기에서 글 전체를 읽게 하려면 템플릿 한 줄을 바꾸면 된다는 답이 포럼에 여러 번 올라와 있습니다.
걸리는 건 그다음입니다. 본문 속 /images/... 같은 상대 경로가 구독기에서 깨지지 않느냐는 질문이 같이 따라붙습니다.

그래서 제 사이트 사본에서 요약을 전문으로 바꿔 빌드하고 용량과 상대 경로 개수를 전후로 셌습니다.
포럼에서 권하는 CDATA 방식도 같이 돌려봤고, 상대 경로가 섞인 작은 시험 사이트도 따로 만들었습니다.
Hugo v0.165.0 extended, 2026-10-06에 잰 값입니다. 운영 중인 사이트의 피드는 아직 요약 그대로입니다.
요약을 내보내는 줄은 Hugo 안에 들어 있습니다

layouts/에 RSS 템플릿을 따로 만들지 않았다면 Hugo 안에 들어 있는 템플릿이 쓰입니다.
v0.165.0 태그의 내장 rss.xml에서 항목 본문을 찍는 줄은 이것 하나입니다.
<description>{{ .Summary | transform.XMLEscape | safeHTML }}</description>
이 줄을 고치려면 내장 템플릿을 통째로 복사해 내 layouts/에 두고 덮어써야 합니다.
RSS 템플릿 문서도 “Override Hugo’s embedded RSS template by creating one or more of your own"라며 홈 피드 자리로 layouts/home.rss.xml을 예로 듭니다.
내장 템플릿을 덮어쓰는 다른 이유는 lastBuildDate가 빠진 문제에서도 다뤘습니다.
먼저 기준선을 잡았습니다. 저장소 사본을 배포와 같은 옵션(--gc --minify)으로 빌드하니 피드가 라이브와 바이트 단위로 같았습니다.
$ curl -sS https://birchholt.com/index.xml -o live.xml
$ cmp live.xml out-before/index.xml && echo SAME
SAME
그다음 복사본에서 .Summary를 .Content로만 바꿨습니다. 뒤에 붙은 transform.XMLEscape는 그대로 뒀습니다.
-<description>{{ .Summary | transform.XMLEscape | safeHTML }}</description>
+<description>{{ .Content | transform.XMLEscape | safeHTML }}</description>
전문으로 바꾸면 피드가 여덟 배 넘게 커집니다

항목 19개짜리 피드입니다.
| 그대로 | brotli 압축 | |
|---|---|---|
| 요약(지금 라이브) | 53,001바이트 | 11,256바이트 |
| 전문 | 459,051바이트 | 81,681바이트 |
| 전문 + 항목 10개 제한 | 264,977바이트 | 47,391바이트 |
그대로 재면 8.7배, 압축해서 재면 7.3배입니다. 제 사이트는 피드를 brotli(웹 서버가 쓰는 압축 방식)로 압축해 보내고 있어서
brotli를 받는 구독기라면 둘째 열만큼 내려받습니다.
전문 피드는 글이 늘수록 같이 커집니다. Hugo는 기본으로 피드 항목 수에 제한이 없습니다(위 문서의 “By default, the number of items in each feed is unlimited”).
그래서 셋째 줄처럼 설정에 [services.rss] limit = 10을 두고 다시 쟀습니다.
늘어난 양 중에는 구독기에서 쓸모없는 것도 섞여 있었습니다.
본문을 풀어서 세어 보니 코드 색칠용 <span class="..."> 태그만 4,308개, 51,302바이트였습니다.
제 사이트는 코드 색을 CSS 클래스로 입히는데(noClasses = false) 구독기에는 그 CSS가 없습니다.
색은 안 나오고 태그 무게만 갑니다.
빌드 결과에서 피드 파일을 뺀 나머지 파일은 전후로 해시가 전부 같았습니다. 바뀐 건 index.xml 하나입니다.
상대 경로는 Hugo가 이미 절대 주소로 바꾸고 있었습니다

제 원고에는 ](/posts/...)처럼 슬래시로 시작하는 링크가 311개 있습니다. 이미지도 전부 /images/posts/...입니다.
그런데 전문 피드를 열어 src와 href를 모두 세어 보니 이렇게 나왔습니다.
src·href 234개
https://birchholt.com/... 로 시작 146
외부 사이트 87
mailto 1
/ 로 시작(루트 상대) 0
# 로 시작(같은 페이지 조각) 0
슬래시로 시작하는 주소가 하나도 안 남았습니다. 같은 빌드의 HTML 페이지에는 /posts/hugo-over-astro/ 링크가 슬래시로 시작하는 그대로 있었으니 피드에서만 바뀐 것입니다.
최소화(--minify)를 끄고 빌드해도 결과가 같아서 소스를 열어 봤습니다.
if isRSS {
// Always canonify URLs in RSS
pd.AbsURLPath = s.absURLPath(targetPath)
hugolib/site.go의 이 분기가 RSS 출력 전체에 절대 주소 변환(canonify)을 겁니다.
설정의 canonifyURLs를 켜지 않아도 RSS에는 항상 들어갑니다.
변환 규칙은 absurlreplacer.go에 있습니다.
src=·href=·srcset= 등의 값이 /로 시작하면 앞에 baseURL을 붙입니다.
그리고 XML일 때는 따옴표가 "나 '로 이스케이프돼 있어야 값을 알아봅니다.
transform.XMLEscape를 거친 본문은 따옴표가 정확히 그 모양이 됩니다.
그래서 내장 템플릿의 줄을 그대로 두고 .Summary만 .Content로 바꾸면 이 변환이 알아서 따라붙습니다.
W3C Feed Validator에 넣어도 상대 경로 경고는 나오지 않았습니다.
포럼에서 권하는 CDATA로 감싸면 그 변환이 꺼집니다

전문 피드를 묻는 포럼 글에는 CDATA(XML 안에서 이스케이프 없이 글을 그대로 담는 구간)로 감싸라는 답이 자주 나옵니다. 2017년 글도, 2020년 글도 그렇습니다. 그 방식으로도 빌드해 봤습니다.
<description>{{ "<![CDATA[" | safeHTML }}{{ .Content | safeHTML }}{{ "]]>" | safeHTML }}</description>
루트 상대 https://birchholt.com/...
XMLEscape 유지 0 146
CDATA로 감쌈 146 0
146개가 전부 상대 경로로 돌아왔습니다. CDATA 안에서는 따옴표가 날것인 "라서 위 규칙이 값을 못 알아봅니다.
Feed Validator도 이번에는 description should not contain relative URL references 경고를 냈습니다.
경고 설명은 “users of some news aggregators will not be able to click on them"입니다.
2020년 글의 답이 CDATA와 함께 href="/·src="/를 문자열 치환으로 바꾸라고 한 것도 아마 이 때문일 겁니다.
Hugo가 해주는 변환을 꺼 놓고 손으로 다시 하는 셈입니다.
구독기가 실제로 깨지는지는 구독기마다 다릅니다. 파이썬 피드 파서 feedparser 6.0.14에 피드 주소를 함께 알려주면 146개를 전부 제 도메인 주소로 풀었습니다. 주소를 알려주지 않으면 146개가 상대 경로로 그대로 남았습니다. 상용 구독기는 시험하지 않았습니다.
용량은 CDATA 쪽이 358,475바이트로 더 작았습니다. 이스케이프가 없어서입니다. 다만 brotli로 압축하면 79,284바이트 대 81,681바이트라 차이가 3%도 안 됩니다.
슬래시로 시작하지 않는 주소는 끝까지 남습니다

제 원고는 링크가 거의 다 슬래시로 시작해서 운이 좋았던 셈입니다.
Hugo의 변환이 무엇을 놓치는지 보려고 baseURL을 https://example.org/blog/로 둔 작은 시험 사이트를 만들었습니다.
글 하나에 번들 이미지(글 폴더 안에 같이 둔 그림), 같은 글 안 절로 가는 앵커, ../ 상대 링크, 각주를 넣었습니다.
위와 같은 템플릿(XMLEscape 유지)으로 빌드하자 이 다섯 개가 그대로 남았습니다.
cat.png
#%EC%B2%AB-%EC%A0%88
../other/
#fn:1
#fnref:1
feedparser에 피드 주소를 주고 풀게 하니 전부 엉뚱한 곳을 가리켰습니다.
cat.png → https://example.org/blog/cat.png (실제는 /blog/posts/bundle/cat.png)
#fn:1 → https://example.org/blog/index.xml#fn:1
../other/ → https://example.org/other/
구독기 쪽에서는 이 주소들의 기준이 글 주소가 아니라 피드 주소가 됩니다.
각주 링크가 RSS에서 문제가 된다는 지적은 2020년 전문 피드 옵션 제안 PR에도 이미 달려 있었습니다(“Hugo creates relative references (like <a href="#fn:1">) that would be invalid in RSS”). 그 PR은 반영되지 않고 닫혔습니다.
고치는 데는 RSS 전용 렌더 훅(마크다운 링크·이미지를 HTML로 바꿀 때 끼어드는 템플릿)을 썼습니다.
렌더 훅 문서에 출력 형식별로 훅을 따로 둘 수 있다고 나와 있고 render-link.rss.xml이 예로 실려 있습니다.
{{- /* layouts/_markup/render-link.rss.xml */ -}}
{{- $u := urls.Parse .Destination -}}
{{- $href := .Destination -}}
{{- if and (not $u.IsAbs) (not (hasPrefix .Destination "/")) -}}
{{- $href = ((urls.Parse .Page.Permalink).ResolveReference $u).String -}}
{{- end -}}
<a href="{{ $href }}"{{ with .Title }} title="{{ . }}"{{ end }}>{{ .Text }}</a>
{{- /* layouts/_markup/render-image.rss.xml */ -}}
{{- $u := urls.Parse .Destination -}}
{{- $src := .Destination -}}
{{- with .Page.Resources.Get $u.Path -}}{{- $src = .Permalink -}}
{{- else -}}{{- if and (not $u.IsAbs) (not (hasPrefix $.Destination "/")) -}}{{- $src = ((urls.Parse $.Page.Permalink).ResolveReference $u).String -}}{{- end -}}
{{- end -}}
<img src="{{ $src }}" alt="{{ .PlainText }}">
슬래시로 시작하는 주소는 일부러 건드리지 않았습니다. 처음에는 글 주소에 그냥 이어 붙였다가 /posts/other/가 .../bundle/posts/other/로 붙는 걸 보고 고쳤습니다. 그쪽은 Hugo 변환에 맡기면 됩니다.
각주는 마크다운 엔진이 직접 그려서 훅을 거치지 않습니다. 그래서 피드 템플릿에서 문자열로 바꿨습니다.
<description>{{ replace .Content "href=\"#" (printf "href=\"%s#" .Permalink) | transform.XMLEscape | safeHTML }}</description>
다시 빌드하니 다섯 개 모두 절대 주소가 됐습니다. cat.png·앵커·각주는 https://example.org/blog/posts/bundle/ 아래로, ../other/는 https://example.org/blog/posts/other/로 풀렸고 Feed Validator 경고도 사라졌습니다.
같은 빌드의 HTML 페이지에는 cat.png, #fn:1이 원래대로 남아 있었습니다. .rss.xml 훅은 피드에만 쓰입니다.
baseURL에 하위 경로가 있는 사이트라면 하나 더 볼 것이 있습니다. 본문의 /images/dog.png가 피드에서는 https://example.org/blog/images/dog.png로 바뀌었습니다. 반면 HTML 페이지에서는 브라우저가 같은 값을 도메인 루트 기준으로 읽으니 두 곳이 다른 주소를 가리킵니다.
바꾸기 전에 저울에 올려볼 것

제 사이트 피드는 아직 요약 그대로입니다. 바꾸는 쪽으로 정한다면 이렇게 하겠습니다.
- 내장
rss.xml을layouts/home.rss.xml로 복사하고.Summary를.Content로 바꿉니다.transform.XMLEscape는 남깁니다. CDATA로 갈아타면 Hugo의 절대 주소 변환이 꺼집니다 - 빌드한 피드에서
src="href="값 중http나mailto:로 시작하지 않는 것을 셉니다. 0이면 템플릿 한 줄로 끝입니다 - 남는 게 번들 이미지·앵커·
../·각주라면 위의.rss.xml렌더 훅 두 개와 각주 치환을 더합니다 - 피드 용량을 전후로 재고 필요하면
services.rss.limit로 항목 수를 묶습니다. 제 경우 요약 대비 8.7배였습니다 - Feed Validator에 넣어
ContainsRelRef경고가 없는지 봅니다
용량 말고도 따질 게 있습니다. 전문 피드는 구독자에게 편하지만 글 전체가 기계가 읽기 좋은 한 파일에 모입니다.
남의 사이트에 글을 통째로 옮겨 싣는 쪽에도 그만큼 편해질 것으로 봅니다. 이건 재 볼 수 없는 부분이라 짐작으로만 적어 둡니다.
요약을 description에 두고 전문을 content:encoded에 따로 싣는 방법도 있는데, 그렇게 빌드하니 506,109바이트로 가장 컸습니다.
이 방법도 XMLEscape를 유지하면 상대 경로는 0개였습니다.
템플릿 한 줄은 쉽게 바뀝니다. 그 한 줄 뒤에서 Hugo가 몰래 해주던 일이 무엇이었는지는 피드를 열어 세어 보고서야 알았습니다.