TanStack Markdown과 Highlight

TanStack Markdown과 Highlight

문서 사이트를 만들다 보면 이상한 순간이 옵니다. 정작 보여주려는 건 글자 몇 천 개인데, 그걸 그리려고 내려받는 자바스크립트가 1MB를 넘어가는 거죠. 😅

TanStack 팀이 딱 이 상황이었습니다. tanstack.com의 문서 페이지 하나가 스크립트만 약 1.1MiB를 전송하고 있었는데, 그중 358KiB 정도가 오직 코드 구문 강조에 쓰이고 있었어요.

이걸 React Server Components로 감추는 대신, 팀은 도구 자체를 작게 만드는 쪽을 택했습니다. 그렇게 나온 게 TanStack Markdown과 TanStack Highlight입니다.

무엇을 만들었나요

이름 그대로 하나는 마크다운 파서이고 하나는 코드 하이라이터입니다. 중요한 건 둘이 분리돼 있다는 점인데요.

기존 도구들은 파싱과 하이라이팅이 하나의 파이프라인으로 엉켜 있는 경우가 많습니다. unified와 remark, rehype 조합만 해도 플러그인 체인 하나에 전부 물려 있죠. 그러면 마크다운만 필요한 곳에서도 하이라이터가 딸려 오고, 하이라이팅만 바꾸고 싶어도 파이프라인 전체를 건드려야 합니다.

TanStack은 이 둘을 떼어놨습니다. 각각 따로 캐싱하고, 따로 렌더링하고, 필요 없으면 아예 안 가져오는 게 가능해졌어요.

설치하기

두 패키지는 독립적이라 필요한 것만 설치하면 됩니다.

bun add @tanstack/markdown @tanstack/highlight
결과
installed @tanstack/markdown@0.0.12
installed @tanstack/highlight@0.0.9

2 packages installed [572.00ms]

버전 번호에서 짐작하셨겠지만 아직 알파입니다. API가 바뀔 수 있으니 프로덕션에 넣기 전에는 이 점을 감안하셔야 해요.

TanStack Markdown: AST가 결과물입니다

가장 눈에 띄는 설계 결정은 렌더링된 HTML이 아니라 추상 구문 트리(Abstract Syntax Tree, 이하 AST)를 1차 산출물로 본다는 점입니다.

parse.ts
import { parseMarkdown } from "@tanstack/markdown/parser";
import { renderHtml } from "@tanstack/markdown/html";

const document = parseMarkdown(source);
const html = renderHtml(document);

parseMarkdown()이 돌려주는 값은 그냥 평범한 객체입니다. JSON.stringify()로 직렬화해서 캐시에 넣거나, 서버에서 만들어 클라이언트로 넘기거나, 검색 색인을 만들 때 그대로 훑을 수 있어요.

직접 돌려보면 이런 모양입니다.

t.ts
const source = `# 제목

**굵게** 그리고 [링크](https://daleseo.com)입니다.
`;

console.log(JSON.stringify(parseMarkdown(source), null, 2));
결과
{
  "type": "root",
  "children": [
    {
      "type": "heading",
      "depth": 1,
      "id": "section",
      "children": [
        {
          "type": "text",
          "value": "제목"
        }
      ]
    },

여기에 renderHtml()을 태우면 예상대로 나옵니다.

결과
<h1 id="section">제목</h1>
<p><strong>굵게</strong> 그리고 <a href="https://daleseo.com">링크</a>입니다.</p>
<ul>
<li>첫째</li>
<li>둘째</li>
</ul>

React를 쓴다면 컴포넌트로 바로 감쌀 수도 있습니다.

Article.tsx
import { Markdown } from "@tanstack/markdown/react";

export function Article({ source }: { source: string }) {
  return <Markdown>{source}</Markdown>;
}

AI 출력처럼 조금씩 흘러들어오는 마크다운을 다뤄야 한다면 스트리밍 확장이 따로 있습니다.

streaming.ts
import { streamingMarkdownExtension } from "@tanstack/markdown/extensions/streaming";

const extensions = [streamingMarkdownExtension()];

TanStack Highlight: 언어를 직접 등록합니다

하이라이터 쪽의 핵심은 숨은 언어 레지스트리가 없다는 점입니다. 쓸 언어를 코드에서 명시적으로 가져와 등록해야 해요.

highlighter.ts
import { createHighlighter } from "@tanstack/highlight/core";
import { ts } from "@tanstack/highlight/languages/ts";

const highlighter = createHighlighter({ languages: [ts] });
const result = highlighter.highlight("const total = items.length", {
  lang: "ts",
});

번거로워 보이지만 이게 번들 크기를 통제하는 방법입니다. 등록하지 않은 언어는 번들에 들어오지 않으니까요.

결과는 토큰 배열과 HTML을 함께 담고 있습니다.

결과
{
  "code": "const total = items.length",
  "lang": "ts",
  "tokens": [
    { "className": "keyword", "value": "const" },
    { "value": " total = items." },
    { "className": "property", "value": "length" }
  ],
  "html": "<pre class=\"th-code th-code--ts\" data-language=\"ts\"><code><span class=\"th-token th-keyword\">const</span> total = items.<span class=\"th-token th-property\">length</span></code></pre>"
}

th-keyword, th-property 같은 의미 있는 클래스가 붙는 게 보이시죠. 색상을 인라인 스타일로 박지 않고 클래스와 CSS 변수로 넘기기 때문에, 다크 모드를 바꿀 때 다시 하이라이팅할 필요가 없습니다. CSS만 갈아끼우면 끝이에요.

theme.ts
import { createThemeCss } from "@tanstack/highlight/theme";
import { githubDarkTheme } from "@tanstack/highlight/themes/github-dark";
import { githubLightTheme } from "@tanstack/highlight/themes/github-light";

const css = createThemeCss({
  light: githubLightTheme,
  dark: githubDarkTheme,
  darkSelector: ".dark",
});

번들 크기를 직접 재봤습니다

발표 글이 제시한 숫자가 실제로 맞는지 궁금해서 직접 번들링해 봤습니다. 각 진입점만 가져오는 파일을 만들어 bun build --minify로 묶고 gzip으로 압축한 결과입니다.

결과
markdown/parser                raw 14367B  gzip  4997B
highlight/core + ts            raw  9654B  gzip  3941B

파서가 gzip 기준 약 4.9KB, 하이라이터 코어에 TypeScript 언어 하나를 더한 게 약 3.9KB입니다. 공식 발표의 “파서 4.9KB”, “코어 + TSX 3.9KB”와 사실상 일치하네요.

참고로 TanStack이 밝힌 다른 수치들은 이렇습니다.

  • 하이라이터 코어만: 약 1.7KB
  • 문서용 9개 언어: 약 5.8KB
  • 전체 25개 언어: 약 8KB
  • 스트리밍 확장: 약 0.2KB

tanstack.com에 실제로 적용한 뒤에는 프로덕션 경로에서 약 27KiB가 전송된다고 합니다. 1.1MiB에서 출발했으니 꽤 큰 변화죠.

Shiki와는 무엇이 다른가요

Shiki는 VS Code와 같은 TextMate 엔진을 쓰기 때문에 편집기 수준의 정확도를 냅니다. 대신 문법 정의와 엔진을 통째로 안고 가야 해서 무겁죠.

TanStack Highlight는 애초에 웹페이지 렌더링만 노립니다. 편집기용 증분 파싱이나 TextMate 호환성을 포기하는 대신 크기를 얻은 거예요.

ShikiTanStack Highlight
엔진TextMate 문법자체 토크나이저
정확도편집기 수준웹페이지 수준
언어 등록레지스트리에서 자동명시적 import
테마 전환재하이라이팅 필요CSS 교체만
크기코어 1.7KB부터

마크다운 쪽도 비슷합니다. react-markdown이나 MDX가 CommonMark 구석구석과 플러그인 생태계를 약속한다면, TanStack Markdown은 제목, 강조, 목록, 표, 각주, 코드 블록처럼 실제로 쓰는 문법에 집중하고 나머지는 의도적으로 뺐습니다. MDX 평가나 임의의 비동기 플러그인, 내장 sanitizer도 없어요.

“모든 경우를 지원한다”를 포기하는 대가로 작아진 도구입니다. 이 트레이드오프가 맞는지는 프로젝트마다 다르겠죠.

한글 제목은 앵커가 사라집니다

한국어로 글을 쓰신다면 이 부분을 꼭 확인하셔야 합니다. 제목에서 자동 생성되는 id가 한글을 전부 버립니다.

t2.ts
for (const h of ["# Getting Started", "# 시작하기", "# API 레퍼런스"]) {
  console.log(renderHtml(parseMarkdown(h)).trim());
}
결과
<h1 id="getting-started">Getting Started</h1>
<h1 id="section">시작하기</h1>
<h1 id="api">API 레퍼런스</h1>

영문 제목은 getting-started로 잘 만들어지는데, 한글은 통째로 날아가고 section만 남습니다. API 레퍼런스처럼 영문이 섞여 있으면 그 부분만 살아남아 api가 되고요.

한 문서에 한글 제목이 여러 개면 이렇게 됩니다.

결과
<h2 id="section">설치하기</h2>
<h2 id="section-2">사용법</h2>
<h2 id="section-3">마치며</h2>

번호가 붙어서 충돌하지는 않지만, 목차 링크가 #section-2 같은 모양이 됩니다. URL만 보고는 어느 섹션인지 알 수 없고, 글을 고쳐서 섹션 순서가 바뀌면 기존 링크가 조용히 다른 곳을 가리키게 됩니다.

한글 문서에 쓰실 거라면 제목 id를 직접 지정하는 방법을 마련해두시는 게 좋겠습니다.

아직 알파입니다

0.0.x 버전이 말해주듯 계약이 아직 움직일 수 있습니다. 팀은 TanStack 문서 2,940개에서 뽑은 333개 픽스처로 테스트하고 있고, 1만 블록 규모의 처리량 목표를 두고 있다고 밝혔어요.

React와 Octane은 선택적 peer 의존성으로 별도 진입점 뒤에 있습니다. HTML 렌더러 자체는 프레임워크와 무관하게 동작하니, Astro처럼 다른 환경에서도 쓸 수 있습니다.

마치며

TanStack Markdown과 Highlight는 “더 많은 기능”이 아니라 “덜어냈더니 작아졌다”로 승부하는 도구입니다. 문서 사이트나 블로그처럼 마크다운을 대량으로 렌더링하는 곳에서 번들이 부담스러웠다면 한 번 재볼 만합니다.

다만 알파 단계이고 CommonMark를 전부 지원하지 않으며, 한글 제목의 앵커 문제도 있습니다. 지금 당장 갈아타기보다는 번들에서 하이라이터가 차지하는 비중부터 확인해 보시는 걸 권합니다. 저처럼 직접 번들링해서 재보면 몇 분이면 답이 나오거든요.

TanStack의 다른 라이브러리가 궁금하시다면 TanStack 관련 글을 살펴보세요. 자세한 내용은 공식 발표 글에 정리돼 있습니다.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

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

Discord