remark/rehype 플러그인으로 마크다운의 한계 넘기
이미지 캡션, 수식, CJK 볼드 처리하기
들어가며
블로그에 마크다운을 쓰다 보니 표준 문법만으로는 해결되지 않는 것들이 쌓였다. 이미지 캡션, 수식 렌더링, 한국어 볼드 깨짐까지 성격이 제각각이었다. remark/rehype 파이프라인에 플러그인을 끼워 넣어 이 문제들을 하나씩 해결했다.
remark/rehype
마크다운을 HTML로 변환하는 라이브러리는 marked, markdown-it 등 여러 가지가 있다. 이 블로그에서는 remark/rehype 조합을 사용했다.
블로그에 이미지를 넣다 보니 이미지 아래에 캡션을 달고 싶었다.

그런데 마크다운에는 표준 캡션 문법이 없다. 그래서 이미지 바로 다음 줄에 이탤릭(_캡션_)이 단독으로 오면 캡션으로 인식하는 규칙을 정했다. 이탤릭은 본문에서도 쓰이지만, 이미지 바로 뒤에 단독으로 오는 경우는 거의 캡션 용도뿐이라 충돌할 일이 없었다.
문제는 이 규칙을 어떻게 구현하느냐였다. 마크다운은 문자열이다. 문자열 상태에서 "이미지 뒤에 이탤릭이 오면 캡션으로 감싸라" 같은 조작을 하려면 정규식으로 패턴을 찾아야 한다. 그런데 마크다운에서는 이미지를 링크로 감쌀 수도 있고([](url)), 코드 블록 안에 이미지 문법을 그대로 적을 수도 있다.
코드 블록 안의 은 이미지가 아니라 설명을 위한 텍스트인데, 정규식은 이 둘을 구분하지 못한다. 이탤릭도 마찬가지다. 캡션용 이탤릭인지, 본문에서 강조하려고 쓴 이탤릭인지 정규식만으로는 문맥을 알 수 없다. 예외 케이스가 늘어날수록 정규식은 점점 복잡해지고 깨지기 쉬워진다.
결국 문자열 상태에서는 문맥을 알 수 없다는 게 근본적인 문제였다. 그래서 마크다운을 문자열이 아니라 트리 구조로 바꿔서 다루는 방법을 찾았다.

파서가 마크다운을 파싱하면서 "이건 이미지 노드", "이건 이탤릭 노드", "이건 코드 블록 안이니까 문법이 아니라 텍스트"를 미리 구분해둔다. 코드 블록 안의 은 이미지 노드가 아니라 텍스트 노드로 파싱되어 있으니까 애초에 걸리지 않는다. "이미지 노드를 찾아서 다음 노드가 이탤릭이면 figure로 감싸라"고만 하면 된다.
remark/rehype는 이 변환을 세 단계로 나눈다.
- remark: 마크다운 텍스트를 mdast(마크다운 트리)로 파싱한다. remark 플러그인들이 이 트리를 변환한다.
- remark-rehype: mdast를 hast(HTML 트리)로 변환하는 플러그인이다. mdast의 마크다운 개념을 HTML 개념으로 매핑한다.
heading+depth: 2는h2가 되고,emphasis는em이 되는 식이다. - rehype: hast를 받아 HTML 단계에서 추가 처리를 한다. rehype 플러그인들이 이 단계에서 동작한다.
두 단계로 나누는 이유는, 어떤 문제는 마크다운 단계에서 잡는 게 자연스럽고 어떤 문제는 HTML 단계에서 처리하는 게 자연스럽기 때문이다. CJK(중국어·일본어·한국어 문자) 볼드 보정은 마크다운 트리에서 텍스트 노드를 찾아 변환하는 게 맞고, 수식을 KaTeX HTML로 렌더링하는 건 HTML 트리에서 하는 게 맞다. 각 단계에 플러그인을 끼워넣을 수 있어서, 이런 문제들을 독립된 플러그인으로 분리할 수 있었다.
마크다운 텍스트가 파싱되어 AST가 되고, remark 플러그인들이 그 트리를 변환한 뒤 HTML AST로 넘어간다. rehype 플러그인들이 HTML 단계에서 추가 처리를 하고 최종 HTML 문자열로 직렬화된다.
이 파이프라인을 React에서 쓸 때는 react-markdown을 사용한다. 이 라이브러리가 내부적으로 remark/rehype 파이프라인을 돌리면서, 결과물을 React 컴포넌트로 렌더링해 준다. 현재 블로그의 플러그인 구성은 이렇다.
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath, remarkMediaCaption, remarkCjkBold]}
rehypePlugins={[rehypeRaw, rehypeSlug, rehypeKatex]}
components={{ /* 커스텀 컴포넌트 */ }}
>
{postData.content}
</ReactMarkdown>
각 플러그인이 어떤 문제를 해결하는지 하나씩 살펴보자.
remark, rehype의 플러그인
GFM
표준 마크다운(CommonMark, 마크다운 표준 스펙)은 생각보다 지원하는 문법이 적다. 테이블, 취소선, 체크박스 같은 것들은 표준에 없다. remarkGfm은 GFM(GitHub Flavored Markdown, GitHub에서 확장한 마크다운 스펙)을 추가해서 이런 문법들을 사용할 수 있게 한다.
| 문법 | 마크다운 | 결과 |
|---|---|---|
| 테이블 | | A | B | | 표 렌더링 |
| 취소선 | ~~텍스트~~ | |
| 작업 목록 | - [x] 완료 | 체크박스 |
| 자동 링크 | https://example.com | 클릭 가능한 링크 |
GFM 없이는 이런 것들을 전부 raw HTML로 작성해야 한다.
KaTeX 수식
기술 노트를 쓰다 보면 수학 수식이 필요할 때가 있다. 알고리즘의 시간 복잡도를 으로 표기하거나, 공식을 블록으로 보여주고 싶은 경우다. 처음에는 LaTeX를 떠올렸는데, LaTeX는 논문 조판용 시스템이라 웹에서 쓰기엔 너무 무겁다. KaTeX는 LaTeX 수식 문법을 브라우저에서 빠르게 렌더링하기 위해 만들어진 경량 라이브러리다. 문법은 거의 같으면서 훨씬 가볍다.
remarkMath가 $inline$과 $$block$$ 문법을 인식해서 AST 노드로 변환하고, rehypeKatex가 그 노드를 KaTeX로 렌더링한다.
두 플러그인이 remark 단계와 rehype 단계에서 각각 역할을 나눠 처리하는 구조다.
헤딩 ID와 Raw HTML
rehypeSlug는 ## 제목을 <h2 id="제목">제목</h2>으로 변환해서 목차(TOC) 클릭 시 해당 헤딩으로 스크롤할 수 있게 해준다. rehypeRaw는 마크다운 안에 <div class="callout"> 같은 raw HTML을 직접 쓸 수 있게 통과시켜 준다. 이 플러그인이 없으면 HTML 태그가 텍스트로 이스케이프되어 그대로 노출된다.
이미지 캡션 플러그인 (remarkMediaCaption)
앞서 설명한 이미지 캡션 규칙을 직접 구현한 플러그인이다. 이미지 뒤에 이탤릭이 단독으로 오면 <figure> + <figcaption>으로 자동 변환한다. 기존 오픈소스 플러그인에는 원하는 동작과 맞는 게 없어서 다른 플러그인과는 다르게 직접 만들게 되었다.
AST 변환 로직
remark 플러그인은 AST를 입력받아 AST를 반환하는 함수다. 마크다운이 파싱되면 각 요소가 노드로 표현된 트리 구조가 만들어지는데, 이 트리를 순회하면서 원하는 패턴을 찾아 변환하는 것이 플러그인의 역할이다.
const remarkMediaCaption: Plugin<[], Root> = () => {
return (tree: Root) => {
// 1. AST의 최상위 노드들 순회
for (let i = 0; i < tree.children.length; i++) {
const node = tree.children[i];
// 2. 이미지를 포함한 paragraph 탐색
if (!isImageParagraph(node)) continue;
// 3. 바로 다음 노드가 이탤릭(캡션)인지 확인
const nextNode = tree.children[i + 1];
const caption = isCaptionParagraph(nextNode);
// 4. 이미지 + 캡션을 <figure> HTML로 교체
const html = createFigureHtml(node, caption);
tree.children.splice(i, caption ? 2 : 1, { type: "html", value: html });
}
};
};
가독성을 위해 간략하게 작성했지만, 실제 코드는 연속 이미지 그리드나 비디오 처리도 포함한다.
tree.children은 AST의 최상위 노드 목록이다. 문단, 헤딩, 코드 블록 등이 이 배열에 하나씩 들어있다. 이 노드들을 순서대로 돌면서 이미지 paragraph를 찾고, 바로 다음 노드가 이탤릭이면 캡션으로 간주한다. 이미지와 캡션을 합쳐서 <figure> HTML로 교체하면 변환이 완료된다.
이미지 그리드
이미지를 두 장 넣어야 할 때가 있다. 세로로 나열하면 스크롤이 길어지고 비교가 어려운데, 한 행에 나란히 놓으면 훨씬 자연스럽다. 그래서 연속된 이미지가 2개 이상이면 자동으로 그리드 레이아웃으로 묶는 기능도 추가했다.
image-grid-2 클래스에 CSS Grid를 적용하면 2열 레이아웃이 되고, 이미지가 3개면 image-grid-3이 붙어서 3열이 된다. 마크다운에서 이미지를 연속으로 나열하기만 하면 나머지는 자동이다.
CJK 볼드 보정 플러그인 (remarkCjkBold)
한국어 노트를 작성하다 보면 **볼드** 처리가 안 되는 경우가 있었다. 모든 볼드가 깨지는 게 아니라 특정 패턴에서만 발생했다. **Observable**이라는은 잘 되는데 **연산자(operator)**를은 안 된다. 괄호 뒤에 한국어가 바로 오는 경우에만 깨지는 것 같았는데, 그 당시에는 정확한 원인을 모르고 넘겼었다.
CommonMark의 flanking delimiter 규칙
원인은 CommonMark 스펙에 있었다. CommonMark는 마크다운의 표준 스펙이고, remark의 파서도 이 스펙을 따른다. CommonMark 파서는 **를 볼드로 인식할 때 앞뒤 문자를 검사하는데, 이 검사 규칙을 flanking delimiter rule이라고 한다.
간단하게 말해서 여는 **는 왼쪽에, 닫는 **는 오른쪽에 인접한 문자가 특정 조건을 만족해야 한다. 닫는 **의 조건은 이렇다.
닫는
**바로 앞이)같은 구두점이면,**바로 뒤에 공백이나 구두점이 와야 한다.
영어에서는 단어 뒤에 공백이 자연스럽게 오기 때문에 이 조건에 걸릴 일이 거의 없다. 하지만 한국어에서는 조사가 바로 붙는다. **연산자(operator)**를처럼 닫는 ** 뒤에 "를", "을", "라고" 같은 조사가 공백 없이 오면 파서가 이걸 볼드의 끝으로 인식하지 못한다.
**연산자(operator)**를 제공한다. → )**를 → 볼드 실패
**Observable**이라는 단일 추상화 → e**이 → 볼드 성공 (앞이 알파벳)
**않는** 문제가 발생한다. → 는\*\* → 볼드 성공 (뒤에 공백)
결국 닫는 ** 앞이 ) + 뒤에 한글이 바로 오는 조합이 문제였다. **FOUC(Flash of Unstyled Content)**라고, **지각적 균일성(perceptual uniformity)**을 같은 패턴이 전부 해당된다.
AST 레벨에서 확인
실제로 파서가 **연산자(operator)**를을 어떻게 처리하는지 AST로 찍어봤다.

볼드가 제대로 인식되었다면 이렇게 되어야 한다.
{
"type": "paragraph",
"children": [
{ "type": "text", "value": "...조합하는 " },
{
"type": "strong",
"children": [{ "type": "text", "value": "연산자(operator)" }]
},
{ "type": "text", "value": "를 제공한다." }
]
}
**가 strong 노드로 분리되지 않고 텍스트 노드 안에 리터럴 문자열로 남아있었다. 파서 단계에서 이미 "이건 볼드가 아니다"라고 판단한 거라, 파서 자체를 고칠 수는 없었다. 대신 파서 이후 단계에서 후처리하는 방법을 찾아야 했다.
remarkCjkBold 플러그인 구현
파서 자체를 수정할 수는 없으니, 파서 이후 단계에서 후처리하는 방식을 찾았다. 텍스트 노드를 순회하면서 리터럴로 남아있는 **...** 패턴을 찾아 strong 노드로 변환하는 remark 플러그인을 만들었다.
const remarkCjkBold: Plugin<[], Root> = () => {
return (tree: Root) => {
visit(tree, "text", (node: Text, index, parent) => {
if (!parent || index === undefined) return;
// 텍스트 노드에 남아있는 **...** 패턴 탐색
const regex = /\*\*([^\s*](?:[^*]*[^\s*])?)\*\*/g;
const matches = [...node.value.matchAll(regex)];
if (matches.length === 0) return;
// 텍스트 노드를 분할해서 strong 노드 삽입
const newChildren: PhrasingContent[] = [];
let lastIndex = 0;
for (const match of matches) {
const startIdx = match.index!;
if (startIdx > lastIndex) {
newChildren.push({ type: "text", value: node.value.slice(lastIndex, startIdx) });
}
newChildren.push({
type: "strong",
children: [{ type: "text", value: match[1] }],
});
lastIndex = startIdx + match[0].length;
}
if (lastIndex < node.value.length) {
newChildren.push({ type: "text", value: node.value.slice(lastIndex) });
}
parent.children.splice(index, 1, ...newChildren);
});
};
};
앞서 만든 remarkMediaCaption과 같은 패턴이다. AST를 순회하면서 원하는 노드를 찾고, 새로운 노드로 교체한다. 커스텀 플러그인을 만드는 것이 거창해 보일 수 있지만, 결국 "트리에서 패턴 찾기 → 변환"이라는 동일한 구조의 반복이다.
기존 동작을 망가뜨리지 않을까 걱정했는데, 확인해보니 괜찮았다. 정상적으로 파싱된 볼드는 이미 strong 노드가 되어 있어서 텍스트 노드 안에 **가 남아있지 않다. 이 플러그인은 파서가 놓친 케이스, 즉 텍스트 노드 안에 리터럴로 남은 **...**만 잡아준다.
이 문제를 해결하다 보니 오픈소스로 플러그인을 배포하면 어떨까 싶었다. 내가 겪은 문제라면 분명 다른 사람도 겪었을 것 같아서 찾아보니 역시 remark-cjk-friendly라는 플러그인이 이미 있었다. CommonMark 이슈 #650에서 7년 넘게 논의되고 있는 문제이기도 하다.
remark-cjk-friendly는 파서 레벨에서 토크나이저 규칙 자체를 수정하는 방식이라, 내 플러그인처럼 후처리로 잡는 것보다 근본적인 해결이다. 내가 직접 만든 플러그인은 블로그에서 마주친 케이스는 잘 잡아주고 있지만, 모든 경우의 수를 커버하지는 못할 수 있다는 약간의 아쉬움이 들었다.
커스텀 컴포넌트
지금까지는 마크다운이 HTML로 변환되는 과정에 개입하는 플러그인을 살펴봤다. 하지만 변환된 HTML이 React 컴포넌트로 렌더링되는 마지막 단계에서도 커스터마이징할 수 있다. react-markdown의 components prop을 사용하면, 특정 HTML 요소를 기본 태그 대신 커스텀 React 컴포넌트로 대체할 수 있다.
이미지 모달
블로그 글에 포함된 스크린샷이나 다이어그램은 원본 크기로 보고 싶을 때가 있다. 이미지를 클릭하면 확대된 모달이 열리도록 img 태그를 커스텀 컴포넌트로 교체했다.
components={{
img: ({ src, alt, ...props }) => (
<img
src={src}
alt={alt || ""}
className="cursor-pointer transition-transform hover:scale-[1.02]"
onClick={() => handleImageClick(src || "", alt || "")}
/>
),
}}
마크다운에서 로 이미지를 삽입하면, 기본 <img> 태그 대신 클릭 이벤트가 달린 컴포넌트가 렌더링된다. 호버 시 살짝 커지는 효과를 넣어서 클릭 가능하다는 힌트를 준다.
테이블 래퍼
GFM으로 테이블을 쓸 수 있게 됐지만, 열이 많은 테이블은 모바일에서 화면을 넘친다. 테이블 자체는 건드리지 않고, 감싸는 div를 추가해서 가로 스크롤을 적용했다.
table: ({ children, ...props }) => (
<div className="table-wrapper">
<table {...props}>{children}</table>
</div>
),
table-wrapper에 overflow-x: auto를 적용하면 테이블이 넘칠 때만 스크롤바가 나타난다. 페이지 전체가 흔들리지 않고 테이블 영역만 스크롤된다.
외부 링크 처리
블로그 글에는 참고 자료나 공식 문서 링크를 자주 달게 된다. 외부 링크를 클릭했을 때 현재 탭에서 이동해버리면 읽던 글을 잃게 되니까, 외부 링크는 새 탭에서 열리도록 했다. 동시에 보안을 위해 rel="noopener noreferrer"도 자동으로 추가했다.
a: ({ href, children, ...props }) => {
const isExternal = href?.startsWith("http") && !href?.includes(SITE_URL);
return (
<a
href={href}
{...props}
{...(isExternal ? {
target: "_blank",
rel: "noopener noreferrer"
} : {})}
>
{children}
</a>
);
},
내부 링크(/posts/...)는 기존대로 같은 탭에서 이동하고, 외부 링크만 새 탭으로 열린다. 마크다운을 쓸 때 매번 target="_blank"를 수동으로 붙일 필요가 없다.
정리
처음에는 "이미지 캡션 하나 넣으려는데 이렇게까지 해야 하나" 싶었다. 하지만 문제가 하나씩 추가될 때마다 플러그인 구조의 가치를 체감했다. CJK 볼드 문제가 터졌을 때도 기존 파이프라인을 건드리지 않고 플러그인 하나를 추가하는 것만으로 해결할 수 있었다.
현재 파이프라인에 올라가 있는 플러그인들이다.
- GFM 확장 문법 지원
- 수학 수식 렌더링 (KaTeX)
- 이미지 캡션/그리드 자동 처리
- CJK 볼드 보정
- 헤딩 자동 id 부여 (TOC 연동)
- 코드 블록 Shiki 하이라이팅 (다음 편에서 다룸)