조용히 실패하는 것들 4 — 정본 주소가 404를 가리켜도 화면은 멀쩡히 뜹니다
사이트 네 곳에 SEO를 붙이다가 세 가지를 찾았어요. 정본 주소가 없는 페이지를 가리키고 있었고, 한자를 거르려던 정규식이 한글을 통째로 삼켰고, 그래서 카드가 한 장도 안 구워졌는데 빌드는 성공했습니다.
앞의 세 편은 빌드와 배포가 조용히 실패한 이야기였어요.
이번 편은 자리가 다릅니다. <head> 안이에요.
여기가 고약한 이유는 하나예요. 사람이 안 보는 곳이라서, 틀려도 화면은 멀쩡히 뜹니다.
사이트 네 곳(홈·디자인 시스템·약관 보관소·이 블로그)에 SEO를 붙이면서 세 가지를 찾았어요. 셋 다 빌드는 초록이었습니다.
1. 정본 주소가 없는 페이지를 가리키고 있었어요
이 블로그는 예전 Pelican 시절의 주소를 그대로 지키고 있어요.
그래서 어떤 페이지는 .html로 끝납니다.
/android/aapt-string-newline.html
/archives.html
정본 주소(canonical)는 이렇게 만들고 있었어요.
const canonical = new URL(Astro.url.pathname, SITE.url).href;
문제는 Astro.url.pathname이 그 주소를 다듬어서 내놓는다는 거예요.
실제 주소 /android/aapt-string-newline.html
pathname /android/aapt-string-newline/
뒤쪽 주소는 어디에도 없습니다. 깃허브 페이지에서 404예요. 즉 모든 글의 정본이 404를 가리키고 있었습니다.
정본이 404면 그 페이지는 색인에서 조용히 빠져요.
그런데 화면은 멀쩡히 뜨고, 링크도 다 잘 돌아갑니다.
틀린 건 사람이 안 여는 <head> 한 줄이에요.
고치는 건 간단했어요. 레이아웃이 주소를 추측하지 않게 하고, 부르는 쪽이 실제 주소를 주게 했습니다.
<Base path={postUrl(post)}> <!-- /android/foo.html -->
<Base path="/archives.html">
주소를 아는 쪽이 주소를 준다. 레이아웃은 지금 어느 페이지인지 모르는 게 정상이에요.
2. 한자를 거르려던 정규식이 한글을 통째로 삼켰어요
공유 카드(OG 이미지)를 코드로 굽기로 했어요. 글꼴은 Pretendard를 쓰는데, 여기엔 한자가 없습니다. 라틴·한글·가나까지만 있어요.
없는 글자를 그리면 두부(□)가 나옵니다. 그래서 “한자가 섞였으면 카드를 굽지 말자”는 검사를 넣었어요.
// 이렇게 적었습니다
const HAN = /[㐀-䶿一-鿿豈-]/;
범위를 글자 그대로 적었죠. 마지막 범위의 시작을 豈로 잡았는데,
호환 한자 블록의 첫 글자(U+F900)라고 알고 있었어요.
아니었습니다. 豈는 U+8C48이에요.
그래서 생긴 범위가 이겁니다.
의도한 것 U+F900 – U+FAFF (호환 한자)
실제로 적힌 것 U+8C48 – U+FAFF ← 한글(U+AC00~U+D7A3)이 이 안에 있어요
한글이 통째로 “한자”로 판정됐어요.
HAN.test('안드로이드를 만들며 배운 것') // → true
결과가 뭐였냐면 — 카드가 한 장도 안 구워졌습니다. 모든 글이 “못 그리는 글”로 걸러졌으니까요.
그런데 빌드는 성공했어요. “구울 것이 없다”와 “구울 게 있는데 실패했다”를 빌드는 구분하지 않거든요. 0개를 구우면 0개를 굽는 데 성공한 겁니다.
고친 방법은 하나예요. 범위를 코드포인트로 적는 것.
// 글자가 아니라 번호로. 눈으로 맞히려 드는 순간 같은 실수가 또 납니다.
const HAN = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/;
사람이 눈으로 “이 글자가 그 블록의 첫 자”를 맞히려 드는 순간 이런 오차가 생겨요. 그리고 눈으로는 절대 안 잡힙니다.
3. 그래서 “0개 생성”을 아무도 이상하게 보지 않았어요
2번이 정말 무서웠던 건 정규식 실수 자체가 아니에요. 아무도 이상하다고 말하지 않았다는 것이었어요.
빌드 로그는 이랬습니다.
✓ Completed in 267ms.
[build] 68 page(s) built
68장이 만들어졌으니 다 잘된 것 같죠. 카드가 0장이라는 사실은 로그 어디에도 없어요. 만들지 않은 것은 로그에 남지 않으니까요.
제가 이걸 잡은 건 우연에 가까웠어요.
결과물 폴더를 열어보다가 dist/og/에 파일이 하나뿐인 걸 봤습니다.
그래서 규칙을 하나 더했어요. 앞의 세 편에서 세운 것과 같은 규칙이에요.
빌드가 끝나면 결과물을 세어본다.
지금은 구운 HTML을 전부 훑는 검사를 돌립니다. 빠진 것을 찾는 게 아니라 가리키는 것이 실제로 있는지를 봐요.
── blog ─ HTML 68장 (색인 65) · robots O · sitemap 65 URL
✗ /android/…: canonical 이 없는 주소
✗ /android/…: og:image 가 없는 파일
이 두 줄이 1번과 2번을 동시에 잡아줬어요.
덤 — 도구가 조용히 다르게 한 일 셋
같은 작업에서 만난, 같은 성질의 것들이에요. 전부 “에러는 안 나는데 결과가 다른” 종류입니다.
Next.js는 openGraph를 합쳐주지 않아요
레이아웃에 카드 이미지를 정해두고, 페이지에서 제목만 덮어썼어요.
// layout.tsx — 이미지·사이트 이름을 정함
export const metadata = { openGraph: { images: [...], siteName: ... } }
// page.tsx — 제목만 적었는데
export const metadata = { openGraph: { title: '...' } }
이러면 이미지가 통째로 사라집니다. Next는 이 칸을 필드 단위로 합치지 않고 통째로 갈아 끼워요.
에러는 안 나요. 그냥 카드에 그림이 없어집니다.
satori는 자간을 주면 한글 사이 공백 하나를 삼켜요
카드의 작은 머리말에 letterSpacing: 2를 줬더니 이렇게 나왔어요.
넣은 글자 안드로이드를 만들며 배운 것
그려진 글자 안드로이드를만들며 배운 것
공백 하나만 사라집니다. 나머지는 멀쩡해요. 멋보다 낱말이 붙지 않는 쪽이 나으니 자간을 걷었습니다.
정적 내보내기에서 OG 이미지는 확장자 없이 나와요
Next의 opengraph-image 규약을 그대로 쓰면
정적 내보내기에서 확장자 없는 파일이 남아요.
out/opengraph-image ← .png 가 아니에요
깃허브 페이지는 확장자로 “이게 무엇인지”를 정합니다. 그러니 이 파일은 그림이 아니라 “내려받을 덩어리”가 되고, 카드를 긁어가는 쪽(트위터·슬랙)은 그걸 그림으로 읽지 않아요.
주소를 우리가 짓는 걸로 바꿨습니다.
src/app/og.png/route.tsx → out/og.png ← 진짜 PNG
고치고 나서 적어 둔 것
1 도구가 잘못 판정했고, 2 도구가 통째로 실패했는데 말하지 않았고, 3 검사가 비교할 대상을 잃고도 그냥 떨어졌고, 4 결과물이 0개인데 성공했습니다.
앞의 세 편에서 이렇게 적었어요.
자동화의 끝은 “돌렸다”가 아니라 “확인했다”입니다.
이번 편이 거기에 한 줄을 더해요.
확인은 “에러가 없다”가 아니라 “있어야 할 것이 있다”입니다.
<head>는 특히 그래요.
화면이 멀쩡하다는 건 아무 증거가 못 됩니다.
거긴 사람이 안 보는 자리라서, 틀린 채로 몇 달도 갈 수 있어요.