Engineering

Markdown 한 줄이 화면이 되기까지

글은 Markdown 텍스트로 저장됩니다. 이걸 어떻게 안전한 HTML로, \ 그것도 빌드 타임에 바꾸는지 unified 파이프라인을 해부합니다. 왜 직접 파이프라인을 짰나 react-markdown 하나 붙이면 끝 아닌가 싶지만, 블로그 본문에는…

M
Mason
Software Engineer · · 8분

글은 Markdown 텍스트로 저장됩니다. 이걸 어떻게 안전한 HTML로,
그것도 빌드 타임에 바꾸는지 unified 파이프라인을 해부합니다.

왜 직접 파이프라인을 짰나

react-markdown 하나 붙이면 끝 아닌가 싶지만, 블로그 본문에는 생각보다 요구사항이 많습니다.

  • 코드 블록에 문법 강조가 들어가야 합니다.

  • 헤딩마다 앵커(id)가 있어야 목차와 딥링크가 동작합니다.

  • 이미지에 캡션을 달면 <figure>로 예쁘게 렌더링되어야 합니다.

  • 예전에 HTML로 저장된 글도 깨지지 않아야 합니다.

  • 그리고 이 모든 게 클라이언트 비용 없이 빌드 타임에 끝나야 합니다.

그래서 unified(remark/rehype) 기반으로 파이프라인을 직접 구성했습니다.

전체 파이프라인 한눈에 보기

Markdown 문자열 \
→ remarkParse (Markdown → mdast 구문 트리) \
→ remarkGfm (표, 체크박스, 자동 링크) \
→ remarkRehype (mdast → hast, HTML 트리로 변환) \
→ rehypeRaw (글 속 raw HTML 흡수) \
→ rehypeImageFigure(이미지+캡션 →
 
) ← 커스텀 플러그인 \
→ rehypeSlug (헤딩에 id 부여) \
→ rehypeAutolinkHeadings (헤딩을 앵커 링크로 감싸기) \
→ rehypePrettyCode (Shiki로 코드 색칠) \
→ rehypeStringify (hast → HTML 문자열) \
HTML 문자열
 

코드로는 이렇게 한 번에 선언됩니다.

// src/lib/markdown.ts \
const processor = unified() \
.use(remarkParse) \
.use(remarkGfm) \
.use(remarkRehype, { allowDangerousHtml: true }) \
.use(rehypeRaw) \
.use(rehypeImageFigure) \
.use(rehypeSlug) \
.use(rehypeAutolinkHeadings, { behavior: "wrap" }) \
.use(rehypePrettyCode, { theme: "github-dark", keepBackground: false }) \
.use(rehypeStringify);
 

remark와 rehype, 무엇이 다른가

헷갈리기 쉬운데, 둘은 다루는 트리가 다릅니다.

  • remarkmdast(Markdown 추상 구문 트리)를 다룹니다. "이건 제목, 이건 목록, 이건 코드 블록" 같은 Markdown 관점입니다.

  • rehypehast(HTML 추상 구문 트리)를 다룹니다. "이건 <h2>, 이건 <ul>" 같은 HTML 관점입니다.

  • 둘을 잇는 다리가 remarkRehype입니다. 여기서 Markdown 세계가 HTML 세계로 넘어갑니다.

그래서 플러그인을 끼울 때 "지금 내가 다루는 게 Markdown 구조인가, HTML 구조인가"를 기준으로 remark 쪽인지 rehype 쪽인지 정하면 됩니다.

보안에 대한 솔직한 선택

눈썰미가 좋다면 allowDangerousHtml: truerehypeRaw가 걸렸을 겁니다. 이름 그대로, 글 안의 raw HTML을 그대로 살려서 렌더링합니다. 정화(sanitize) 단계가 없습니다.

// 글 속 raw HTML을 보존 — 정화 단계는 일부러 생략 \
.use(remarkRehype, { allowDangerousHtml: true }) \
.use(rehypeRaw)
 

위험해 보이지만, 이 선택에는 전제가 있습니다. 글을 쓸 수 있는 사람은 관리자뿐이라는 것입니다. 글쓰기는 RLS로 막혀 있어 외부의 신뢰할 수 없는 입력이 본문에 들어올 수 없습니다. 덕분에 예전에 HTML로 저장된 글도 그대로 렌더링됩니다.

만약 외부 작성자에게 글쓰기를 열게 된다면, rehypeRaw 다음에 rehype-sanitize를 한 줄 추가하면 됩니다. 그 전까지는 불필요한 비용을 지지 않는 선택입니다.

커스텀 플러그인: 이미지에 캡션 달기

에디터에서는 이미지에 캡션을 달면 ![캡션](url) 형태로 저장됩니다. 그런데 이걸 그냥 렌더링하면 캡션이 본문 텍스트처럼 보입니다. 그래서 "문단 안에 이미지 하나만 있고 alt(캡션)가 있으면 <figure> + <figcaption>으로 바꾸는" 플러그인을 직접 만들었습니다.

// src/lib/markdown.ts (발췌) — hast 트리를 순회하며 변환 \
const rehypeImageFigure: Plugin = () => (tree) => { \
const toFigure = (node) => { \
if (node.tagName !== "p" || !node.children) return null;
 
// 공백을 뺀 "의미 있는" 자식이 이미지 하나뿐인 문단만 대상으로 \
const meaningful = node.children.filter( \
(c) => !(c.type === "text" && !(c.value ?? "").trim()), \
); \
if (meaningful.length !== 1) return null;
 
const img = meaningful[0]; \
if (img.tagName !== "img") return null;
 
const alt = typeof [img.properties?.alt](https://img.properties?.alt) === "string" ? img.properties.alt.trim() : ""; \
if (!alt) return null; // 캡션(alt)이 없으면 그대로 둔다
 
return { \
type: "element", \
tagName: "figure", \
children: [ \
img, \
{ type: "element", tagName: "figcaption", children: [{ type: "text", value: alt }] }, \
], \
}; \
}; \
// ...트리를 재귀적으로 순회하며 해당 문단을 figure로 교체 \
};
 

핵심은 "문단 안에 의미 있는 자식이 이미지 단 하나, 그리고 alt가 있을 때"만 변환한다는 조건입니다. 본문 중간에 인라인으로 들어간 이미지는 건드리지 않습니다.

빌드 타임 하이라이팅: Shiki

코드 블록은 rehype-pretty-code(내부적으로 Shiki)가 처리합니다. 1편에서도 강조했지만, 이 부분이 성능적으로 꽤 중요합니다.

.use(rehypePrettyCode, { theme: "github-dark", keepBackground: false })
 

highlight.jsPrism은 브라우저에서 자바스크립트로 색을 칠합니다. 즉, 사용자가 코드를 보려면 하이라이팅 JS를 받아 실행해야 합니다. 반면 Shiki는 빌드 때 색칠을 끝낸 HTML을 내보냅니다. 결과적으로 코드 블록을 위한 클라이언트 자바스크립트가 0이고, 첫 화면부터 색이 입혀진 코드가 즉시 보입니다. (keepBackground: false는 배경색을 Shiki 대신 우리 CSS가 칠하도록 비워 두는 설정입니다.)

덤으로 얻는 것: 목차와 읽기 시간

본문을 렌더링하는 김에 목차(TOC)와 읽기 시간도 같이 뽑습니다.

// src/lib/markdown.ts \
export async function renderPost(markdown: string): Promise { \
const html = await renderMarkdown(markdown); \
return { \
html, \
toc: tocFromHtml(html), // 렌더된 HTML에서 목차 추출 \
readingTimeMinutes: estimateReadingTime(markdown), \
}; \
}
 

목차는 Markdown 원본이 아니라 렌더된 HTML에서 뽑습니다. 그래야 rehype-slug가 실제로 만든 id와 목차 링크의 id가 100% 일치하기 때문입니다.

function tocFromHtml(html: string): TocItem[] { \
const items: TocItem[] = []; \
const re = /<h([23]) id="([^"]+)"[^>]*>([\s\S]*?)<\/h\1>/g; \
let m; \
while ((m = re.exec(html)) !== null) { \
items.push({ depth: Number(m[1]), id: m[2], text: m[3].replace(/<[^>]+>/g, "").trim() }); \
} \
return items; \
}
 

읽기 시간은 한국어/영어가 섞인 글을 감안해 단순하게 글자 수 기준으로 계산합니다.

// 공백을 뺀 글자 수 ÷ 분당 약 500자 \
export function estimateReadingTime(markdown: string): number { \
const chars = markdown.replace(/\s/g, "").length; \
return Math.max(1, Math.round(chars / 500)); \
}
 

정리

  • 본문 렌더링은 unified 기반의 remark → rehype 파이프라인 한 줄로 선언됩니다.

  • raw HTML을 보존하되, "작성자는 관리자뿐"이라는 전제 위에서 정화를 생략했습니다.

  • 이미지+캡션을 <figure>로 바꾸는 커스텀 rehype 플러그인을 직접 넣었습니다.

  • Shiki로 코드 하이라이팅을 빌드 타임에 끝내, 클라이언트 비용을 0으로 만들었습니다.

  • 렌더링하는 김에 목차와 읽기 시간까지 한 번에 뽑아냅니다.


다음 편: 검색 엔진과 사람 모두를 위한 마감 — SEO와 글쓰기 경험을 다룹니다.

이 글이 어떠셨나요? 이모지로 반응을 남겨주세요
M
Mason
Software Engineer · masonlab

코드와 비즈니스, 그 사이에서 배운 것들을 기록하고 공유합니다.

댓글 0

아직 댓글이 없어요. 첫 댓글을 남겨보세요.

이런 글은 어때요?