본문으로 건너뛰기

Next.js 블로그에 Mermaid 다이어그램 도입기 (npm 없이 CDN으로)

·8 min read

발단: 다이어그램이 자꾸 깨진다

Presigned URL 포스트에 아키텍처 비교 다이어그램을 넣었는데, 배포하고 나서 보니까 박스가 어긋나 있었다.

┌──────────┐   100MB 파일    ┌──────────┐
│ 브라우저  │ ──────────────→ │ 백엔드    │
└──────────┘                 └──────────┘

코드 에디터에서는 딱 맞게 그렸는데, 브라우저에서 보면 칸이 틀어진다. 원인은 한글 문자 너비 문제였다.

원인: 한글은 2칸짜리다

ASCII 박스 그리기(┌─┐│└┘)는 모노스페이스 폰트에서 모든 문자가 1칸이라고 가정하고 그린다. 근데 한글은 2칸(full-width) 을 차지한다.

예를 들어 브라우저는 4글자처럼 보이지만 실제로는 8칸을 쓴다. 그래서 박스를 ──────────(10칸)으로 그려도 안에 브라우저를 넣으면 칸이 모자라서 어긋난다.

터미널에서도 한/영 혼합 정렬이 안 맞는 그 이유랑 똑같다.

선택지: 수정 vs 도입

두 가지 방법이 있었다.

  1. ASCII 박스를 고친다 — 한글 너비에 맞게 박스를 다시 그린다. 빠르긴 한데 임시방편이고, 앞으로 다이어그램 포스트 쓸 때마다 같은 문제가 반복된다.

  2. Mermaid를 도입한다 — 텍스트 문법으로 다이어그램을 선언하면 SVG(해상도 무관하게 선명한 벡터 이미지 형식)로 렌더링해준다. 한글 문제 없고, 유지보수도 편하다.

앞으로도 다이어그램 글을 쓸 예정이라 Mermaid를 도입하기로 했다.

구현 계획

Mermaid를 MDX에 넣는 방법은 여러 가지인데, 이 블로그 구조에 맞게 설계했다.

핵심 흐름:

` ```mermaid ` 블록
  ↓ (remark 플러그인)
<Mermaid chart="..." /> JSX 컴포넌트
  ↓ (클라이언트 컴포넌트)
SVG 렌더링

이 블로그는 마크다운을 처리할 때 두 단계를 거친다.

  • remark — 마크다운 텍스트를 파싱해서 AST(코드를 트리 구조로 표현한 것)로 변환하는 단계. 문법 구조를 다룬다.
  • rehype — remark가 만든 AST를 HTML로 변환하는 단계. rehype-pretty-code처럼 코드 블록을 꾸미는 작업이 여기서 일어난다.

rehype-pretty-code가 코드 블록을 가로채기 전에, remark 단계에서 먼저 mermaid 블록을 <Mermaid> 컴포넌트로 변환한다. 그래야 코드 하이라이터가 mermaid를 건드리지 않는다.

remark 플러그인

// src/lib/remark-mermaid.ts
import { visit } from "unist-util-visit";
import type { Root } from "mdast";
import type { MdxJsxFlowElement } from "mdast-util-mdx-jsx";
 
export function remarkMermaid() {
  return (tree: Root) => {
    visit(tree, "code", (node, index, parent) => {
      if (node.lang !== "mermaid" || index === undefined || !parent) return;
 
      const mermaidNode: MdxJsxFlowElement = {
        type: "mdxJsxFlowElement",
        name: "Mermaid",
        attributes: [
          { type: "mdxJsxAttribute", name: "chart", value: node.value },
        ],
        children: [],
      };
 
      parent.children.splice(index, 1, mermaidNode as never);
    });
  };
}

visit은 AST 트리를 순회하는 유틸 함수다. code 타입 노드(코드 블록) 중 lang"mermaid"인 걸 찾아서 <Mermaid chart="..."> JSX 엘리먼트로 교체한다.

page.tsx에 플러그인 등록

// src/app/blog/[slug]/page.tsx
import { remarkMermaid } from "@/lib/remark-mermaid";
 
// MDXRemote options
remarkPlugins: [remarkMermaid, remarkGfm],

remarkGfm은 마크다운 표(| 열1 | 열2 |), 체크박스(- [ ]), 취소선(~~텍스트~~) 같은 확장 문법을 처리하는 플러그인이다. 기본 마크다운 스펙에는 없는 기능들인데 GitHub에서 널리 쓰이다 보니 사실상 표준이 됐다. remarkMermaid를 앞에 두면 mermaid 블록이 먼저 컴포넌트로 바뀐 뒤 나머지 처리가 진행된다.

npm 설치 실패

pnpm add mermaid를 실행했는데 pnpm store 버전 불일치 에러가 났다. mermaid는 d3, dagre 등 무거운 패키지를 딸려오는데, 여기에 store 버전 충돌까지 겹쳐서 설치가 계속 실패했다.

mermaid는 d3, dagre 등 무거운 패키지를 딸려오기 때문에 의존성 트리가 복잡해서 생기는 문제였다.

CDN 방식으로 전환

npm 패키지 대신 CDN(전 세계 서버에 분산 저장된 파일을 가까운 서버에서 빠르게 제공하는 네트워크)에서 동적 로드하는 방식으로 바꿨다. 오히려 이쪽이 더 나은 점도 있다.

  • npm 패키지 없음 → 빌드 시간, 번들 크기에 영향 없음
  • 다이어그램 있는 페이지에서만 로드 → 불필요한 JS 다운로드 없음
  • 의존성 충돌 걱정 없음
// src/components/mermaid-diagram.tsx
"use client";
 
import { useEffect, useRef, useState } from "react";
 
let scriptLoaded = false;
let scriptLoading = false;
const pendingCallbacks: Array<() => void> = [];
 
function loadMermaidScript(callback: () => void) {
  if (scriptLoaded) { callback(); return; }
  pendingCallbacks.push(callback);
  if (scriptLoading) return;
 
  scriptLoading = true;
  const script = document.createElement("script");
  script.src = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js";
  script.onload = () => {
    scriptLoaded = true;
    pendingCallbacks.forEach((cb) => cb());
    pendingCallbacks.length = 0;
  };
  document.head.appendChild(script);
}
 
export function Mermaid({ chart }: { chart: string }) {
  const ref = useRef<HTMLDivElement>(null);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    let cancelled = false;
    loadMermaidScript(async () => {
      if (cancelled) return;
      const mermaid = (window as any).mermaid;
      const isDark = document.documentElement.classList.contains("dark");
 
      mermaid.initialize({
        startOnLoad: false,
        theme: isDark ? "dark" : "default",
        fontFamily: "inherit",
      });
 
      const id = `mermaid-${Math.random().toString(36).slice(2)}`;
      const { svg } = await mermaid.render(id, chart);
      if (!cancelled && ref.current) ref.current.innerHTML = svg;
    });
    return () => { cancelled = true; };
  }, [chart]);
 
  return <div ref={ref} className="my-6 flex justify-center [&>svg]:max-w-full" />;
}

scriptLoaded / scriptLoading 플래그로 같은 페이지에 다이어그램이 여러 개 있어도 스크립트를 한 번만 로드한다. pendingCallbacks에 쌓아뒀다가 로드 완료되면 한꺼번에 실행한다.

다크모드는 document.documentElement.classList.contains("dark")로 현재 테마를 읽어서 mermaid에 전달한다.

결과

이제 MDX에서 이렇게 쓰면 된다:

한글이 들어가도 깨지지 않고, 다크모드에서도 자동으로 테마가 바뀐다. 빌드도 정상 통과.

npm 설치가 막혔을 때 CDN을 택한 게 결과적으로 더 깔끔한 해결이었다.