Hugo ld+json에 '문자열의 이스케이프 시퀀스가 잘못됨'이 뜰 때, 지금 버전에서는 포럼의 해결책이 원인이었습니다
Hugo 템플릿에 구조화된 데이터(검색엔진이 읽는 글 정보 묶음, ld+json)를 넣어 두면 대부분은 조용히 지나갑니다.
그런데 리치 결과 테스트에 어떤 글을 넣으면 파싱할 수 없는 구조화된 데이터가 뜨고
상세정보에 문자열의 이스케이프 시퀀스가 잘못됨이라고 나오는 경우가 있습니다.
같은 템플릿을 쓰는 다른 글은 멀쩡하고 깨지는 글은 제목이나 설명에 &·따옴표·역슬래시가 들어 있습니다.

Hugo 포럼에 이 문제를 다룬 스레드가 둘 있고(조회 14,225회·4,100회) 답도 달려 있지만 스레드가 시작된 것은 2016년과 2019년이고 답도 대부분 그 시절 Hugo 기준입니다.
지금 쓰는 v0.165.0에서도 맞는지 보려고 Hugo 16개 버전과 템플릿 작성법 9가지로 직접 재현했습니다.
결론부터 말씀드리면, 제가 시험한 작성법 중 지금 Hugo에서 JSON을 깨뜨린 것은 기본 문법이 아니라 포럼에서 해결책으로 퍼진 코드였습니다.
스레드 조회수는 Discourse가 내주는 JSON에서 2026-10-06에 읽은 값입니다 — Problems with encoding special characters in json+ld(2016), \x26rsquo; bad escape sequence in string for JSON-LD(2019).
오류 문구가 무엇을 가리키는지부터 확인했습니다

JSON(구조화된 데이터를 적는 텍스트 형식)은 문자열 안에 쓸 수 있는 역슬래시 표기가 정해져 있습니다.
\", \\, \n, \u0026 같은 것은 되고, \x26 같은 표기는 없습니다.
JSON 표준 RFC 8259 7절의 문법은 역슬래시 뒤에 " \ / b f n r t와 u+16진수 네 자리만 허용합니다. 따옴표·역슬래시·제어문자(U+0000~U+001F)는 반드시 이스케이프하라고도 적혀 있습니다.
리치 결과 테스트의 코드 탭에 값 하나만 바꿔 가며 넣어 봤습니다.
headline 값 | 결과 | 상세정보 |
|---|---|---|
"R\x26D 일지" | 파싱할 수 없음 | 문자열의 이스케이프 시퀀스가 잘못됨 |
"Tom\x27s 노트" | 파싱할 수 없음 | 문자열의 이스케이프 시퀀스가 잘못됨 |
"C:\Users 경로" | 파싱할 수 없음 | 문자열의 이스케이프 시퀀스가 잘못됨 |
""큰" 실험" | 파싱할 수 없음 | 파싱 오류: ‘,’ 또는 ‘}’ 누락 |
"R\u0026D 일지" | 유효한 항목 1개 | — |
같은 &라도 \x26으로 적으면 깨지고 \u0026으로 적으면 통과합니다.
그리고 Hugo와 상관없이 \U처럼 JSON에 없는 역슬래시 조합이 생기면 똑같은 문구가 나옵니다.
2026-10-07 리치 결과 테스트(비로그인, 한국어 화면)에서 잰 값입니다. 각 입력은 @context·@type·datePublished·author가 들어간 BlogPosting 한 덩어리이고 headline만 바꿨습니다.
Search Console 보고서 화면의 문구는 직접 보지 못했습니다.
같은 템플릿이 지금 Hugo에서는 통과합니다

2016년 스레드의 첫 글은 이렇게 쓴 템플릿이었습니다.
<script type="application/ld+json">
{ "@type": "BlogPosting", "headline": "{{ .Title }}" }
</script>
값을 따옴표로 직접 감싼 모양입니다. 운영 중인 저장소는 건드리지 않고 사본과 빈 연습 사이트를 만들어
제목이 Tom & Jerry's "Big" Day + a<b \ 끝인 글 하나로 빌드했습니다.
Hugo 0.55.6 → "headline":"Tom \x26 Jerry\x27s \x22Big\x22 Day \x2b a\x3cb \\ 끝"
Hugo 0.165.0 → "headline":"Tom \u0026 Jerry\u0027s \u0022Big\u0022 Day \u002b a\u003cb \\ 끝"
옛 버전은 \x로, 지금 버전은 \u00으로 적습니다. 앞쪽은 파싱에 실패하고 뒤쪽은 성공합니다.
Hugo는 <script> 안에 넣는 값의 특수문자를 알아서 바꿔 넣는데, 그 바꾸는 방식이 버전 사이에 달라졌습니다.
어느 버전에서 바뀌었는지 릴리스 파일을 받아 차례로 빌드해 봤습니다.
0.55.6 0.60.0 0.70.0 0.71.0 → 깨짐 (\x26)
0.71.1 0.72.0 0.80.0 0.90.0 0.100.0 0.110.0 0.120.0 0.130.0 0.165.0 → 통과 (\u0026)
경계는 0.71.0과 0.71.1 사이였습니다.
릴리스 노트에 “Fix Go template script escaping"이 있고, 연결된 이슈 제목이 “+를 \x2b로 바꿔 ld+json 파싱이 실패한다"입니다.
이 수정 커밋의 코드 주석에는 \x 표기를 쓰지 않는 이유가 “JSON과 호환되지 않고 이 처리기가 ld+json도 다루기 때문"이라고 적혀 있습니다.
v0.71.1 릴리스 노트, 이슈 #6695, 커밋 6c3c668 — 2026-10-06에 열어 확인했습니다. 0.71.1 이후는 위에 적은 버전들만 표본으로 빌드했고 모든 버전을 돌려 보지는 않았습니다. 구버전 바이너리 중 일부는 Intel용이라 Rosetta로 실행했습니다.
제가 빌드해 본 0.71.1 이상 버전에서는 "{{ .Title }}"처럼 쓴 템플릿이 이 오류를 내지 않았습니다. 최신 버전인데도 뜬다면 원인은 다른 데 있을 가능성이 큽니다.
지금 버전에서 깨지는 것은 ‘고친 코드’ 쪽이었습니다

옛 버전에서 막힌 사람들은 Hugo의 자동 변환을 피하려고 우회 코드를 썼고, 그게 포럼에 해결책으로 남았습니다. 대표적인 것이 2016년 스레드에서 “SOLUTION"으로 올라온 줄과, 2019년 스레드에 2025년에 달린 답입니다.
{{/* 2016년 "SOLUTION" */}}
"description": {{ printf "\"%s\"" .Description | safeJS }},
{{/* 2025년 답 */}}
"description": {{ printf "\"%s\"" (.Params.description | htmlUnescape | plainify) | safeJS }}
safeJS는 “이 값은 안전하니 손대지 말라"고 Hugo에 알려 주는 함수입니다.
그래서 \x26은 안 생기지만 반대로 아무것도 바꾸지 않고 날것 그대로 넣습니다.
글자 하나씩 넣은 글 8편으로 v0.165.0에서 다시 빌드해 Python의 json.loads로 읽어 봤습니다.
| 쓰는 법 | & | ' | " | + | < | \ | 탭 |
|---|---|---|---|---|---|---|---|
"{{ .Title }}" | 통과 | 통과 | 통과 | 통과 | 통과 | 통과 | 통과 |
printf … | safeJS | 통과 | 통과 | 깨짐 | 통과 | 통과 | 깨짐 | 깨짐 |
printf (htmlUnescape | plainify) | safeJS | 통과 | 통과 | 깨짐 | 통과 | 글자 사라짐 | 깨짐 | 깨짐 |
dict … | jsonify (safeJS 없음) | 문자열 | 문자열 | 문자열 | 문자열 | 문자열 | 문자열 | 문자열 |
"{{ .Title | markdownify }}" | 값 바뀜 | 값 바뀜 | 값 바뀜 | 통과 | 값 바뀜 | 통과 | 통과 |
\가 든 제목은printf방식에서"C:\Users 경로"가 그대로 나갑니다. 리치 결과 테스트에서 바로 문자열의 이스케이프 시퀀스가 잘못됨이 뜬 그 모양입니다.- **
plainify**는 HTML 태그를 지우는 함수라서a<b 비교의<b 비교를 태그로 보고 지웠습니다. 제목이a만 남았습니다. jsonify만 쓰면 결과 전체가 따옴표로 한 번 더 감싸여 문자열 하나가 됩니다. 리치 결과 테스트 문구는 **잘못된 최상위 요소 ‘string’**이었습니다.- **
markdownify**를 거치면'가’로 바뀐 다음에 다시 바뀌어\u0026rsquo;가 됩니다. 파싱은 되지만 값을 풀어 보면Tom’s 노트라는 글자가 들어 있습니다. 옛 버전에서는 이게\x26rsquo;로 나왔는데, 2019년 스레드 제목이 바로 그 모양입니다.
탭은 json.loads가 거부했지만 리치 결과 테스트는 유효로 봤습니다. 검사기마다 너그러운 정도가 다릅니다.
–minify가 일부는 막아 주지만 이 오류는 통과시킵니다

제 사이트는 hugo --gc --minify로 빌드합니다. 사본에 위 printf 방식을 섞어 같은 명령으로 빌드했더니 빌드가 실패했습니다.
failed to process "/posts/<운영 중인 글>/index.html": ...
expected comma character or an array or object ending on line 31 and column 145
description에 큰따옴표가 든 실제 글 한 편이 걸렸습니다. 오류 문구로 보면 결과물을 줄이는 과정(minify)에서 ld+json 안쪽의 구조가 걸린 것입니다. 그럼 검사기 노릇도 하는지, 깨진 값을 직접 박아 확인했습니다.
<script> 안에 넣은 것 | --minify 빌드 | json.loads |
|---|---|---|
| 따옴표가 깨진 값 | 빌드 실패 | 실패 |
\x26 | 통과 | 실패 |
C:\Users | 통과 | 실패 |
| 날것 탭 | 통과 | 실패 |
| 최상위가 문자열 | 통과 | 문자열로 읽힘 |
제가 넣어 본 경우 중에서는 구조가 무너진 것만 막고 이스케이프가 틀린 것은 그대로 내보냈습니다. 이 글의 오류는 통과하는 쪽입니다.
고친 방법은 따옴표를 손으로 붙이지 않는 것입니다

값마다 따옴표를 붙이고 특수문자를 신경 쓰는 대신, 값을 dict(키와 값을 짝지은 묶음)로 모아 Hugo가 JSON으로 통째로 바꾸게 합니다.
제 사이트의 템플릿이 이렇게 되어 있습니다(필드는 줄였습니다).
{{- $ld := dict
"@context" "https://schema.org"
"@type" "BlogPosting"
"headline" .Title
"description" .Description
-}}
<script type="application/ld+json">{{ $ld | jsonify | safeJS }}</script>
jsonify가 따옴표·역슬래시·탭을 JSON 규칙대로 바꾸고, &·<·>는 \u0026·\u003c·\u003e로 적습니다.
safeJS는 그 결과를 한 번 더 감싸지 말라는 표시입니다.
둘 중 하나만 있으면 안 됩니다. safeJS가 빠지면 앞에서 본 “최상위 요소 ‘string’“이 됩니다.
필드 하나만 고치고 싶다면 그 자리에서만 같은 일을 해도 됩니다.
"headline": {{ .Title | jsonify | safeJS }},
두 방식 모두 앞의 8편이 원래 제목과 글자 하나 다르지 않게 읽혔고 v0.55.6에서도 똑같았습니다.
운영과 같은 dict … | jsonify | safeJS 방식으로 테스트 글을 빌드한 결과물을 리치 결과 테스트에 넣으면 항목 이름이 Tom & Jerry's "Big" Day + a<b \ 끝로 정확히 풀려 나왔습니다.
jsonify가 &·<·>를 바꾼다는 것은 Hugo 문서의 noHTMLEscape 항목에 적혀 있습니다.
safeJS 문서는 감싼 내용이 템플릿 출력에 “그대로(verbatim)” 들어간다고 적고 있고 유효하더라도 신뢰할 수 없는 JSON을 safeJS로 넣는 것은 안전하지 않다고 경고합니다. printf로 만든 문자열은 유효한 JSON인지조차 보장되지 않습니다.
내 사이트의 모든 글을 한 번에 읽어 보는 법

리치 결과 테스트는 한 번에 한 페이지라 sitemap의 주소를 전부 받아 ld+json만 뽑아 읽는 스크립트를 돌렸습니다. 읽기만 합니다. 처음에는 Python 기본 설정으로 요청했다가 403(접근 거부)을 받았습니다. 요청 머리말의 User-Agent를 바꾸니 통과했습니다.
import re, json, urllib.request
def get(url):
# 기본 User-Agent(Python-urllib)는 403이 났다
req = urllib.request.Request(url, headers={"User-Agent": "curl/8"})
return urllib.request.urlopen(req).read().decode()
sm = get("https://birchholt.com/sitemap.xml")
for u in re.findall(r"<loc>([^<]+)</loc>", sm):
h = get(u)
# minify 하면 속성 따옴표가 빠지므로 둘 다 받는다
for raw in re.findall(r'<script type="?application/ld\+json"?>(.*?)</script>', h, re.S):
try:
o = json.loads(raw)
print("OK " if isinstance(o, dict) else "STR ", u)
except json.JSONDecodeError as e:
print("FAIL", u, e.msg)
2026-10-07에 돌린 결과는 sitemap 21개 주소 중 글 18편에 ld+json이 있었고 18편 모두 OK였습니다.
특수문자가 든 글은 설명에 큰따옴표가 있는 1편이었고 \"로 바르게 들어가 있었습니다. 나머지 3개 주소(홈·목록·개인정보처리방침)는 글에만 넣도록 조건을 건 대로 ld+json이 없었습니다.
앞의 탭 사례처럼 json.loads는 리치 결과 테스트보다 엄격한 경우가 있습니다. FAIL이 나온 주소만 리치 결과 테스트에 넣어 보면 됩니다.
이 오류를 다시 만나면 보는 순서

hugo version부터 봅니다.0.71.0이하라면"{{ .Title }}"가\x26을 만드는 옛 동작입니다(제가 확인한 범위는 0.55.6~0.71.0). 버전을 올리거나 아래 3번으로 바꿉니다.- 버전이 최신인데도 뜬다면 템플릿에서
printf "\"%s\""와safeJS가 같이 쓰인 줄을 찾습니다. 테마에 들어 있을 수도 있습니다. 제목이나 설명에"·\·탭이 들어간 글에서만 터집니다. - 그 자리를 **
dict … | jsonify | safeJS나{{ .X | jsonify | safeJS }}**로 바꿉니다. 따옴표는 직접 쓰지 않습니다. markdownify·plainify를 거친 값을 넣고 있다면 빼 봅니다. 오류는 안 나도’가 글자로 남거나<뒤가 잘려 나갑니다.- 고친 뒤에는
--minify빌드 성공을 근거로 삼지 않습니다. 위 스크립트로 전 편을 읽어 보고 의심 가는 글만 리치 결과 테스트에 넣습니다.
포럼의 답은 그 시절 버전에서는 맞았습니다. 다만 버전이 바뀐 뒤에는 그 답이 오류를 만드는 쪽이 되어 남아 있었습니다.