모든 글
Silent Failures · 3분 읽기

조용히 실패하는 것들 4 — 정본 주소가 404를 가리켜도 화면은 멀쩡히 뜹니다

사이트 네 곳에 SEO를 붙이다가 세 가지를 찾았어요. 정본 주소가 없는 페이지를 가리키고 있었고, 한자를 거르려던 정규식이 한글을 통째로 삼켰고, 그래서 카드가 한 장도 안 구워졌는데 빌드는 성공했습니다.

#seo#astro#nextjs#정적사이트#자동화

앞의 세 편은 빌드와 배포가 조용히 실패한 이야기였어요. 이번 편은 자리가 다릅니다. <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>는 특히 그래요. 화면이 멀쩡하다는 건 아무 증거가 못 됩니다. 거긴 사람이 안 보는 자리라서, 틀린 채로 몇 달도 갈 수 있어요.