내 블로그와, 내가 참여하고 있는 또 다른 오픈소스 프로젝트가 최근 모두 Mermaid를 Obsidian 팀이 오픈소스로 공개한 pele로 바꿨다. 먼저 같은 문법으로 그린 결과를 보자. 왼쪽은 Mermaid 공식 패키지, 오른쪽은 pele다.
보기 좋은 것만이 아니다. 같은 136개 다이어그램을 브라우저에서 Mermaid 공식 패키지와 비교하면 이렇다.
전체 데이터와 측정 방법은 마지막 절에 정리했다.
내 블로그는 Mermaid로 그림을 자주 그린다. 흐름, 아키텍처, 시퀀스 모두 Mermaid에 의지한다. 지금 사이트에는 글 44편, 다섯 개 언어를 합쳐 다이어그램이 136개 있다. 이 다이어그램들이 보기 좋으면서도 페이지를 느리게 만들지 않도록 렌더러를 세 번 바꿨다. 처음에는 Mermaid 공식 패키지, 8월에는 beautiful-mermaid, 그리고 지난주 사이트 전체를 개편하면서 Obsidian 팀이 오픈소스로 공개한 pele로 바꿨다.
pele로 바꾼 뒤 사이트의 다이어그램 136개로 실측해 봤다. 전부 렌더링에 성공했고, 전체에 걸린 시간은 75밀리초 정도로 beautiful-mermaid보다 약 9배 빨랐으며, 생성된 SVG도 40% 작아졌다. 더 중요한 점은 DOM을 전혀 건드리지 않고 의존성도 하나도 없으며, 색은 전부 CSS 변수에 맡긴다는 것이다. 덕분에 「서버에서 SVG를 다 그려 두고, 브라우저는 렌더링 코드를 한 줄도 불러오지 않는」 구성이 딱 맞게 가능하다.
이 글은 순서대로 다룬다. pele가 무엇인지, 왜 여기까지 바꿔 왔는지, 실측 데이터와 그려진 결과의 차이, TanStack Start에 어떻게 연결했는지(서버에서 다이어그램 생성, CSS만으로 테마 전환, 이미지를 내려받을 때 색을 다시 써 넣기), 그리고 마지막으로 다른 프로젝트가 브라우저에서 Mermaid 공식 패키지를 교체한 전후 비교다. 코드는 모두 첨부했으니 Mermaid를 쓰는 블로그라면 그대로 참고할 수 있을 것이다.
pele란 무엇인가#
pele는 Obsidian 팀이 오픈소스로 공개한 Mermaid 렌더러로, MIT 라이선스이고 소스 코드는 GitHub의 obsidianmd/pele에 있다. npm 배포자는 kepano, lishid 등 Obsidian 쪽 사람들이며 GitHub Actions로 배포된다. 하는 일은 하나뿐이다. Mermaid 문법을 SVG 문자열로 바꾸는 것.
특징을 몇 가지로 정리하면 다음과 같다.
- 동기식, 의존성 없음:
render(text)가 바로{ svg }를 반환한다. 브라우저도 jsdom도 필요 없다. - Mermaid 12.1에 내장된 모든 다이어그램 유형 지원: 다이어그램마다 파서가 Mermaid 자체의 테스트 명세를 통과하고, Mermaid의 파서를 기준으로 퍼즈 테스트도 한다.
- 색은 CSS 변수로만: 출력된 SVG는
var(--pele-fg),var(--pele-surface)같은 변수로 색을 칠하므로 테마를 바꿔도 다시 그릴 필요가 없다. - 안전: 출력된 SVG에는 script도, 이벤트 핸들러도, HTML도 없고, 다이어그램 안의 어떤 코드도 실행하지 않는다.
- 필요한 다이어그램 유형만 불러올 수 있음:
pele/core에 단일 다이어그램 모듈을 더하거나,pele/lazy로 필요할 때 불러온다.
레이아웃과 시각 스타일은 pele 고유의 것이라, 같은 문법이라도 GitHub나 Mermaid Live Editor에서 보는 것과 다르게 그려진다. 맨 처음의 비교 이미지를 보면 알 수 있다.
WARNING
npm의 pele 1.0.0은 2020년에 나온 다른 프로젝트(LavaMoat의 실험)로, 이 pele와는 관계가 없다. Obsidian의 pele는 2026년 10월 6일에야 1.x를 내기 시작했고, 3주 만에 1.12.0까지 나왔을 만큼 거의 매일 새 버전이 나온다. 내 사이트는 현재 1.3.0에 고정해 두었다.
왜 pele까지 바꿔 왔나#
Mermaid 공식 패키지#
처음에는 Mermaid 공식 패키지에 ELK 레이아웃을 붙여 브라우저에서 렌더링했다. 그릴 수는 있었지만 계속 거슬리는 점이 몇 가지 있었다.
- 너무 무겁다: 렌더러에 ELK까지 더하면 lazy chunk가 약 1.5 MB다. 글에 다이어그램이 하나라도 있으면 독자는 이 덩어리를 받아야 한다.
- 다이어그램이 배경과 같은 색: 기본 노드 채우기가 반투명 보라색이라, 사이트의 어두운 별하늘 위에 겹치면 거의 보이지 않았다. 나조차 글을 볼 때 툴바에서 테마를 바꿔야 했다.
- 가드를 잔뜩 써야 한다: ELK가 레이아웃을 잡을 때 객체를 직렬화하다가 React fiber에 부딪혀 circular JSON이 생겼고, 이 때문에 35줄짜리
JSON.stringify가드를 썼다.
beautiful-mermaid#
8월에는 beautiful-mermaid로 바꿨다. 동기식이고 DOM을 건드리지 않으며, 출력 SVG도 CSS 변수로 색을 칠하기 때문에 렌더링을 통째로 서버로 옮길 수 있었다. 다이어그램은 서버에서 다 그려지고 브라우저는 SVG 문자열만 받으니, client 쪽 JS가 20.18 MB에서 18.80 MB로 줄었다.
문제는 레이아웃이었다. 내부적으로 똑같이 ELK를 쓰기 때문에 조금만 복잡해져도 연결선이 엇갈리고, 선 위의 레이블이 작은 상자 하나하나에 담겨 비좁아 보였다. 게다가 크기도 작지 않아서 압축 후에도 1.5 MB였다.
pele#
10월에 사이트를 개편하면서 사이트 전체의 보라색을 걷어 내야 했고, 다이어그램 블록도 다시 만들어야 해서 이참에 pele로 바꿨다. 동기식, DOM을 건드리지 않음, 색은 CSS 변수라는, 렌더링을 서버로 옮길 수 있게 해 준 beautiful-mermaid의 특성은 그대로 유지하면서도 크기는 훨씬 작고 결과물도 훨씬 깔끔하다.
실측: beautiful-mermaid와 비교#
사이트의 글 44편, 다섯 개 언어에 있는 Mermaid 다이어그램 136개를 전부 추출하고(flowchart 107개, graph 19개, sequenceDiagram 10개), 같은 머신에서 Node로 pele 1.3.0과 beautiful-mermaid 1.1.3을 각각 세 번씩 돌렸다.
| pele 1.3.0 | beautiful-mermaid 1.1.3 | |
|---|---|---|
| 성공 | 136 / 136 | 136 / 136 |
| 전체 완료 | 72–91 ms | 657–693 ms |
| 개당 중앙값 | 0.4 ms | 3.7 ms |
| 개당 p95 | 1.4 ms | 8.2–11.3 ms |
| 가장 느린 하나 | 7–10 ms | 94–102 ms |
| SVG 평균 크기 | 4.8 KB | 7.7 KB |
크기는 esbuild로 각각 압축한 browser bundle을 만들어 쟀다.
| 번들 내용 | 압축 후 | gzip |
|---|---|---|
| pele 전체 다이어그램 유형 | 626 KB | 231 KB |
| pele flowchart만 등록 | 112 KB | 43 KB |
| beautiful-mermaid | 1,522 KB | 457 KB |
내 사이트는 서버에서 렌더링하기 때문에 이 크기는 독자의 브라우저로 넘어가지 않는다. client에서 쓸 계획이라면 pele에 필요한 다이어그램 유형만 등록할 때 차이가 크다. flowchart만 쓴다면 gzip 후 43 KB다.
그려진 결과는 어떻게 다른가#
아래는 CrowdSec 글에 있는 같은 다이어그램이다. 옛 사이트는 렌더러를 바꾸기 전 상태로 고정되어 있어서 아직 beautiful-mermaid로 그린 것이다.
pele로 바꾼 뒤:
똑같이 왼쪽에서 오른쪽으로 흐르는 플로차트인데, pele는 이를 한 줄로 늘어놓고 오가는 두 선을 평행하게 정렬하며 레이블을 선 위에 바로 올려서 엇갈리는 곳이 하나도 없다. 이 다이어그램에는 원래 내가 classDef로 초록색과 노란색을 지정한 노드가 있었는데, 새 버전에서는 그 두 색이 보이지 않는다. 이건 사이트 쪽에서 따로 처리한 것으로, 뒤에서 설명한다.
사이트에 어떻게 연결했나#
내 사이트는 TanStack Start를 쓴다. 전체적인 방식은 이렇다. 서버에서 글 안의 Mermaid 블록을 모두 SVG로 그려 글 데이터와 함께 브라우저로 보내고, 브라우저는 SVG를 페이지에 넣고 확대·축소와 툴바만 처리한다.
서버에서 다이어그램 생성#
렌더링은 한 줄뿐이고, 색은 렌더러에 전혀 들어가지 않는다.
// src/lib/mdx/mermaidSvg.ts
import { render } from 'pele';
export function renderMermaidSvg(body: string): string {
return render(body).svg;
}이를 다시 server function으로 감싸서 글 안의 다이어그램을 모두 그리고, 「다이어그램 내용 hash → SVG」 대응표를 만든다.
// src/lib/mdx/mermaidRender.ts
export const renderMermaidSvgs = createServerFn({ method: 'POST' })
.validator((content: string) => content)
.handler(async ({ data: content }) => {
const fences = extractMermaidFences(content);
if (fences.length === 0) return {};
// 반드시 handler 안에서 동적 import해야 하며, 모듈 최상위에 정적 import가 있으면 안 된다
const { renderMermaidSvg } = await import('./mermaidSvg');
const out: Record<string, string> = {};
for (const body of fences) {
const key = mermaidKey(body);
if (out[key]) continue;
try {
out[key] = renderMermaidSvg(body);
} catch {
// 다이어그램 하나의 문법이 깨졌다고 글 전체가 실패해서는 안 된다
}
}
return out;
});이 대응표는 글 데이터를 불러올 때 함께 가져와 query와 같이 브라우저로 보내고, 다이어그램 컴포넌트는 표를 바로 조회한다. SVG가 이미 서버에서 그려져 있으니 SSR HTML에도 들어가고, 독자는 페이지를 열자마자 JS를 기다리지 않고 다이어그램을 볼 수 있다.
꼭 피해야 할 함정이 하나 있다. 렌더러는 동적 import로만 불러와야 한다. 처음 시도했을 때 SSR 쪽은 정적 import, 브라우저 쪽은 동적 import로 했더니 같은 모듈이 두 가지 방식으로 참조되는 바람에 rollup이 이를 분리하지 못하고 메인 chunk에 그대로 합쳐 버렸다. 글 페이지의 라우트 chunk가 127 KB에서 1,613 KB로 폭증했다. 전부 server function 안의 동적 import로 바꾼 뒤에는 client 쪽에서 렌더러 코드를 전혀 찾을 수 없게 됐다.
테마 전환은 CSS 변수만 바꾼다#
pele가 출력하는 SVG는 --pele-* 변수로 색을 칠하고, 값은 바깥쪽 class가 제공한다. 내 사이트는 테마를 두 개만 남겼다. 밤(사이트의 배색)과 종이(밝은 색, 이미지로 내려받아 다른 곳에 붙이기 좋다)다.
.mm-theme-ink {
--pele-bg: transparent;
--pele-fg: var(--paper);
--pele-muted: var(--paper-3);
--pele-line: var(--paper-2);
--pele-surface: rgba(var(--paper-rgb), 0.05);
--pele-surface-alt: rgba(var(--paper-rgb), 0.1);
--pele-border: var(--paper-3);
--pele-accent: var(--amber);
--pele-font: var(--cjk-font);
--pele-font-mono: var(--mono-font);
--pele-radius: 2px;
}독자가 툴바에서 「종이」로 바꾸면 mm-theme-ink를 mm-theme-light로 바꿀 뿐이다. 다이어그램을 다시 그릴 필요가 없으니 브라우저 쪽에는 렌더러도 필요 없다.
이미지를 내려받을 때는 색을 다시 써 넣어야 한다#
이 방식에는 부작용이 하나 있다. SVG 자체에는 색이 없고, 색은 바깥쪽 CSS가 준다. 독자가 「SVG 내려받기」를 눌러 받은 파일은 페이지를 벗어나는 순간 변수에 값이 없어서 pele 기본값인 흰 바탕에 검은 글씨로 돌아간다. 그래서 내려받기 전에 그 시점에 계산된 변수 값을 SVG 자체에 써 넣는다.
const PELE_VARS = ['--pele-bg', '--pele-fg', '--pele-muted', '--pele-line', '--pele-surface',
'--pele-surface-alt', '--pele-border', '--pele-accent', '--pele-font', '--pele-font-mono', '--pele-radius'];
function standaloneSvg(svg: SVGSVGElement): SVGSVGElement {
const clone = svg.cloneNode(true) as SVGSVGElement;
const cs = getComputedStyle(svg);
for (const name of PELE_VARS) {
const value = cs.getPropertyValue(name).trim();
if (value) clone.style.setProperty(name, value);
}
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
return clone;
}PNG 내려받기도 먼저 이 SVG를 canvas에 그리고, 바탕색은 테마를 따른다. 밤은 사이트의 검은색, 종이는 흰색이다.
내가 다이어그램에 지정한 색#
예전 글에서 나는 classDef로 노드에 색을 자주 지정했다. 연두색은 성립, 연한 빨강은 제외, 연노랑은 핵심을 뜻했고, 모두 11편에서 쓰고 있었다. 개편 뒤 사이트 전체에는 검정, 흰색, 호박색 하나만 남았기 때문에 이 색들을 그대로 두면 아주 튄다. 하지만 내용의 일부라서 그냥 빼 버릴 수도 없다.
그래서 서버에서 다이어그램을 생성할 때 한 단계를 더 넣었다. 각 노드에 내가 당시 지정한 색을 읽어 내고, 색상(hue)으로 그 노드가 다이어그램에서 무엇을 뜻하는지 판단해 네 가지 역할로 분류한 다음, 원래 색을 지우고 대응하는 class를 붙여 사이트의 CSS가 어떻게 그릴지 정하게 했다. 즉 색이 노드의 모양을 직접 정하는 게 아니라, 먼저 「이 노드가 뜻하는 것」으로 번역되는 것이다.
| 원래 지정한 색 | 역할 | 표현 방식 |
|---|---|---|
| 초록 | 성립 | 가장 밝은 테두리, 아주 옅은 바탕 |
| 노랑, 주황 | 핵심 | 위와 같고, 왼쪽 위에 호박색 점 하나 추가, 다이어그램 하나에 하나만 |
| 파랑, 보라 | 보조 | 테두리와 글자를 한 단계 낮춤 |
| 빨강 | 제외 | 점선 테두리, 바탕 없음, 글자를 한 단계 더 낮춤 |
회색조와 알아볼 수 없는 표기는 기본값대로 그린다. 앞의 다이어그램에서 Jarvis bot 왼쪽 위의 호박색 점이 바로 이렇게 생긴 것이다. 글 자체는 한 글자도 고칠 필요가 없고, 이미지를 내려받을 때 이 스타일도 함께 써 넣어진다.
Mermaid 공식 패키지와 정면 비교: 브라우저에서 20배 빠르다#
나는 친구와 함께 오픈소스 프로젝트 tessera도 관리하고 있다. 자체 호스팅하는 파일·노트 공유 서비스로, 공유한 것은 시간이 되면 알아서 지워지며 아직 개발 중이다. 노트에 Mermaid를 쓸 수 있는데, 원래는 Mermaid 공식 패키지를 썼다. 블로그를 다 바꾼 뒤 함께 협업하는 동료에게 tessera도 pele로 바꿔 달라고 부탁했고, PR #148이 10월 28일에 병합됐다.
tessera의 상황은 블로그와 정반대다. 노트는 사용자가 브라우저에서 입력하면서 바로 보는 것이라 다이어그램은 client에서만 그릴 수 있고, 내용도 다른 사람이 붙여 넣은 것이라 신뢰할 수 없다. 글자 하나를 고칠 때마다 다이어그램을 다시 그려야 하고, 다른 사람이 공유한 노트를 열 때도 브라우저가 먼저 렌더러를 내려받아야 한다. 그래서 여기서는 렌더러의 크기와 속도가 그대로 체감된다.
바꾼 효과#
PR에서는 크기를 재지 않았기 때문에 내가 직접 한 번 쟀다. tessera가 바꾸기 전과 후의 코드 그대로(같은 버전, 같은 설정) esbuild로 필요할 때 불러오는 모듈로 번들링하고, Edge에서 사이트의 다이어그램 136개를 차례로 그리기를 각각 세 번씩 돌렸다.
| Mermaid 11.17.2(바꾸기 전) | pele 1.12.0(바꾼 뒤) | |
|---|---|---|
| 첫 다이어그램을 그리기 위해 내려받는 JS | 821 KB(gzip 224 KB) | 160 KB(gzip 67 KB) |
| 첫 다이어그램 표시(로딩 포함) | 110–132 ms | 29–35 ms |
| 136개 전부 그리기 | 4.1–4.5초 | 0.19–0.24초 |
| 개당 중앙값 | 28 ms | 1.1–1.4 ms |
| SVG 평균 크기 | 27.5 KB | 4.8 KB |
| 라이트/다크 모드 전환 | 다이어그램마다 다시 그림 | 다시 그릴 필요 없음 |
다이어그램이 있는 노트를 하나 열 때 내려받아야 할 렌더러는 원래의 30% 정도로 줄었다. 입력하면서 볼 때 매번 다시 그리는 시간은 거의 30밀리초에서 1밀리초 남짓으로, 약 20배 빨라졌다. 이 시간은 로컬에서 불러오고 속도 제한이 없는 상태로 잰 것이라 주로 JS를 파싱하고 실행하는 비용을 반영한다. 실제로 네트워크를 거치면 Mermaid가 더 내려받아야 하는 150여 KB는 따로 계산해야 한다. 136개 다이어그램은 양쪽 모두 전부 그리는 데 성공했고, 바꾼 탓에 깨진 다이어그램은 없었다.
연결 방법#
연결 방식은 pele의 또 다른 사용법으로, 브라우저에서 필요할 때 불러온다.
// 그릴 다이어그램이 있을 때만 렌더러를 불러오고, 메인 bundle에는 넣지 않는다
const { renderAsync } = await import('pele/lazy');
// 붙여 넣은 내용은 신뢰할 수 없다: 출력에 링크를 넣지 않고 이미지 요청도 보내지 않는다
const rendered = await renderAsync(trimmed, {
images: false,
links: false,
});색도 마찬가지로 CSS 변수에 맡긴다. tessera에는 원래 디자인 token 체계가 있어서 :root에서 --pele-*를 바로 연결했다. 라이트/다크 모드를 바꾸거나 강조색을 바꾸면 다이어그램도 다시 그리지 않고 따라 바뀐다.
:root {
--pele-bg: var(--card);
--pele-fg: var(--foreground);
--pele-surface: var(--muted);
--pele-border: var(--border);
--pele-accent: var(--primary);
/* 나머지 변수도 같은 방식으로 기존 token에 연결한다 */
}지운 코드#
빨라진 것 말고도 유지보수가 훨씬 편해졌다. 렌더링용 hook에서 66줄을 지우고 12줄만 다시 넣었는데, 지운 코드는 거의 다 Mermaid를 우회하려고 쓴 것이었다.
- 렌더링할 때마다 중복되지 않는 id를 만들어야 했다.
- Mermaid는 렌더링에 실패하면 크기를 재는 데 쓰는 컨테이너를
body에 남겨 두고, 이게 점점 쌓인다(mermaid-js/mermaid#4730). 직접 치워야 했다. - Mermaid가 출력하는 SVG는 너비가 100%로 적혀 있고 실제 크기는 inline style에 숨어 있어서, 정규 표현식으로
width,height로 고쳐 써야 확대·축소 창에서 잴 수 있었다. pele의 SVG는 처음부터 너비와 높이를 갖고 있다. - 테마가 바뀌면 다이어그램을 통째로 다시 렌더링해야 했다.
lockfile에서도 d3, cytoscape, KaTeX, DOMPurify 등 Mermaid가 끌고 들어온 80여 개 패키지가 빠졌고, 새로 추가된 건 pele 하나뿐이며 pele 자체는 의존성이 전혀 없다. 같은 렌더러를 블로그는 서버에서, tessera는 브라우저에서 쓰는데, 두 가지 연결 방식 모두 아주 단순하다.
주의할 점#
- 버전은 고정하자: pele는 지금 업데이트가 매우 빨라서 3주 동안 열몇 개의 버전이 나왔다. 버전을 고정해 두고, 업그레이드하기 전에 변경 사항을 확인한 뒤 자기 다이어그램으로 한 번 돌려 보길 권한다.
- Mermaid와 생김새가 다르다: pele는 문법 호환만 약속하며 레이아웃과 시각 스타일은 pele 고유의 것이다. 독자가 다이어그램을 GitHub의 렌더링 결과와 대조해 볼 것 같다면 각오해 두는 게 좋다.
- 오류 메시지에 원본 코드가 인용된다:
render()는 문법 오류나 지원하지 않는 유형을 만나면PeleError를 던지는데, 메시지에 다이어그램 원문이 들어 있다. 표시할 때는 일반 텍스트로 다루고 HTML에 그대로 넣지 말자. - 브라우저에서는
mount()를 쓸 수 있다: CSS에서 글꼴을 읽고 컨테이너 너비에 맞춰 레이아웃을 조정해 준다. 내 사이트는 서버에서 다이어그램을 생성하므로 쓰지 않았다.
바꾼 뒤 지금까지 사이트의 다이어그램 136개는 모두 정상이고, 글 페이지도 더 이상 어떤 렌더러도 짊어질 필요가 없다. tessera 쪽도 내려받아야 할 렌더러가 원래의 30%만 남았다. 평소 Obsidian으로 노트를 쓰고 있는데, 블로그의 다이어그램까지 결국 같은 팀이 만든 것을 쓰게 될 줄은 몰랐다.
- pele 공식 사이트와 문서pele.run
- pele 테마 변수pele.run
- pele 소스 코드GitHub
- tessera를 pele로 바꾼 PRLimatura/tessera#148
- Mermaidmermaid.js.org