블로그 본문을 훑다가 별표 두 개가 글에 그대로 찍혀 있는 걸 발견했습니다. 한두 곳이 아니었습니다. 전수 검사해 보니 167편 중 44편, 194곳이었습니다.
처음엔 원고에 오타가 있는 줄 알았습니다. 아니었습니다. 마크다운 파서는 규칙대로 동작하고 있었고, 문제는 한국어의 조사였습니다.
재현되는 최소 사례
이렇게 쓰면 깨집니다.
**요시다 공업(Yoshida Kogyo)**의 머리글자를 딴 것이다.
결과는 이렇습니다.
**요시다 공업(Yoshida Kogyo)**의 머리글자를 딴 것이다.
강조가 걸리기는커녕 별표가 그대로 남습니다. 반면 이건 멀쩡합니다.
**요시다 공업(Yoshida Kogyo)** 의 머리글자를 딴 것이다.
차이는 닫는 별표와 조사 사이의 공백 한 칸뿐입니다.
왜 그런가 — right-flanking 규칙
CommonMark는 별표가 강조를 닫을 수 있는지를 앞뒤 글자로 판정합니다. 닫는 구분자가 되려면 이른바 right-flanking 조건을 만족해야 하는데, 그중 문제가 되는 조항이 이겁니다.
구분자 바로 앞이 구두점이면, 바로 뒤는 공백이거나 구두점이어야 한다.
위 사례를 대입해 봅니다. 닫는 ** 바로 앞은 ) — 구두점입니다. 그러면 바로 뒤는 공백이나 구두점이어야 하는데, 실제로 오는 것은 의입니다. 한글은 공백도 구두점도 아닙니다. 조건이 깨지고, 파서는 이 별표를 "닫는 구분자가 아니다"라고 판정합니다. 열린 강조는 짝을 못 찾고, 별표는 리터럴 문자로 출력됩니다.
영어에서는 이 규칙이 거의 문제를 일으키지 않습니다. **bold**.처럼 닫는 별표 뒤에 구두점이나 공백이 오는 게 자연스럽기 때문입니다. 한국어는 정반대입니다. 괄호나 따옴표로 끝난 표현 뒤에 조사가 공백 없이 바로 붙는 것이 정상 문장입니다. **(주)한국**은, **"자유"**라는, **3.5%**의 — 전부 같은 이유로 깨집니다.
즉 이건 파서 버그가 아니라 영어 문장을 전제로 만들어진 규칙과 한국어 문법의 구조적 충돌입니다. 그래서 마크다운을 쓰는 한국어 사이트라면 어디서나 재현됩니다.
해결 — 파싱 전에 치환
규칙을 우회하려고 원고에 공백을 넣는 건 답이 아닙니다. 글 쓰는 사람이 파서 내부 규칙을 외우고 있어야 하고, 결과 문장에 어색한 공백이 남습니다.
파싱하기 전에 볼드 표기를 직접 태그로 바꾸는 쪽이 결정적입니다.
const fixCjkBold = (md) =>
md.replace(/\*\*([^*\n]+?)\*\*/g, "<strong>$1</strong>");
다만 이걸 그냥 적용하면 안 됩니다. 코드 블록과 인라인 코드 안의 별표까지 바꿔 버리기 때문입니다. 저는 적용 전에 원고 전체에 코드펜스와 백틱이 0건인 것을 확인하고 넣었습니다. 코드가 섞인 원고라면 코드 영역을 먼저 떼어 놓고 치환한 뒤 되돌려야 합니다.
검증은 렌더 결과에서
고쳤다고 믿는 대신 렌더된 HTML에서 별표 개수를 셌습니다. 194 → 0.
이 결함이 오래 살아 있었던 이유가 있습니다. 빌드는 성공하고, 파서는 에러를 내지 않고, 개발자는 원고를 읽지 결과 페이지를 정독하지 않습니다. 독자 눈에만 보입니다. 그리고 이런 게 널려 있으면 사이트 전체가 방치된 것처럼 읽힙니다. 렌더 결과를 세어서 확인하는 것 말고 다른 방법이 없습니다.