Satteri: Rust로 파싱하고 JavaScript로 확장하는 마크다운 처리기

Satteri: Rust로 파싱하고 JavaScript로 확장하는 마크다운 처리기

블로그나 문서 사이트를 운영하다 보면 빌드 시간이 슬금슬금 늘어나는 순간이 옵니다. 글이 수백 개를 넘어가면 마크다운 파싱과 변환에만 수십 초가 쓰이고, 거기에 remark/rehype 플러그인을 몇 개 더 끼우면 1분을 훌쩍 넘기는 경우도 흔하죠. 저도 이 블로그를 운영하면서 빌드 로그를 보다가 “마크다운 처리에 이만큼 시간을 쓴다고?”라는 생각을 한두 번 해본 게 아닌데요 😅

최근 Astro 6.4에서 markdown.processor API가 새로 등장하면서, unified 생태계가 아닌 다른 마크다운 처리기로 갈아탈 수 있는 길이 열렸습니다. 그리고 그 첫 번째 대안으로 Bruits라는 Rust 커뮤니티가 공개한 Satteri라는 라이브러리가 함께 발표됐는데요. 이름이 좀 낯설지만 “파싱은 Rust로, 플러그인은 JavaScript로”라는 명확한 철학을 내세운 프로젝트입니다.

이 글에서는 Satteri가 무엇이고 unified와 어떻게 다른지, 그리고 실제로 어떻게 쓰는지를 정리해 보겠습니다.

Satteri가 뭔가요

Satteri는 마크다운과 MDX를 처리하는 라이브러리입니다. 핵심은 파서와 컴파일러는 Rust로 작성하고, 플러그인 계층만 JavaScript에 노출하는 구조라는 점이에요. 내부적으로는 다음과 같은 잘 알려진 Rust 프로젝트 위에 쌓여 있습니다.

  • pulldown-cmark — CommonMark 파서. 풀(pull) 방식으로 토큰을 흘려보내는 빠른 파서로 유명합니다.
  • mdxjs-rs — Titus Wormer가 만든 MDX 컴파일러. Satteri는 이걸 포크해서 OXC와 함께 쓰도록 손봤습니다.
  • oxc — Rust로 짠 JavaScript 파서/컴파일러. MDX의 JSX 부분을 처리할 때 씁니다.

JavaScript 쪽에서는 napi-rs로 바인딩된 얇은 TypeScript 레이어가 노출되고, 사용자는 평범한 npm 패키지처럼 satteri를 설치해서 쓰면 됩니다. 네이티브 바이너리는 macOS, Linux, Windows를 모두 지원하고, 브라우저나 엣지 런타임에서는 WASI 폴백으로 동작합니다.

왜 또 하나의 마크다운 처리기인가

remark와 rehype는 이미 충분히 좋은 도구입니다. unified라는 추상 위에서 동일한 API로 마크다운과 HTML을 다룰 수 있고, 플러그인 생태계도 풍부하죠. Prettier, ESLint, Astro, MDX 등 수많은 프로젝트가 이 위에 올라가 있을 정도입니다.

문제는 성능입니다. JavaScript로 작성된 파서가 수백, 수천 개의 마크다운 파일을 처리하다 보면 결국 한계가 옵니다. 특히 큰 문서를 처리할 때 micromark가 만들어 내는 수많은 작은 객체가 GC 부담을 늘리고, 플러그인 체인이 길어지면 트리를 여러 번 순회하면서 비용이 누적되는데요. Astro 공식 블로그는 자신들의 문서 사이트와 Cloudflare 문서 사이트를 Satteri로 옮긴 후 빌드 시간이 상당히 짧아졌다고 밝혔습니다.

Satteri는 이 지점을 정조준합니다. 가장 비싼 작업인 파싱과 AST 변환은 Rust에서 끝내고, 사용자가 커스터마이징하고 싶어 하는 부분(플러그인)만 JavaScript에 노출하는 거죠.

설치하고 가장 간단한 예제 돌려보기

설치는 평범한 npm 패키지와 똑같습니다.

bun add satteri
# 또는
npm install satteri

가장 기본적인 사용법은 마크다운 문자열을 받아 HTML 문자열을 돌려주는 한 줄짜리 호출입니다.

import { markdownToHtml } from "satteri";

const html = markdownToHtml("# Hello\n\nWorld");
// <h1>Hello</h1>\n<p>World</p>

mdxToJs를 쓰면 MDX 입력을 JavaScript 모듈 코드로 컴파일할 수도 있습니다.

import { mdxToJs } from "satteri";

const js = mdxToJs("# Hello\n\n<MyComponent />");

플러그인 없이도 CommonMark 표준은 그대로 동작하고, GFM(테이블, 취소선, 각주, 태스크 리스트, 자동 링크)이나 frontmatter, 수식 같은 확장은 features 옵션으로 켤 수 있습니다.

const html = markdownToHtml(source, {
  features: {
    gfm: true,
    frontmatter: true,
    math: { singleDollarTextMath: false },
  },
});

math: { singleDollarTextMath: false }처럼 세부 옵션을 따로 끌 수 있는 점이 눈에 띕니다. 한글 문서에서는 $10 같은 표기가 수식으로 오해되지 않도록 이 옵션을 끄는 게 안전합니다.

mdast 플러그인 작성하기

플러그인은 두 단계에서 끼어들 수 있습니다. mdast 플러그인은 마크다운 AST 단계에서 동작하고, hast 플러그인은 HTML AST 단계에서 동작하는데요. remark와 rehype의 분업과 거의 같은 구조입니다.

mdast 플러그인은 노드 타입 이름을 그대로 메서드 이름으로 써서 방문자를 정의합니다. 예를 들어 :wave: 같은 이모지 단축어를 실제 이모지로 바꾸려면 text 노드 방문자를 만들면 됩니다.

import { defineMdastPlugin, markdownToHtml } from "satteri";

const emojis = defineMdastPlugin({
  name: "emojis",
  text(node, ctx) {
    if (node.value.includes(":wave:")) {
      ctx.setProperty(
        node,
        "value",
        node.value.replaceAll(":wave:", "\u{1F44B}"),
      );
    }
  },
});

const html = markdownToHtml("안녕하세요 :wave:", {
  mdastPlugins: [emojis],
});

여기서 한 가지 중요한 점이 있는데요. 위 코드에서 node.value = ...처럼 직접 대입하지 않고 ctx.setProperty(node, "value", ...)로 우회한 이유가 있습니다.

노드는 왜 읽기 전용인가

Satteri 플러그인 안에서 받은 노드 객체는 읽기 전용 뷰입니다. 진짜 AST는 Rust 메모리에 살고 있고, JavaScript는 그 위에 얇은 뷰만 받고 있어요. 그래서 다음 같은 코드는 아무 효과가 없습니다.

// 작동하지 않음 — TypeScript도 readonly라고 경고합니다
heading(node) {
  node.depth = 2;
}

대신 컨텍스트가 제공하는 메서드를 써야 변경이 Rust 쪽 트리에 반영됩니다.

heading(node, ctx) {
  ctx.setProperty(node, "depth", 2);
}

방문자가 아예 새 노드를 반환하면 그 노드로 교체되기도 합니다. 다만 매번 새 노드를 만들어 반환하면 Rust ↔ JavaScript 간 복사가 늘어나니 가능하면 setProperty 쪽이 효율적입니다.

조금 더 과감하게 노드를 통째로 다른 형태로 바꾸고 싶다면 rawHtml이나 rawMarkdown을 반환할 수도 있어요. 예를 들어 코드 블록을 직접 만든 하이라이팅 HTML로 치환할 때 유용합니다.

const highlightCode = defineMdastPlugin({
  name: "highlight-code",
  code(node) {
    return {
      rawHtml: `<pre class="highlighted">${escape(node.value)}</pre>`,
    };
  },
});

처음 보면 어색할 수 있는 모델이지만, FFI 비용을 줄이려는 합리적인 선택입니다. 같은 결정을 Lightning CSS도 했고, 결과적으로 큰 문서를 다룰 때 차이가 꽤 크다고 알려져 있어요.

hast 플러그인으로 HTML 다듬기

mdast → hast 변환이 끝난 다음, 출력 직전에 HTML 트리를 손볼 때는 hast 플러그인을 씁니다. 외부 링크에 target="_blank"를 붙이는 흔한 작업을 예로 볼게요.

import { defineHastPlugin } from "satteri";

const addLinkClasses = defineHastPlugin({
  name: "add-link-classes",
  element: {
    filter: ["a"],
    visit(node, ctx) {
      ctx.setProperty(node, "class", "link");
      ctx.setProperty(node, "target", "_blank");
    },
  },
});

element 안의 filter에는 매칭할 태그 이름 배열이 들어갑니다. 여러 종류의 노드를 다르게 처리하고 싶다면 element를 배열로 줘서 그룹별로 다른 방문자를 정의할 수도 있어요.

const multiFilter = defineHastPlugin({
  name: "multi-filter",
  element: [
    {
      filter: ["h1", "h2", "h3"],
      visit(node, ctx) {
        ctx.setProperty(node, "class", "heading");
      },
    },
    {
      filter: ["a"],
      visit(node, ctx) {
        ctx.setProperty(node, "target", "_blank");
      },
    },
  ],
});

filter: []를 쓰면 모든 요소에 매칭되지만, 큰 문서에서는 빠르게 비싸지니 가급적 피하는 것이 좋다는 안내가 공식 문서에 적혀 있습니다.

MDX와 정적 최적화

MDX는 마크다운 안에 JSX 컴포넌트를 섞어 쓰는 형식이죠. Satteri는 MDX도 마크다운과 동등하게 다루는데, 특히 눈여겨볼 만한 옵션이 하나 있습니다. 바로 optimizeStatic인데요.

MDX를 컴파일하면 보통 모든 요소가 jsx() 호출로 변환됩니다. 그런데 컴포넌트가 섞이지 않은 순수 HTML 부분까지 JSX 호출로 만들 필요는 없잖아요. optimizeStatic은 정적인 서브트리를 한 덩어리의 HTML 문자열로 미리 렌더해서, 런타임 JSX 호출 횟수를 확 줄여 줍니다.

Astro 스타일로 쓰면 정적인 부분은 Fragment로 감싸서 set:html로 주입합니다.

const js = mdxToJs(source, {
  optimizeStatic: {
    component: "Fragment",
    prop: "set:html",
  },
});

React 스타일로는 divdangerouslySetInnerHTML 조합을 쓸 수 있고요.

const js = mdxToJs(source, {
  optimizeStatic: {
    component: "div",
    prop: "dangerouslySetInnerHTML",
    wrapPropValue: true,
  },
});

이 최적화는 원래 Astro의 Bjorn Lu가 Astro 내부에서 만든 기법인데, Satteri가 일반화해서 누구나 쓸 수 있게 풀어둔 것입니다. 큰 MDX 문서를 다룰 때 의미 있는 차이를 만들어 줍니다.

비동기 플러그인도 쓸 수 있지만

방문자 함수는 async로 만들 수도 있습니다. 다만 비동기 방문자가 하나라도 있으면 markdownToHtmlmdxToJs의 반환값이 string 대신 Promise<string>이 되고, 매 노드마다 Rust ↔ JavaScript 경계를 비동기로 넘나들기 때문에 성능 비용이 커집니다.

비동기가 어쩔 수 없는 대표적인 경우가 코드 하이라이팅이에요. Shiki 같은 라이브러리는 초기화가 비동기라서 어쩔 수 없이 async가 필요합니다.

const highlighter = await createHighlighter({
  themes: ["github-dark"],
  langs: ["js", "ts"],
});

const asyncHighlight = defineMdastPlugin({
  name: "async-highlight",
  async code(node) {
    const html = await highlighter.codeToHtml(node.value, {
      lang: node.lang,
      theme: "github-dark",
    });
    return { rawHtml: html };
  },
});

const html = await markdownToHtml("```js\ncode\n```", {
  mdastPlugins: [asyncHighlight],
});

공식 가이드도 “성능 때문에 가능하면 동기 방문자를 쓰라”고 권장합니다. 매칭되는 노드가 적을 때는 큰 차이가 없지만, 모든 텍스트 노드에 비동기 작업을 거는 식의 패턴은 피하는 게 좋아요.

unified와 무엇이 다른가

이쯤 되면 자연스럽게 “그래서 unified 자리에 그대로 끼울 수 있나?”라는 질문이 떠오를 텐데, 답은 아니오입니다. 공식 문서가 명시적으로 “Satteri는 unified의 드롭인 대체재가 아니다”라고 못 박고 있습니다.

AST 형태 자체는 mdast와 hast 스펙을 따르고 있어서 보기에는 비슷합니다. 하지만 플러그인 API가 완전히 다르고, 노드도 읽기 전용이라 기존 remark/rehype 플러그인을 그대로 가져다 쓸 수 없어요. 한국어 줄바꿈을 자연스럽게 처리해 주는 remark-cjk-friendly라든가, 코드 블록에 제목을 붙여 주는 커스텀 remark 플러그인 같은 것들이 이미 마음에 든다면, Satteri로 옮길 때 같은 기능을 새 플러그인으로 다시 구현해야 합니다.

대신 다음과 같은 기능은 Satteri의 features 옵션으로 기본 제공됩니다.

  • gfm — 테이블, 취소선, 각주, 태스크 리스트, 자동 링크
  • frontmatter — YAML frontmatter 파싱
  • math — 수식 노드
  • directive:::note ... ::: 같은 컨테이너 지시문
  • smartPunctuation — 따옴표와 대시 자동 변환

GFM이나 frontmatter처럼 흔히 remark 플러그인으로 추가하던 기능이 파서에 내장돼 있으니, 단순한 블로그라면 별도 플러그인 없이도 꽤 멀리 갈 수 있습니다.

Astro와 함께 쓰기

Astro 6.4에서 새로 들어온 markdown.processor 옵션을 통해 unified 대신 Satteri를 끼울 수 있습니다. @astrojs/markdown-satteri 어댑터 패키지를 추가하고 설정에서 프로세서를 갈아 끼우면 되는데요.

다만 앞서 말한 호환성 한계 때문에, 기존 프로젝트가 remark/rehype 플러그인에 의존하고 있다면 옮기기 전에 다음을 점검해야 합니다.

  • 쓰고 있는 remark 플러그인 목록 → Satteri의 mdast 플러그인으로 다시 작성해야 함
  • 쓰고 있는 rehype 플러그인 목록 → hast 플러그인으로 다시 작성해야 함
  • 코드 하이라이팅 — Shiki를 비동기 mdast 플러그인으로 직접 연결해야 함

새 프로젝트라면 부담이 적지만, 이미 운영 중인 Astro 블로그를 옮길 때는 빌드 시간을 줄여서 얻는 이득과 플러그인을 다시 짜는 비용을 저울질해 봐야 합니다.

마치며

Satteri는 마크다운 처리 영역에서 새로운 자리를 잡아가고 있는 프로젝트입니다. “Rust로 빠르게 파싱하고, 정말 커스터마이징이 필요한 부분만 JavaScript로 노출한다”는 분업 모델은 esbuild나 oxc, Lightning CSS 같은 다른 프런트엔드 도구가 이미 검증한 방향이기도 합니다. 그 흐름이 마크다운 처리기에도 도착했다고 보면 됩니다.

물론 unified 생태계를 한 번에 대체하지는 못합니다. 풍부한 플러그인 자산을 포기해야 하고, 노드를 직접 변경하지 못하는 read-only 모델에도 적응해야 하니까요. 하지만 큰 문서 사이트를 운영하면서 빌드 시간이 진짜 문제라면, 한 번쯤 시간을 들여 마이그레이션을 검토할 가치가 있습니다.

다음 단계로는 Satteri의 플러그인을 직접 하나 짜 보는 것을 추천합니다. mdast나 hast 스펙은 이미 unified 문서에 잘 정리돼 있어서, AST 모양만 익혀 두면 두 진영을 모두 다룰 수 있게 됩니다.

더 자세한 내용과 최신 API 변화는 Satteri 공식 사이트를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord