CommonMark vs GitHub Flavored Markdown: 실제로 무엇이 다른가
Markdown은 2004년의 Perl 스크립트와 느슨하게 쓰인 문법 페이지에서 출발했고, 그래서 10년 동안 구현체마다 엣지 케이스 해석이 달랐습니다. CommonMark(2014)가 이를 바로잡았습니다 — 650개 이상의 번호 매겨진 예제가 곧 적합성 테스트 스위트 역할을 하는, 모호함 없는 명세입니다. 오늘날 두 렌더러의 결과가 다르면 CommonMark 스펙이 판정 기준입니다.
GitHub Flavored Markdown(GFM)은 CommonMark의 공식 상위집합(superset)입니다. 추가되는 확장은 정확히 다섯 개 — 테이블, 태스크 리스트 항목, 물결표 두 개(~~)를 쓰는 취소선, 맨몸 URL 자동 링크, 그리고 위험한 raw HTML 태그(script, title, iframe, style 등)를 제거하는 tagfilter입니다. GitHub에서 보이는 나머지 기능들 — [!NOTE] 같은 알림 인용구, Mermaid 다이어그램, 각주, 이모지 숏코드 — 은 GFM 스펙이 아니라 GitHub 제품 차원의 동작이며, npm, GitLab, 대부분의 정적 사이트 생성기에서는 렌더링되지 않습니다.
실무에서 문제 되는 차이: CommonMark는 꺾쇠괄호로 감싼 URL만 자동 링크하지만 GFM은 www.나 https://로 시작하는 맨몸 텍스트도 링크로 만듭니다. GFM 테이블은 헤더 행 아래에 대시로 된 구분 행이 반드시 필요하고, 셀 안의 파이프 문자는 백슬래시로 이스케이프해야 합니다. 태스크 리스트 체크박스는 리스트 항목의 맨 앞에 올 때만 동작합니다. 여러 플랫폼에서 동일하게 렌더링돼야 하는 문서라면 CommonMark 코어 안에 머무르고, 모든 확장 기능 사용을 이식성에 대한 명시적 결정으로 취급하세요.
GFM extensions (will NOT render in strict CommonMark):
| Flag | Meaning |
|------|---------|
| -v | verbose |
- [x] ship parser
- [ ] write docs
~~deprecated~~ www.example.com <- GFM autolinks bare URLs
CommonMark-portable equivalents:
<https://www.example.com> <- autolink works everywhere
Cell with \| escaped pipe <- required inside GFM tables강조, 리스트, 줄바꿈: 모두가 걸려 넘어지는 규칙들
강조 문법은 사소해 보이지만 실제로는 CommonMark 스펙에서 가장 복잡한 부분입니다 — delimiter-run 규칙만 몇 페이지에 달합니다. 실무 요점: 언더스코어는 단어 내부에서 강조를 만들지 않으므로 snake_case_names는 그대로 살아남지만, 애스터리스크는 만듭니다(a*b*c는 b를 강조). 애스터리스크 세 개는 바깥에서 안쪽으로 굵게+기울임을 중첩합니다. 강조가 렌더링되지 않는다면 원인은 거의 항상 구분자의 잘못된 쪽에 있는 공백입니다 — 여는 애스터리스크 뒤에 공백이 오면 left-flanking이 아니어서 문자 그대로 남습니다.
리스트에는 유명한 함정이 둘 있습니다. 첫째, 들여쓰기는 상대적입니다. 중첩 항목은 고정된 4칸이 아니라 부모의 첫 콘텐츠 열에 맞춰 들여써야 합니다. 부모 마커가 '1. '이면 자식은 3열에서 시작하지만 '10. '이면 4열로 밀립니다. 둘째, loose vs tight 리스트: 어느 두 항목 사이든 빈 줄이 하나라도 있으면 모든 항목이 문단 태그로 감싸져 세로 간격이 눈에 띄게 벌어집니다 — "리스트 간격이 왜 들쭉날쭉하지?"의 가장 흔한 원인입니다.
순서 있는 리스트는 첫 번째 숫자만 존중합니다. 7, 8, 9로 쓰면 7에서 시작하는 리스트가 되고 이후 숫자는 무시됩니다. 줄 맨 앞의 연도 뒤에 마침표가 오면('2026. 그 해에는...') 조용히 순서 있는 리스트로 변합니다 — 마침표를 백슬래시로 이스케이프하세요.
줄바꿈: 개행 하나는 soft break이며 파일에서는 공백으로 렌더링됩니다. 강제 줄바꿈(hard break)을 원하면 줄 끝에 공백 두 개 또는 백슬래시를 붙이세요. GitHub 댓글과 이슈는 개행 하나를 hard break로 렌더링하지만 README 파일은 그렇지 않습니다 — 같은 문법, 다른 출력, 끝없는 혼란의 원천입니다.
snake_case_name -> no emphasis (intraword underscore)
a*b*c -> a<em>b</em>c (asterisk works intraword)
* not italic * -> literal (space after opening delimiter)
7. seven
8. eight -> renders as a list starting at 7
2026\. was a good year -> escaped: stays a paragraph
line one·· (two trailing spaces = hard break)
line two\
line three (backslash = hard break, survives autoformat)정규식 기반 Markdown 변환기가 깨지는 이유 (그리고 진짜 파서의 동작 방식)
문자열 치환을 이어 붙이면 헤딩과 굵은 글씨 정도는 변환할 수 있지만, Markdown은 정규 언어가 아닙니다 — 구문 요소가 임의로 중첩됩니다. 코드 스팬 안의 애스터리스크는 강조가 되면 안 되고, 펜스드 코드 블록 안의 #으로 시작하는 줄은 헤딩이 되면 안 되며, 인용구 안에 리스트가, 그 안에 또 인용구가 들어갈 수 있습니다. 정규식 파이프라인은 이것들을 고정된 순서로 처리하므로 반드시 한 구문이 다른 구문을 망가뜨리게 됩니다. 빠른 미리보기에는 괜찮지만, 게시할 콘텐츠에는 위험합니다.
스펙을 준수하는 파서는 CommonMark 스펙의 파싱 전략 부록에 기술된 대로 2단계로 동작합니다. 먼저 문서를 블록 트리(문단, 리스트, 인용, 코드 펜스)로 분해하고, 그다음 인라인 파싱을 허용된 블록 안에서만 수행합니다. 코드 펜스 안에 Markdown처럼 생긴 텍스트를 안전하게 넣을 수 있는 이유가 바로 이것입니다 — 인라인 규칙이 그 안에 들어가지 않기 때문입니다.
JavaScript 생태계의 주류 선택지: markdown-it(빠르고 플러그인이 풍부하며 CommonMark 준수), micromark + remark(가장 작은 정확한 코어 + AST 생태계, MDX의 엔진), marked(가장 오래되고 빠르지만 역사적으로 엣지 케이스에 느슨함). C 레퍼런스 구현인 cmark는 초당 수 메가바이트를 파싱하며 스펙 테스트가 이것을 기준으로 만들어졌습니다.
무엇을 고르든 스펙의 예제 스위트로 한 번 검증해 보세요. 스스로 Markdown 파서라 부르는 라이브러리 중 놀랄 만큼 많은 수가 650여 개 적합성 케이스에서 수십 개씩 실패합니다 — 그리고 그 실패 지점이 바로 사용자들이 언젠가 입력할 엣지 케이스입니다.
Raw HTML과 XSS: 렌더링 전에 반드시 새니타이즈
Markdown은 설계상 raw HTML을 그대로 통과시킵니다 — 그리고 그 유연함이 사용자 제공 Markdown을 렌더링할 때 가장 큰 보안 위험입니다. onerror 속성이 달린 img 태그는 JavaScript를 실행합니다. 완벽히 유효한 Markdown 링크가 javascript:나 data: URL을 가리킬 수 있습니다. HTML 블록조차 필요 없습니다 — 인라인 구문만으로 충분합니다.
렌더러의 HTML 출력을 신뢰하지 마세요. 방어는 3중입니다. 첫째, 필요 없을 때는 파서에서 raw HTML을 끄세요(markdown-it: html을 false로; remark 계열이라면 rehype-raw를 아예 추가하지 않기). 둘째, 링크와 이미지 목적지를 스킴 허용 목록(http, https, mailto)으로 제한하세요 — 파서는 javascript: 목적지도 문법적으로 유효한 Markdown으로 봅니다. 셋째, 최종 HTML을 DOMPurify 같은 제대로 된 새니타이저로 정화하세요. 직접 만든 태그 제거 정규식은 중첩되거나 변형된 태그로 어김없이 우회됩니다.
GitHub의 파이프라인이 좋은 본보기입니다. GFM을 렌더링하고, tagfilter 확장을 적용해(script, iframe, style 등 위험 태그 제거) 속성 허용 목록 기반 새니타이저를 거친 뒤에야 페이지에 도달합니다. 순서에 주목하세요 — 렌더링 후에 새니타이즈해야 합니다. Markdown 문법 자체가 소스에는 보이지 않던 HTML을 만들어낼 수 있기 때문입니다.
마지막 함정 하나: 신뢰할 수 없는 Markdown을 본인 브라우저에서 미리보기하면 그 안의 HTML이 실행됩니다. 샌드박스는 게시 결과물만이 아니라 미리보기 환경에도 있어야 합니다.
<!-- all of these are "valid Markdown" from a parser's view -->
<img src=x onerror=alert(document.cookie)>
[click me](javascript:alert(1))
[harmless](data:text/html;base64,PHNjcmlwdD4uLi48L3NjcmlwdD4=)
// safe rendering pipeline
const md = markdownit({ html: false, linkify: true });
const dirty = md.render(userInput);
const clean = DOMPurify.sanitize(dirty, {
ALLOWED_URI_REGEXP: /^(https?|mailto):/i
});
container.innerHTML = clean;이식 가능한 Markdown 쓰기: 하나의 소스, 여러 렌더러
같은 .md 파일이 GitHub(GFM), 정적 사이트 생성기, npm 레지스트리 페이지, IDE 미리보기, pandoc에서 렌더링될 수 있고 — 각각 엔진이 다릅니다. Hugo는 Goldmark(CommonMark 준수)를 쓰고, Jekyll의 기본은 kramdown인데 이건 CommonMark가 아니며 중괄호 속성 리스트 같은 자체 문법을 추가합니다. pandoc에는 선택형 확장이 수십 개 있습니다. 트리플 대시 사이의 YAML 프런트매터는 아예 Markdown이 아닙니다 — 이를 모르는 렌더러는 페이지 상단에 테이블이나 원문 텍스트로 출력해 버립니다.
이득이 되는 이식성 규칙: Setext 밑줄 방식보다 ATX 헤딩(# 기호)을 쓰세요. 4칸 들여쓰기 대신 언어 태그를 명시한 펜스드 코드 블록을 항상 사용하세요 — 들여쓰기 코드는 언어를 표현할 수 없고, 들여쓰기가 모호해 리스트와 끔찍하게 충돌합니다. 모든 블록 요소(리스트, 테이블, 펜스) 앞뒤에 빈 줄을 두세요. lazy continuation 규칙이 엔진마다 다르기 때문입니다. 줄 끝 공백 두 개로 만드는 hard break에 의존하지 마세요 — 에디터와 포매터가 저장할 때 곧잘 제거합니다. 대신 백슬래시를 쓰세요.
수명이 긴 문서라면 한 문장당 한 줄(one-sentence-per-line) 방식을 고려할 가치가 있습니다. git diff를 문장 단위로 리뷰할 수 있게 해 주고, soft break는 공백으로 접히므로 렌더링 결과는 동일하며, 단어 하나 바꿨다고 문단 전체가 재줄바꿈되어 blame 이력을 오염시키는 문제를 피할 수 있습니다.
---
title: parsed as front matter by Hugo/Jekyll,
printed as text (or a table!) by others
---
Portable Fragile
-------- -------
# ATX heading Setext heading underlined with ===
fenced block + language 4-space indented code (no language)
break with backslash \ break with two trailing spaces
blank line before a list list glued to the previous paragraph