이 블로그는 Next.js의 generateMetadata로 각 글의 <meta name="description">을 만든다. 마크다운 원문에서 제목 줄과 코드블록을 걷어내고 앞부분 155자 정도를 잘라 쓰는 간단한 함수 하나가 그 역할을 한다. 네이버 색인용으로 RSS 피드(/feed.xml)를 새로 추가하면서 이 함수를 다시 들여다볼 일이 생겼는데, 그 과정에서 몇 주 전부터 실제 운영 중이던 글들의 메타 디스크립션이 이미 깨져 있었다는 걸 알게 됐다.
증상: 설명이 HTML 태그로 시작한다
RSS 아이템의 <description>에 쓸 요약 텍스트를 만들려고 기존 toMetaDescription() 함수를 그대로 재사용했는데, 결과물이 이상했다. 이미지 블록으로 시작하는 글 몇 편을 넣어보니 이런 식으로 나왔다.
<figure> <img src="https://raw.githubusercontent.com/gradle/gradle/master/images/gradle-dark-green-primary.png" alt="Gradle 공식 로고" /> <figcaption>이미지: gradle/gradle...
155자 제한에 걸려서 잘리기까지 하면 완전히 사람이 읽을 수 없는 문자열이 됐다. 실제로 당시 운영 중이던 60, 61번 글의 페이지 소스를 열어서 <meta name="description">을 확인해보니 똑같이 깨져 있었다. 검색 결과에서는 이게 그대로 스니펫으로 노출될 수 있는 값이라, 발견 못 했으면 한동안 그 상태로 색인됐을 뻔했다.
원인: 마크다운만 걷어내고 HTML은 안 걷어냈다
문제의 함수는 원래 이렇게 생겼었다.
function toMetaDescription(source: string, limit = 155): string {
const lines = source.replace(/```[\s\S]*?```/g, ' ').split('\n')
const bodyLines: string[] = []
let sawHeading = false
for (const rawLine of lines) {
const line = rawLine.trim()
if (/^#{1,6}\s/.test(line)) {
if (sawHeading) break
sawHeading = true
continue
}
bodyLines.push(line.replace(/^[-*]\s+/, ''))
}
const plain = bodyLines
.join(' ')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/[*`>_#]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (plain.length <= limit) return plain
const truncated = plain.slice(0, limit)
const lastSentenceEnd = truncated.lastIndexOf('.')
if (lastSentenceEnd > limit * 0.4) return truncated.slice(0, lastSentenceEnd + 1)
const lastSpace = truncated.lastIndexOf(' ')
return `${truncated.slice(0, lastSpace > 0 ? lastSpace : limit)}…`
}
코드블록(```)과 헤딩, 마크다운 문법 기호(*, `, >, _, #)는 지우지만 <, >로 된 HTML 태그는 손대지 않는다. 이 함수를 처음 만들 때는 본문이 순수 마크다운 텍스트였으니 문제가 없었다. 그런데 스타일 가이드에 "모든 글에 이미지 최소 1장 필수" 규칙이 추가되면서, 글 도입부에 <figure><img ... /><figcaption>...</figcaption></figure> 블록을 마크다운이 아니라 raw HTML로 넣기 시작했다. 함수는 그대로였는데 입력값의 생김새가 바뀐 셈이다.
둘 다 각자 만들 때는 맞는 선택이었다. 이미지 블록에 대체 텍스트, 캡션, 출처 링크까지 넣으려면 마크다운 이미지 문법보다 <figure> 쪼각이 다루기 편했다. 문제는 이 함수가 "본문은 마크다운이다"라는 가정을 암묵적으로 깔고 있었고, 그 가정이 깨진 걸 알려주는 에러나 타입 체크가 전혀 없었다는 점이다. 빌드도 되고 페이지도 멀쩍하게 렌더링되니, RSS처럼 같은 값을 다른 데서 재사용해보기 전까지는 드러날 일이 없었다.
해결: HTML 블록을 먼저 떼어내고, 함수를 공유 모듈로
<figure> 블록 전체를 먼저 제거하고, 혹시 남는 단독 HTML 태그 줄도 걸러내도록 고쳤다.
export function toMetaDescription(source: string, limit = 155): string {
const withoutCode = source.replace(/```[\s\S]*?```/g, ' ')
const withoutHtmlBlocks = withoutCode.replace(/<figure[\s\S]*?<\/figure>/gi, ' ')
const lines = withoutHtmlBlocks.split('\n')
const bodyLines: string[] = []
let sawHeading = false
for (const rawLine of lines) {
const line = rawLine.trim()
if (!line) continue
if (/^#{1,6}\s/.test(line)) {
if (sawHeading) break
sawHeading = true
continue
}
if (/^<\/?[a-z][a-z0-9]*(\s|>|\/)/i.test(line)) continue
bodyLines.push(line.replace(/^[-*]\s+/, ''))
}
const plain = bodyLines
.join(' ')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/<[^>]*>/g, ' ')
.replace(/[*`>_#]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (plain.length <= limit) return plain
const truncated = plain.slice(0, limit)
const lastSentenceEnd = truncated.lastIndexOf('.')
if (lastSentenceEnd > limit * 0.4) return truncated.slice(0, lastSentenceEnd + 1)
const lastSpace = truncated.lastIndexOf(' ')
return `${truncated.slice(0, lastSpace > 0 ? lastSpace : limit)}…`
}
<figure>...</figure> 통째 제거만으로는 부족했다. 글 중간에 캡션 한 줄만 따로 떨어져 남는 경우가 있어서, 줄 단위로 "HTML 태그로 시작하는 줄"을 걸러내는 조건과, 마지막에 남은 잔여 태그를 지우는 정규식을 한 번 더 걸었다. 세 겹으로 걸러야 안전했다.
그리고 이 함수를 app/board/[id]/page.tsx 안에 박혀 있던 private 함수에서 components/board/description.ts로 꺼내 export했다. RSS 피드 라우트(app/feed.xml/route.ts)가 같은 함수를 import해서 쓰게 하니, 앞으로 또 입력 형식이 바뀌어도 고칠 곳이 한 곳으로 줄어든다.
결론
이 버그는 코드 리뷰나 타입 체크로는 못 잡는 종류였다 — 함수 시그니처도, 반환 타입도 그대로였고 컴파일도 통과했다. 바뀐 건 "본문 마크다운에 어떤 문자열이 섞여 들어오는가"라는, 코드 바깥의 암묵적인 전제뿐이었다. 돌이켜보면 이미지 블록 규칙을 추가한 날 바로 메타 디스크립션 렌더링 결과를 한 번만 확인했어도 바로 잡았을 문제다.
같은 입력을 다루는 소비자를 하나 더 추가하는 작업(이번엔 RSS 피드)이 기존 코드의 숨은 가정을 드러내는 계기가 되는 경우가 종종 있다. 비슷한 변환 함수가 있다면, 본문 포맷을 바꿀 때마다 "이 함수가 가정하고 있는 입력 모양이 아직 맞는가"를 짧게라도 되짚어보는 게 낫다.