마크다운 · 한국어 · CommonMark

한국어에서 마크다운 볼드가 깨지는 이유 — 범인은 조사다

167편 중 44편, 194곳에서 별표가 본문에 그대로 노출되고 있었다. 마크다운 파서는 정상 동작 중이었다.

블로그 본문을 훑다가 별표 두 개가 글에 그대로 찍혀 있는 걸 발견했습니다. 한두 곳이 아니었습니다. 전수 검사해 보니 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.

이 결함이 오래 살아 있었던 이유가 있습니다. 빌드는 성공하고, 파서는 에러를 내지 않고, 개발자는 원고를 읽지 결과 페이지를 정독하지 않습니다. 독자 눈에만 보입니다. 그리고 이런 게 널려 있으면 사이트 전체가 방치된 것처럼 읽힙니다. 렌더 결과를 세어서 확인하는 것 말고 다른 방법이 없습니다.

다른 제작 노트