- 들어가며 — 문서 3천 페이지, 빌드 2분 22초
- 시간은 어디로 가는가 — 다섯 단계
- 프로파일링 — 추측 대신 측정
- 플러그인 체인이 비용이 되는 이유
- satteri — 네이티브 코어가 바꾸는 것과 포기하는 것
- 벤치마크를 어디까지 믿을 것인가
- 수천 페이지 사이트가 실제로 해야 할 일
- 마치며 — 빠른 파서보다 안 도는 파서가 빠르다
- 참고 자료
들어가며 — 문서 3천 페이지, 빌드 2분 22초
문서 사이트가 커지면 어느 순간부터 빌드가 CI 파이프라인에서 가장 긴 항목이 됩니다. 페이지가 수백 개일 때는 신경 쓰이지 않던 것이 수천 개가 되면 배포 리드타임을 지배하고, 로컬에서 오탈자 하나 고치고 확인하려면 커피를 타러 가야 하는 상태가 됩니다.
이 문제에 대한 최근의 답이 satteri입니다. GeekNews에는 "JavaScript 생태계를 위한 고성능 Markdown 및 MDX 처리기"로 소개됐고, 저장소가 스스로 요약하는 문장이 정확합니다 — Rust에서 파싱하고 컴파일하며, 플러그인은 JavaScript에서 돌린다. 프로젝트가 지목하는 문제도 명확합니다. JavaScript 생태계에는 풍부한 플러그인이 있지만 파서가 느리고, Rust 생태계는 그 반대라는 것입니다.
다만 도구를 바꾸기 전에 해야 할 일이 있습니다. 시간이 실제로 어디에 쓰이는지 재는 것입니다. 큰 MDX 사이트에서 마크다운 파싱이 병목인 경우는 생각보다 드뭅니다. 이 글은 단계별 비용 구조와 프로파일링 절차를 먼저 세우고, 그 위에서 satteri 같은 네이티브 코어가 무엇을 바꾸고 무엇을 포기하는지 봅니다. 빌드가 느린 일반적인 이유는 내 빌드는 왜 느린가 편에 정리해 두었습니다.
시간은 어디로 가는가 — 다섯 단계
MDX 한 파일이 HTML이 되기까지 거치는 단계는 다음과 같습니다. 각 단계의 비용 성격이 다르므로 뭉뚱그리면 대책이 나오지 않습니다.
| 단계 | 하는 일 | 페이지 수에 대한 비용 | 주된 낭비 | 어떻게 재는가 |
|---|---|---|---|---|
| 파싱 | micromark가 마크다운을 토큰화하고 mdast를 만든다 | 선형, 문서 길이에 비례 | 거의 없음 | CPU 프로파일의 micromark 프레임 |
| 변환 | remark·rehype 플러그인이 트리를 한 번씩 순회한다 | 선형 곱하기 플러그인 개수 | 쓰지 않는 플러그인, 중복 순회 | 플러그인별 시간 계측 |
| 컴파일 | mdast를 hast로, 다시 JSX/JS로 바꾼다. MDX는 표현식 파싱까지 | 선형 | MDX 표현식이 없는 문서까지 MDX로 처리 | CPU 프로파일의 acorn·estree 프레임 |
| 번들 | 생성된 JS를 번들러가 다시 파싱·변환·트리셰이킹한다 | 선형이지만 상수가 큼 | 페이지마다 별도 모듈, 공통 청크 미분리 | 번들러 자체 프로파일 |
| 프리렌더 | 각 페이지를 실행해 HTML을 만든다 | 선형이지만 상수가 가장 큼 | 코드 하이라이터·이미지 처리 초기화 | 빌드 로그의 단계별 시간 |
실무에서 반복해서 확인되는 사실이 하나 있습니다. 페이지 수가 수천 단위로 가면 아래 두 줄이 위 세 줄을 압도하는 경우가 많습니다. 마크다운 파싱은 한 문서에 밀리초 단위이지만, 프리렌더는 한 페이지에 수십 밀리초씩 들고 여기에 코드 하이라이팅과 이미지 최적화가 붙습니다. 특히 Shiki 계열 하이라이터는 문법과 테마를 로드하는 초기 비용이 크고, 설정에 따라 페이지마다 다시 일하기도 합니다.
그러므로 마크다운 처리기를 교체하는 선택은 파싱과 변환이 실제로 상위 비용일 때만 의미가 있습니다. 그렇지 않은 사이트에서는 파서를 다섯 배 빠르게 만들어도 전체 빌드는 몇 퍼센트만 줄어듭니다.
프로파일링 — 추측 대신 측정
측정은 세 층위로 하면 충분합니다.
1층: 빌드 전체를 몇 번 돌려 분산을 본다. 한 번 재고 결론을 내리면 대개 틀립니다.
# 캐시를 지우고 세 번 반복, 평균과 편차를 함께 본다
hyperfine --warmup 1 --runs 3 --prepare 'rm -rf .next .contentlayer' 'pnpm build'
2층: Node CPU 프로파일로 어느 코드가 시간을 쓰는지 본다.
# V8 CPU 프로파일을 남긴다 (.cpuprofile 파일이 생성된다)
node --cpu-prof --cpu-prof-dir=./prof ./node_modules/.bin/next build
# 크롬 개발자도구의 Performance 패널에서 .cpuprofile을 열어 자기시간 기준으로 정렬
프로파일에서 볼 것은 상위 프레임의 성격입니다. micromark 계열이 위에 있으면 파싱, unist-util-visit이 위에 있으면 플러그인 순회, acorn 계열이면 MDX 표현식 컴파일, 하이라이터 이름이 보이면 그쪽이 범인입니다.
3층: 플러그인별로 시간을 나눠 본다. 이게 가장 실행 가능한 정보를 줍니다. unified의 어태처를 감싸면 플러그인마다 순회 시간을 잴 수 있습니다.
// time-plugins.mjs — 플러그인별 트리 순회 시간을 잰다
import { readFileSync } from 'node:fs'
import { performance } from 'node:perf_hooks'
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkRehype from 'remark-rehype'
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypeStringify from 'rehype-stringify'
const totals = new Map()
// 어태처를 감싸 트랜스포머 실행 시간만 누적한다
function timed(plugin, name, options) {
return function (...args) {
const transformer = plugin.call(this, options)
if (typeof transformer !== 'function') return transformer
return async (tree, file) => {
const t0 = performance.now()
const result = await transformer(tree, file)
totals.set(name, (totals.get(name) ?? 0) + (performance.now() - t0))
return result
}
}
}
const processor = unified()
.use(remarkParse)
.use(timed(remarkGfm, 'remark-gfm'))
.use(remarkRehype)
.use(timed(rehypeSlug, 'rehype-slug'))
.use(timed(rehypeAutolinkHeadings, 'rehype-autolink-headings'))
.use(rehypeStringify)
const source = readFileSync(process.argv[2], 'utf8')
const t0 = performance.now()
for (let i = 0; i < 100; i++) await processor.process(source)
const total = performance.now() - t0
console.table(
[...totals].map(([name, ms]) => ({
plugin: name,
ms: +ms.toFixed(1),
share: `${((ms / total) * 100).toFixed(1)}%`,
}))
)
console.log(`total ${total.toFixed(1)}ms for 100 passes`)
이 스크립트를 실제 저장소에서 가장 큰 문서 몇 개에 돌려 보면 대개 놀라운 결과가 나옵니다. 목차 생성이나 헤딩 앵커 같은 사소해 보이는 플러그인이 상위에 올라오거나, 반대로 플러그인 전체 합이 무시할 수준이라 파이프라인 교체가 무의미하다는 사실이 드러납니다. 어느 쪽이든 그 정보가 다음 결정을 정합니다.
플러그인 체인이 비용이 되는 이유
unified의 설계는 우아합니다. 프로세서에 플러그인을 꽂으면 각 플러그인이 트리를 받아 고치고 다음으로 넘깁니다. 문제는 이 우아함의 비용 구조입니다.
각 플러그인은 자기만의 전체 트리 순회를 합니다. GFM 테이블, 작업 목록, 자동 링크, 취소선, 스마트 따옴표, 디렉티브, 헤딩 슬러그, 앵커 링크 — 이것들이 하나씩 순회를 추가합니다. 문서 하나에 플러그인이 열두 개면 트리를 열두 번 도는 셈입니다. 각 순회가 하는 일은 적지만, 노드 객체를 따라다니며 포인터를 쫓는 작업이라 캐시 지역성이 나쁩니다.
여기에 MDX가 얹히면 한 겹 더 붙습니다. MDX 문서의 JSX와 중괄호 표현식은 JavaScript 파서로 다시 파싱해 estree로 만들어야 하고, 결과물은 HTML이 아니라 실행 가능한 JS 모듈입니다. 마크다운 컴파일이 끝난 뒤에도 번들러가 그 JS를 다시 파싱합니다 — 같은 내용이 사실상 세 번 파싱되는 구조입니다.
그래서 파이프라인 교체 없이도 즉시 효과가 나는 조치들이 있습니다.
- 쓰지 않는 플러그인 제거. 템플릿에서 물려받은 플러그인이 그대로 남아 있는 경우가 정말 많습니다. 하나씩 빼면서 결과 HTML을 비교하면 됩니다.
- 여러 플러그인을 하나의 순회로 합치기. 헤딩을 만지는 플러그인이 셋이면 직접 작성한 하나로 합칠 수 있습니다. 유지보수 비용과 교환하는 선택입니다.
- MDX가 필요 없는 문서는 마크다운으로 처리. 컴포넌트를 쓰지 않는 문서까지 MDX로 컴파일하면 JS 파싱 비용을 헛되이 냅니다.
- 코드 하이라이팅을 빌드에서 빼기. 하이라이터를 빌드 시점에 돌리는 대신 결과를 캐시하거나, 필요한 언어와 테마만 로드하도록 좁힙니다. 이 하나로 빌드가 절반이 되는 사이트가 드물지 않습니다.
satteri — 네이티브 코어가 바꾸는 것과 포기하는 것
satteri의 구조는 저장소에 비교적 명확하게 적혀 있습니다. Rust 크레이트가 파이프라인을 구성하고, napi-rs 바인딩이 Node로 노출하며, TypeScript 층이 플러그인 API를 제공합니다.
satteri-pulldown-cmark— MDX 확장을 붙인 pulldown-cmark 포크. CommonMark 파싱을 담당합니다.satteri-mdxjs-rs— Titus Wormer의 mdxjs-rs를 포크해 pulldown-cmark와 OXC를 쓰도록 바꾼 MDX 컴파일러.satteri-arena— 아레나 할당자와 이진 버퍼 원시 타입.satteri-ast— mdast/hast 노드 타입과 코덱, 트리 연산.satteri-plugin-api— 플러그인 트레이트와 타입 있는 방문자, 실행기.
핵심 설계 결정 셋을 뽑으면 이렇습니다. 첫째, 파서가 pulldown-cmark 기반이라 단일 패스 이벤트 스트림에 가깝게 동작합니다. 둘째, AST가 아레나에 이진 형태로 배치되므로 노드 하나마다 객체를 할당하고 포인터를 쫓는 비용이 사라집니다. 셋째, GFM 테이블·작업 목록·각주·취소선·수식·헤딩 속성·YAML 프런트매터 같은 확장이 플러그인이 아니라 파서의 기능으로 들어가 있습니다. 지금까지 플러그인 순회로 처리하던 것들이 파싱 한 번에 흡수된다는 뜻입니다.
설치와 사용은 평범합니다. napi-rs로 미리 컴파일된 네이티브 바이너리를 배포하므로 Rust 툴체인이 필요 없습니다. macOS(애플 실리콘·인텔), 리눅스 x86_64 glibc, 윈도우 x86_64용 바이너리가 제공되고 그 외 환경은 WASI 빌드로 떨어집니다.
npm install satteri
# Vite 통합 — .md/.mdx를 직접 import
npm install vite-plugin-satteri
import { markdownToHtml, mdxToJs, defineMdastPlugin } from 'satteri'
// mdast 수준 플러그인 — 타입 추론을 위해 define 헬퍼를 쓴다
const stripDrafts = defineMdastPlugin({
name: 'strip-drafts',
visit: {
blockquote(node, ctx) {
if (ctx.text(node).startsWith('DRAFT:')) ctx.remove(node)
},
},
})
const html = await markdownToHtml(source, { mdastPlugins: [stripDrafts] })
const js = await mdxToJs(source, { mdastPlugins: [stripDrafts] })
여기서 반드시 구분해야 할 것이 있습니다. satteri는 자체 플러그인 API를 mdast와 hast 두 수준에서 제공합니다. 하지만 이것이 기존 remark-* / rehype-* npm 패키지를 그대로 쓸 수 있다는 뜻은 아닙니다. 그 플러그인들은 unified 프로세서와 일반 JavaScript 객체 트리를 전제로 작성돼 있고, satteri의 트리는 아레나에 이진으로 담겨 napi 경계를 건너옵니다. 실제로 Astro가 satteri를 선택 가능한 프로세서로 붙였을 때 공지된 제약도 정확히 이것이었습니다 — 프로세서를 satteri로 바꾸면 unified 체인 전체가 교체되고, remark-toc·rehype-slug·rehype-autolink-headings 같은 생태계 플러그인은 동작하지 않습니다.
아래 설정 형태는 2차 매체 보도에서 인용된 것이므로, 실제 패키지 이름과 옵션은 Astro 공식 문서에서 다시 확인하시기 바랍니다.
// astro.config.mjs — Astro에서의 옵트인 형태(보도 기준)
import { satteri } from '@astrojs/markdown-satteri'
export default defineConfig({
markdown: {
processor: satteri({
features: { directive: true },
}),
},
})
정리하면 교환 조건은 이렇습니다.
- 얻는 것: 파싱과 컴파일이 네이티브 속도로 내려가고, 확장 기능이 플러그인 순회 없이 처리됩니다. 플러그인이 적고 문서가 많은 사이트일수록 이득이 큽니다.
- 잃는 것: unified 생태계 플러그인 자산 전체. 커스텀 처리가 필요하면 satteri의 플러그인 API로 다시 써야 합니다.
- 확인이 필요한 것: JS 플러그인을 하나 이상 붙이면 매 문서마다 Rust와 JS 사이를 오가야 합니다. 이 경계 비용이 플러그인 개수에 따라 어떻게 늘어나는지는 공개 문서에서 수치로 확인하지 못했습니다. 플러그인을 많이 붙일 계획이라면 자기 문서 집합으로 직접 재 보는 편이 안전합니다.
벤치마크를 어디까지 믿을 것인가
이 부분은 솔직하게 적겠습니다.
널리 인용되는 수치가 있습니다. Astro 문서 사이트 142초에서 63초(약 2.25배), Cloudflare 문서 120초에서 55초(약 2.18배), 중간 규모 마케팅 사이트 38초에서 22초(약 1.73배). 빌드당 79초를 아끼면 하루 50회 빌드 기준 연간 약 230 CI 시간이라는 계산도 함께 따라다닙니다.
확인한 범위에서 상황은 이렇습니다.
- 이 수치들은 satteri 공식 사이트나 저장소에서 제시된 것이 아닙니다. 확인 시점의 satteri 공식 사이트에는 브라우저 WASM 데모의 초당 문서 처리량 표시만 있고, unified·remark·mdx-js와의 비교 수치나 재현 가능한 벤치마크 하네스는 게시돼 있지 않았습니다.
- 위 표는 Astro 통합을 다룬 2차 매체의 기사에서 인용되는 형태로 유통되고 있습니다. 측정 하드웨어, 캐시 상태, 플러그인 구성, 반복 횟수 같은 재현 조건은 제시돼 있지 않습니다.
- 따라서 "2배 정도 빨라진다"는 방향성은 아키텍처상 개연성이 있지만, 위 숫자를 자기 프로젝트의 기대값으로 삼는 것은 근거가 부족합니다.
벤치마크를 읽을 때 확인할 항목을 정리하면 이렇습니다.
- 무엇과 무엇을 비교했는가. 플러그인 열 개가 붙은 unified 체인과 플러그인 없는 네이티브 파이프라인을 비교하면 파서 성능이 아니라 플러그인 개수를 측정한 것입니다.
- 전체 빌드인가 파이프라인만인가. 파이프라인이 5배 빨라져도 그 단계가 전체의 15퍼센트면 전체는 12퍼센트 줄어듭니다. 암달의 법칙은 여기서도 그대로 적용됩니다.
- 콜드인가 웜인가. 캐시가 남은 상태의 재빌드는 다른 것을 측정합니다.
- 출력이 동일한가. 결과 HTML이 다르면 속도 비교 자체가 성립하지 않습니다. 파서를 바꿀 때는 렌더 결과를 반드시 diff로 대조해야 합니다.
네 번째가 특히 중요합니다. pulldown-cmark 계열과 micromark 계열은 CommonMark 준수 수준이 높아도 가장자리 사례에서 출력이 미묘하게 다를 수 있습니다. 전환 전에 전체 문서를 양쪽으로 렌더해 비교하는 절차를 두는 편이 좋습니다.
# 전환 전후 렌더 결과를 전량 대조하는 최소 절차
node scripts/render-all.mjs --engine=unified --out /tmp/html-unified
node scripts/render-all.mjs --engine=satteri --out /tmp/html-satteri
diff -ru /tmp/html-unified /tmp/html-satteri | head -100
수천 페이지 사이트가 실제로 해야 할 일
우선순위를 효과 순으로 놓으면 도구 교체는 생각보다 뒤에 있습니다.
1순위, 처리할 페이지 수를 줄이세요. 파이프라인을 빠르게 만드는 것보다 안 도는 것이 언제나 빠릅니다. 콘텐츠 파일 해시를 키로 컴파일 결과를 캐시하고, 바뀐 파일만 다시 처리하는 증분 경로를 만드세요. CI에서는 이 캐시를 원격으로 공유하면 콜드 러너에서도 이득이 납니다. 캐시 키를 정확히 잡는 방법은 모노레포 CI 캐시 전략 편에서 다뤘습니다.
2순위, 프리렌더와 하이라이팅을 손보세요. 앞서 말했듯 페이지 수가 많을수록 이쪽이 지배적입니다. 하이라이터가 로드하는 문법과 테마를 실제로 쓰는 것만 남기고, 하이라이팅 결과를 콘텐츠 해시 기준으로 캐시하세요.
3순위, 플러그인 체인을 정리하세요. 앞의 계측 스크립트로 상위 셋을 찾아 제거하거나 합치세요. 도구를 바꾸지 않고 얻는 개선이라 위험이 없습니다.
4순위, 그다음에 파이프라인 교체를 검토하세요. 이 순서로 왔는데도 파싱과 변환이 상위에 남아 있다면 satteri 같은 네이티브 코어가 실제로 답일 수 있습니다. 검토 방법은 하나입니다 — 자기 문서 집합에서 직접 재 보고, 출력물을 대조하는 것.
그리고 도입 시점에 대한 판단도 필요합니다. satteri는 새 프로젝트이고, 공식 문서에 아직 채워지지 않은 부분이 있으며, 이 글 시점에 안정 버전 1.0에 도달하지 않았습니다. 문서 사이트의 렌더링 정확성은 되돌리기 어려운 종류의 리스크이므로, 큰 프로덕션 사이트라면 병렬 렌더 대조를 몇 주간 돌려 본 뒤에 전환하는 편이 합리적입니다. 반대로 새로 시작하는 문서 사이트이고 플러그인 의존이 적다면 지금 채택해도 손실이 크지 않습니다.
마치며 — 빠른 파서보다 안 도는 파서가 빠르다
satteri는 실제 문제를 겨눈 프로젝트입니다. JavaScript 마크다운 파이프라인이 느린 이유는 개별 코드가 나빠서가 아니라, 플러그인마다 트리를 다시 도는 구조 자체가 비용이기 때문입니다. 파서와 확장을 네이티브 단일 패스로 옮기는 것은 그 구조에 대한 정직한 대응입니다.
- 먼저 재세요.
--cpu-prof와 플러그인별 계측이면 30분 안에 병목이 나옵니다. - 수천 페이지 사이트에서는 프리렌더와 코드 하이라이팅이 파싱보다 큰 경우가 많습니다. 순서를 뒤집지 마세요.
- 증분 빌드와 콘텐츠 해시 캐시가 언제나 1순위입니다. 안 도는 것보다 빠른 것은 없습니다.
- satteri로 옮기면 unified 생태계 플러그인은 함께 가지 못합니다. 그 자산의 크기가 실제 판단 기준입니다.
- 유통되는 2배 수치는 2차 매체 인용이고 재현 조건이 공개돼 있지 않습니다. 자기 저장소에서 직접 재고, 렌더 결과를 diff로 대조하세요.
빌드 시간 문제의 대부분은 더 빠른 도구가 아니라 더 적은 일로 풀립니다. 도구 교체는 그 뒤에 오는 선택지입니다.
참고 자료
현재 단락 (1/138)
문서 사이트가 커지면 어느 순간부터 빌드가 CI 파이프라인에서 가장 긴 항목이 됩니다. 페이지가 수백 개일 때는 신경 쓰이지 않던 것이 수천 개가 되면 배포 리드타임을 지배하고, ...