Markdown 파서 이해하기: CommonMark vs GFM, AST 변환, 플러그인 확장, MDX
이 글의 핵심
마크다운은 “가벼운 문법”이지만, 파서·표준·AST·플러그인 생태계를 이해해야 팀 문서·블로그·SSG에서 재현 가능하고 안전한 파이프라인을 설계할 수 있습니다. CommonMark/GFM의 차이, unified 생태계의 변환 단계, MDX의 컴파일 경로, 프로덕션에서의 보안·성능·일관성까지 심화 정리합니다.
마크다운 문법을 아는 것과, 마크다운을 HTML로 바꾸는 파이프라인을 설계하는 것은 다른 일입니다. 이 글은 파서가 무엇을 보장하는지, 중간 표현(AST)에서 무슨 일이 일어나는지, 확장은 어디에 끼워 넣는지, MDX가 왜 별도의 도구인지를 Node 생태계의 unified, remark, rehype를 기준으로 설명합니다. 개념은 다른 구현에도 그대로 적용됩니다.
파서 아키텍처: CommonMark와 GFM
왜 “마크다운” 하나로 부르기 어렵나
2004년에 나온 원래 마크다운 설명은 모호한 부분이 많아서, 파서마다 같은 문서에서 서로 다른 HTML을 만들었습니다. CommonMark는 이 문제를 풀기 위해 문법을 명세로 정리하고, 블록과 인라인 구조의 우선순위와 경계 사례를 수백 개의 예제로 고정한 표준입니다.
GFM(GitHub Flavored Markdown)은 CommonMark 위에 테이블, 취소선, 작업 목록, 확장 자동 링크 등을 추가한 방언입니다. 오늘날 “표준 마크다운”이라고 하면 대개 CommonMark에 몇 가지 확장을 더한 것을 가리킵니다.
전형적인 파싱 단계
대부분의 구현은 개념적으로 다음 순서를 따릅니다. 먼저 줄바꿈 정규화나 BOM 제거 같은 전처리를 하고, 문서를 줄 단위로 훑으며 문단, 제목, 목록, 인용, 코드 펜스, HTML 블록 같은 블록 구조를 확정합니다. 그다음 각 블록 안에서 강조, 링크, 코드 스팬, 이스케이프 같은 인라인 요소를 해석합니다. CommonMark 참조 구현은 강조 처리에 구분자 스택(delimiter stack) 알고리즘을 씁니다. 결과는 추상 구문 트리(AST)로 만들어지고, 마지막에 HTML이나 React 트리 같은 출력 형식으로 직렬화됩니다.
같은 .md 파일이라도 CommonMark만 쓰는지, GFM을 켰는지, 수식 확장까지 켰는지에 따라 결과 HTML이 달라집니다. 그래서 문서 파이프라인에서는 파서와 버전, 활성화한 확장 목록을 명시해 두어야 합니다.
블록 파싱이 인라인 파싱보다 먼저 일어난다는 순서는 실제 버그의 원인이 되곤 합니다. 예를 들어 줄 앞에 공백 네 칸을 두면 인라인 문법이 전혀 해석되지 않는 들여쓴 코드 블록이 되는데, 목록 안에 문단을 이어 쓰려다 들여쓰기를 한 칸 더 넣은 것만으로 문단이 코드 블록으로 바뀌어 버립니다. 또 CommonMark에는 “게으른 연속(lazy continuation)” 규칙이 있어서, 인용(>)이나 목록 문단 바로 다음 줄에 마커 없이 텍스트를 쓰면 그 줄이 앞 블록에 이어 붙습니다. 인용문 뒤에 빈 줄 없이 본문을 이어 쓰면 본문 첫 줄이 인용 안으로 들어가는 현상이 이 규칙 때문입니다. “왜 여기서 이렇게 렌더링되지?” 싶은 문제의 상당수가 빈 줄 하나로 해결되는 이유도 빈 줄이 블록 경계를 확정하기 때문입니다.
CommonMark가 고정하는 것
CommonMark는 빈 줄, 들여쓰기, 목록 마커에 따라 블록이 언제 시작하고 끝나는지, 백틱과 별표와 꺾쇠의 이스케이프 규칙, 원시 HTML 블록으로 인식되는 범위를 정합니다. 원시 HTML을 실제로 출력할지, 걸러낼지는 표준이 아니라 이후 단계의 설정에 달려 있습니다.
강조 규칙은 *와 _가 강조를 여닫을 수 있는 조건을 “왼쪽/오른쪽에 붙은 구분자(left/right-flanking)” 규칙으로 정합니다. 그래서 snake_case_name의 밑줄은 단어 중간이라 강조가 되지 않지만, *는 단어 중간에서도 강조를 만듭니다. 오래된 파서에서 변수명 일부가 기울임꼴로 바뀌던 문제를 CommonMark가 정리한 부분입니다.
순서 목록은 3.으로 시작하면 <ol start="3">이 되고, 이후 항목 번호는 실제로 쓴 숫자와 관계없이 순서대로 매겨집니다. 또 문단 중간 줄이 2023. 연도 같은 숫자로 시작해도 목록으로 바뀌지 않도록, 문단을 끊고 순서 목록을 시작하려면 번호가 1.이어야 한다는 제약이 있습니다.
GFM이 추가하는 것
GFM은 파이프(|)로 열을 나누고 :---로 정렬을 지정하는 테이블, ~~텍스트~~ 취소선, - [ ]와 - [x] 형태의 작업 목록(HTML에서는 비활성 체크박스로 출력), www.나 https://로 시작하는 URL과 이메일 주소의 자동 링크를 추가합니다.
GFM 테이블에서 가장 자주 부딪히는 문제는 셀 안의 파이프 문자입니다. 테이블 행을 나누는 작업이 인라인 파싱보다 먼저 일어나므로, 셀 안에 `a | b`처럼 코드 스팬으로 감싸도 |가 칸 구분자로 해석되어 열이 하나 늘어납니다. 셀 안의 파이프는 \|로 이스케이프해야 합니다. 또 테이블 바로 다음 줄에 빈 줄 없이 쓴 텍스트는 테이블의 마지막 행으로 흡수됩니다. 테이블 앞뒤에 항상 빈 줄을 두는 규칙을 린트로 강제하면 이런 사고를 막을 수 있습니다.
AST 구조와 변환
AST를 두는 이유
마크다운을 곧바로 HTML 문자열로 바꿀 수도 있지만, 대부분의 도구는 중간에 AST를 둡니다. 트리 상태에서 제목에 ID를 붙이거나 외부 링크에 rel을 추가하는 등의 변환을 하고, 같은 트리에서 HTML, React 트리, 다른 출력 형식으로 분기할 수 있으며, 이미지의 alt 누락 같은 규칙을 린트로 검사하거나 위험한 노드를 제거한 뒤 출력할 수 있기 때문입니다. 문자열을 정규식으로 고치는 것보다 구조를 알고 고치는 쪽이 훨씬 안전합니다.
mdast와 hast
unified 생태계는 두 종류의 트리를 구분합니다. mdast(Markdown AST)는 heading, paragraph, code, link처럼 마크다운의 의미 단위로 된 트리이고, hast(HTML AST)는 element, text와 properties로 된 HTML 구조 트리입니다. 처리 흐름은 보통 마크다운 텍스트를 remark로 파싱해 mdast를 만들고, mdast를 hast로 바꾼 뒤 rehype 플러그인으로 가공하고, 마지막에 HTML 문자열로 직렬화하는 순서입니다.
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import rehypeSanitize from 'rehype-sanitize';
import rehypeStringify from 'rehype-stringify';
const html = String(
await unified()
.use(remarkParse) // markdown → mdast
.use(remarkGfm) // 테이블·취소선·작업 목록 문법 추가
.use(remarkRehype) // mdast → hast
.use(rehypeSanitize) // 허용 목록 밖의 요소·속성 제거
.use(rehypeStringify) // hast → HTML 문자열
.process('# 제목\n\n| a | b |\n|---|---|\n| 1 | 2 |')
);
이 체인에서는 순서가 곧 의미입니다. remarkGfm은 파서 확장이라 remarkParse와 함께 파싱 단계에 관여하고, remarkRehype 앞에 둔 플러그인은 mdast를, 뒤에 둔 플러그인은 hast를 받습니다. rehype 플러그인을 remark 쪽에 잘못 두면 기대하는 노드 타입이 없어 아무 일도 하지 않고 조용히 넘어가는 경우가 많아, “플러그인을 추가했는데 효과가 없다”는 증상의 흔한 원인이 됩니다.
자주 놓치는 기본값도 있습니다. remark-rehype는 마크다운 안의 원시 HTML을 기본적으로 버립니다. 원시 HTML을 살리려면 remarkRehype에 { allowDangerousHtml: true }를 주고, 그 HTML 문자열을 실제 hast 요소로 파싱하는 rehype-raw를 추가해야 합니다. 원시 HTML을 허용하는 순간 XSS 위험도 함께 들어오므로, rehype-sanitize는 반드시 rehype-raw 뒤에 두어야 합니다. 순서를 반대로 두면 sanitize가 실행되는 시점에 원시 HTML이 아직 요소로 파싱되지 않은 상태라 제대로 검사할 수 없습니다.
변환 단계에서 자주 하는 작업
remark 플러그인으로는 제목 텍스트에서 id를 계산해 붙이거나 상대 링크를 절대 경로로 바꾸는 작업을, rehype 플러그인으로는 pre > code의 클래스 이름 규칙을 맞추거나 외부 링크에 target과 rel을 붙이는 작업을 주로 합니다. 마크다운 의미에 관한 작업은 mdast에서, 최종 HTML 모양에 관한 작업은 hast에서 하는 것이 자연스럽습니다.
AST를 다룰 때의 함정
노드에는 원문 위치를 담은 position 정보가 있고, 린트 경고의 줄 번호와 에러 메시지가 이를 기준으로 합니다. 플러그인이 노드를 새로 만들거나 복제하면서 position을 옮기지 않으면 이후 경고의 위치가 비거나 틀어집니다.
원시 HTML은 hast로 파싱되면서 HTML 파서 규칙에 따라 구조가 바뀔 수 있습니다. 예를 들어 <p> 안에 블록 요소를 넣으면 브라우저와 같은 방식으로 문단이 끊겨, 작성자가 의도한 부모-자식 관계와 다른 트리가 됩니다.
unist-util-visit으로 순회하면서 현재 노드를 삭제하거나 형제를 끼워 넣으면 인덱스가 밀려 노드를 건너뛰거나 같은 노드를 두 번 방문합니다. 방문자 함수에서 [SKIP, index]처럼 다음에 볼 위치를 돌려주거나, 바꿀 노드를 모아 두었다가 순회가 끝난 뒤 한 번에 바꾸는 방식이 안전합니다.
import { visit } from 'unist-util-visit';
// 외부 링크에 rel="noopener noreferrer"를 붙이는 rehype 플러그인
export default function rehypeExternalLinks() {
return (tree) => {
visit(tree, 'element', (node) => {
if (node.tagName !== 'a') return;
const href = String(node.properties?.href ?? '');
if (/^https?:\/\//.test(href)) {
node.properties.rel = ['noopener', 'noreferrer'];
node.properties.target = '_blank';
}
});
};
}
플러그인은 옵션을 받아 변환 함수(transformer)를 돌려주는 함수라는 형태를 따릅니다. hast에서 rel을 공백으로 구분된 문자열이 아니라 배열로 다루는 것처럼, mdast와 hast 각각의 노드 스펙을 알아야 올바른 출력이 나옵니다. 이 예제는 속성만 바꾸고 트리 구조는 건드리지 않으므로 순회 중 변경 문제가 생기지 않습니다.
확장 메커니즘
문법 확장과 변환 확장
확장은 크게 두 갈래입니다. 첫째는 GFM, front matter, 각주, 수식처럼 문법 자체를 인식하게 만드는 파서 수준 확장입니다. unified 생태계에서는 저수준 토크나이저인 micromark에 문법 확장을 붙이고, 그 토큰을 mdast 노드로 바꾸는 mdast-util-* 확장을 짝지어 구성합니다. 둘째는 트리가 만들어진 뒤 노드를 조작해 기능을 얹는 변환 확장입니다. 모든 이미지를 figure로 감싸는 플러그인이 여기에 해당합니다.
둘 다 “플러그인”이라 부르지만 실패하는 방식이 다릅니다. 문법 확장은 기존 블록·인라인 규칙과 맞물리는 경계 사례에서 문제가 생기고, 변환 확장은 노드 타입이나 구조에 대한 가정이 깨질 때 런타임 오류가 나거나 조용히 아무것도 하지 않습니다.
커스텀 디렉티브
일부 팀은 :::note 같은 디렉티브로 안내 상자를 만듭니다. 이는 remark-directive처럼 파서 확장으로 새 블록을 인식하게 하거나, 파싱 전에 문자열을 치환하거나, 파싱 후 특수 노드를 일반 노드로 바꾸는 방식으로 구현할 수 있습니다. 문자열 치환은 코드 블록 안의 텍스트까지 바꿔 버리는 문제가 있으므로 파서 확장 방식이 안전합니다. 참여자가 많은 문서라면 팀이 공식적으로 지원하는 확장 목록을 정해 두고, 개인별 전처리 스크립트가 늘어나지 않게 하는 편이 유지보수에 유리합니다.
MDX 내부
MDX가 해결하려는 문제
MDX는 문서와 컴포넌트를 한 파일에서 결합하려는 요구에서 나왔습니다. 마크다운을 HTML로 바꾸는 데서 끝나지 않고, 같은 파일에 JSX와 import/export를 두고 빌드 시 자바스크립트 모듈로 컴파일합니다.
컴파일 경로
MDX 3 기준으로 개념적인 흐름은 다음과 같습니다. 먼저 마크다운과 JSX를 함께 이해하는 파서가 문서를 읽고, JSX와 표현식 부분은 자바스크립트 파서(acorn)로 해석합니다. 결과 트리는 mdast에 MDX 전용 노드(mdxJsxFlowElement, mdxjsEsm 등)가 섞인 형태이며, 표현식은 ESTree 형태로 함께 들어 있습니다. 이 트리가 hast를 거쳐 자바스크립트 코드(_jsx 호출로 이루어진 컴포넌트 함수)로 바뀌고, 번들러(Vite, esbuild, webpack)가 이 모듈을 다른 모듈처럼 처리합니다.
MDX에서 신경 써야 할 점
MDX 파일에서 쓰는 컴포넌트는 파일 안에서 import하거나, 프레임워크가 제공하는 방식(Astro의 components prop, Next.js의 mdx-components 파일 등)으로 주입해야 합니다. 어느 쪽인지 정해 두지 않으면 빌드는 되는데 렌더링 시 컴포넌트가 undefined라는 오류가 납니다.
MDX의 표현식과 import는 빌드 시 실제로 실행되는 코드입니다. 문서가 사실상 프로그램이 되므로, 외부 기여자가 작성한 MDX를 리뷰 없이 빌드에 넣으면 빌드 환경에서 임의 코드가 실행될 수 있습니다.
문법도 더 엄격합니다. MDX 2 이후로는 본문의 <와 {가 각각 JSX 태그와 표현식의 시작으로 해석됩니다. a < b 같은 비교식이나 중괄호를 설명하는 문장을 그대로 쓰면 Could not parse expression with acorn이나 Unexpected character 같은 컴파일 에러가 나므로 \<, \{로 이스케이프하거나 코드 스팬 안에 넣어야 합니다. HTML 주석(<!-- -->)도 지원하지 않아 {/* 주석 */}으로 바꿔야 하고, <br>처럼 닫지 않는 태그도 JSX 규칙에 따라 <br />로 써야 합니다.
기존 .md 문서 수백 개를 MDX로 일괄 전환할 때 흔히 겪는 문제가 바로 이 부분입니다. 확장자만 바꿨는데 문서의 상당수가 위 규칙 중 하나에 걸려 빌드가 실패합니다. 그래서 컴포넌트가 실제로 필요한 문서만 .mdx로 바꾸고 나머지는 .md로 남겨 두는 편이 대부분의 경우 비용이 적게 듭니다. 인터랙티브 데모나 디자인 시스템 문서처럼 컴포넌트가 본질인 문서는 MDX가, 순수한 글은 remark/rehype만 쓰는 쪽이 공격 면적과 유지보수 부담 모두 작습니다.
프로덕션 마크다운 파이프라인
재현 가능한 빌드
remark, rehype, micromark 계열 패키지는 메이저 버전마다 플러그인 API와 기본 동작이 바뀐 이력이 있으므로 락 파일로 버전을 고정합니다. 어떤 확장을 켰는지는 README 설명이 아니라 실제 설정 파일 하나에 코드로 두어, 로컬과 CI와 배포 환경이 같은 프리셋을 쓰게 합니다.
보안
사용자 입력을 렌더링하지 않더라도, 서드파티 플러그인이 script나 이벤트 속성을 만들어 낼 수 있는지는 검토해야 합니다. rehype-sanitize로 허용할 태그와 속성을 목록으로 제한하고, style과 on* 이벤트 속성은 특히 주의합니다. iframe 임베드를 허용한다면 src 도메인을 허용 목록으로 제한합니다.
성능
플러그인마다 트리를 한 번씩 순회하므로 체인이 길어질수록 빌드 시간이 늘어납니다. 그중 구문 강조(Shiki, Prism)는 코드 블록마다 문법 규칙을 적용하므로 가장 비싼 단계인 경우가 많습니다. 언어 정의와 테마를 파일마다 새로 불러오지 않고 한 번만 초기화해 재사용하는지 확인하는 것이 첫 번째 점검 대상입니다. 수천 개의 파일을 처리하는 정적 사이트 생성기에서는 변경된 파일만 다시 처리하는 캐시가 있는지도 빌드 시간에 큰 영향을 줍니다.
린트와 이식성
remark-lint로 제목 레벨 건너뛰기 금지, 목록 마커 통일 같은 규칙을 강제하고, 깨진 링크 검사도 함께 돌릴 수 있습니다. 용어 표기나 대소문자 규칙처럼 자동화하기 어려운 부분은 리뷰에서 다룹니다. 특정 위키나 플랫폼 전용 문법을 많이 쓰면 다른 도구로 옮길 때 비용이 커지므로, 오래 유지할 문서는 CommonMark에 꼭 필요한 확장 몇 개만 더한 것을 기준선으로 두는 편이 안전합니다.