-
코드 위키의 빈 박스와 깨진 다이어그램 — mermaid 검증 2중 안전망IT 2026. 5. 30. 21:00
코드 위키를 만들었다. 마크다운으로 페이지를 적고, SPA renderer가 페이지를 띄우고, 그 안의 mermaid 다이어그램이 브라우저에서 그려진다. 며칠 문제없이 작동하던 시스템에서 처음으로 보이는 깨짐은 "빈 박스"다. 페이지 한가운데에 다이어그램이 들어가야 할 자리에, 아무것도 없는 사각형이 있다. 에러 메시지도, 경고도, 콘솔 로그도 없다.
그 빈 박스의 원인은 대개 한 가지다 — mermaid 코드 블록의 첫 줄 토큰이 오타거나 mermaid가 모르는 단어다.
flochart TD(오타),flow TD(존재하지 않는 키워드),graph LR(존재함). 첫 줄이 깨지면 mermaid는 조용히 포기한다. SPA는 그 포기를 받아들이고 자리만 비워둔다.그런데 mermaid 다이어그램이 깨지는 양상은 사실 두 가지다. 침묵 실패(빈 박스를 보여주는 실패)와 가시 실패(반쯤 그려진 깨진 그림을 보여주는 실패). 빈 박스는 첫 토큰을 모르면 즉시 발생하고, 깨진 그림은 첫 토큰은 통과한 채 안쪽 syntax가 잘못됐을 때 나온다 — 화살표가 한쪽 끝만 있거나, 노드 라벨이 비었거나, edge가 한 두 개 빠진 그림을 그린다. 둘 다 사용자에게는 신뢰 손실로 보이지만, 잡는 도구가 다르다.
이 글은 그 두 종류의 깨짐을 2중 검증으로 잡는 구조를 본다 — 첫 토큰 사전 매칭(가장 싸고, 모든 저장 시)으로 빈 박스를 막고, mmdc(mermaid 공식 CLI, 실제 렌더링까지 돌려 무겁지만 정확, nightly)로 가시 실패까지 잡는다.
1. 두 종류의 실패 — 빈 박스와 깨진 그림
침묵 실패는 mermaid 첫 토큰이 사전에 없을 때 발생한다. mermaid는 첫 토큰으로 어떤 parser로 들어갈지 결정하는데, 사전에 없는 단어면 어떤 parser도 받지 않고 조용히 포기한다. SPA에는 빈 박스만 남는다. 사용자가 그 자리를 보고서야 잘못이 드러난다 — 그러나 에러 메시지가 없으니 "왜 안 보이지?"라는 의문이 먼저 든다.
가시 실패는 첫 토큰은 통과한 채 안쪽 syntax가 잘못됐을 때 발생한다.
flowchart TD로 시작했지만A --> --> B(이중 화살표 오타)나node[label without end(괄호 안 닫힘) 같은 패턴. mermaid parser가 시작은 했지만 중간에 멈춘다. 마지막 valid한 지점까지 부분 렌더링한 그림이 나온다 — 화살표가 한쪽 끝만 있거나, 노드 라벨이 비었거나, edge가 한 두 개 빠진 그림.두 실패의 차이는 "사용자 신뢰 손실의 즉시성"에 있다. 빈 박스는 즉시 신뢰가 무너진다 — "위키가 망가졌네". 깨진 그림은 즉시 무너지지는 않지만 누적된다 — "이 다이어그램 좀 이상한데", "여기 화살표가 빠졌는데" 같은 의심이 쌓인다. 둘 다 막아야 위키의 시각적 신뢰가 유지된다.
2. 2중 검증 구조
위 흐름은 두 종류의 실패를 어떻게 분담해서 잡는지를 한 장에 담는다. Tier 1은 첫 토큰 사전 매칭 — 한 페이지에 100ms 미만, 모든 저장/commit에서 자동. 사전에 없는 토큰이면 그 자리에서 fail, 빈 박스 사고를 즉시 차단한다. Tier 1을 통과한 다이어그램만 Tier 2로 간다. Tier 2는
mmdcCLI를 호출해 실제로 headless Chrome으로 SVG를 그려본다 — parse만이 아니라 렌더까지. 한 다이어그램에 ~1초, nightly에서만 자동. 렌더가 실패하면 그 자리에서 fail, 깨진 그림이 SPA에 노출되기 전에 차단한다.3. Tier 1 — 첫 토큰 사전 매칭 (parser 안 짜기)
Tier 1의 영리한 부분은 parser를 직접 짜지 않는다는 점이다. mermaid syntax 전체를 검증하려면 mermaid parser를 다시 구현하는 셈이지만, 그건 mermaid 패키지가 이미 갖고 있다. Tier 1은 그 일을 안 한다 — 대신 코드 블록의 첫 토큰만 본다. 첫 토큰이
flowchart·graph·sequenceDiagram·classDiagram·erDiagram·gantt같은 mermaid가 아는 사전 안에 있으면 통과. 그게 아니면 fail.import re # mermaid가 받는 valid한 다이어그램 종류 # parser 전체를 다시 짜는 게 아니라 — 첫 토큰만 본다 KNOWN_TOKENS = { "flowchart", "graph", "sequenceDiagram", "classDiagram", "stateDiagram", "stateDiagram-v2", "erDiagram", "gantt", "pie", "journey", "gitGraph", "mindmap", "timeline", } # 백틱 3개 + mermaid로 시작하는 펜스 블록 추출 FENCE = re.compile(r"```mermaid\n(.*?)\n```", re.DOTALL) def tier1_first_token(md_text: str) -> list[str]: """첫 토큰이 사전에 없는 블록만 리턴. 매 저장 시 자동.""" bad = [] for block in FENCE.findall(md_text): for line in block.splitlines(): line = line.strip() if not line or line.startswith("%%"): continue first = line.split()[0] # "flowchart" / "graph" / 오타 if first not in KNOWN_TOKENS: bad.append(first) break # 한 블록당 첫 토큰만 return bad30줄도 안 되는 함수 하나. 한 페이지에 30ms 미만, nightly에서 전체 페이지를 훑어도 1초 이내. 매 저장과 commit hook에 박아 두면 빈 박스 사고가 사용자에게 노출되기 전에 차단된다.
4. Tier 2 — mmdc로 렌더링까지 검증
Tier 1을 통과해도 안쪽 syntax는 깨져 있을 수 있다. 그 검증은 우리가 만든 휴리스틱이 아니라 mermaid 자신이 실제로 그려 봐야 가장 정확하다 — parse만 성공해도 렌더 단계에서 미묘하게 깨지는 경우(예: foreignObject 처리, 폰트 미존재로 layout 어긋남, 테마와 fill 색의 대비 실패)가 있어, parse 통과만으로는 "사용자가 안 깨진 그림을 본다"를 보장하지 못한다. Tier 2가 잡고 싶은 건 이 마지막 한 발이다.
도구는
@mermaid-js/mermaid-cli(이하mmdc)다. mermaid 메인테이너가 직접 유지하는 공식 CLI로, Puppeteer로 headless Chrome을 띄워 다이어그램을 실제로 SVG/PNG/PDF로 렌더링한다. 운영 브라우저(Chromium 계열)와 같은 엔진이라 SPA가 그릴 결과와 정합이 가장 높다 — "Tier 2가 통과했으면 사용자도 본다"의 신뢰가 여기서 나온다.왜 parse만 보면 안 되나 — mmdc가 추가로 잡는 것
mermaid.parse()같은 parse-only 도구는 syntax 단계의 에러는 잡지만, 렌더 단계의 실패는 못 잡는다. 우리 위키에서 실제로 본 mmdc만이 잡는 사고들:- foreignObject 누락 렌더 — mermaid가 노드 라벨을
<foreignObject>로 그리는데,<img>컨텍스트에서는 이게 무시돼 라벨이 통째로 비는 사고. parse는 통과해도 시각적으로 깨진다. mmdc는 실제로 그려서 SVG를 떨구므로, 떨군 결과를 보면 안다. - 테마 × fill 색 저대비 —
theme: 'dark'환경에 라이트 톤classDef fill을 쓰면 텍스트가 회색에 가까워져 안 보인다. parse는 무관하다. mmdc는 렌더된 SVG로 후처리(예: 픽셀 대비 측정)까지 연결할 수 있다. - 폰트 fallback — 한글 폰트가 환경에 없으면 글자가 □(tofu)로 나온다. parse는 통과. mmdc 출력 SVG의 텍스트 너비를 확인하면 잡힌다.
요약하자면 parse는 "mermaid가 받아들이는 syntax인가"를 묻고, mmdc는 "그걸 그렸을 때 사람이 보는 결과가 멀쩡한가"를 묻는다. Tier 2의 본분은 후자다.
설치와 기본 호출
# 글로벌 설치 — 한 번 npm install -g @mermaid-js/mermaid-cli # 검증: input.mmd → output.svg, 실패 시 exit code non-zero mmdc -i diagram.mmd -o diagram.svg # stderr에는 parse/render 에러가 line:col과 함께 떨어진다 # 예: "Parse error on line 5: ... Expecting 'NEWLINE', got 'STR'"nightly에서 위키 전체를 훑는 검증은 이 한 줄을 펜스 블록마다 돌리면 된다.
--quiet로 표준 출력을 죽이고, exit code만으로 통과/실패를 판단한다.ARM·헤드리스 환경의 gotcha —
-pPuppeteer configmmdc는 Puppeteer가 번들로 받은 Chromium을 쓰는데, ARM64 호스트(예: DGX Spark Linux/aarch64)에서는 그 바이너리가 환경에 안 맞아 못 띄우는 경우가 있다. 시스템에 이미 설치된 Chromium(또는 Playwright의 Chromium)을 가리키면 우회된다:
# .puppeteer.json { "executablePath": "/home/abcd/.cache/ms-playwright/chromium-1217/chrome-linux/chrome", "args": ["--no-sandbox", "--disable-setuid-sandbox"] } # mmdc 호출 시 지정 mmdc -i diagram.mmd -o diagram.svg -p .puppeteer.json같은 흐름으로
-c .mermaidrc.json에 themeVariables(fontFamily 등)를 박으면 렌더 결과의 폰트도 통제된다.운영 cost와 cadence
비용은 다이어그램당 ~1초. 대부분이 Chromium startup이라, 다이어그램이 많아도 거의 선형으로 증가한다(같은 mmdc 호출 안에서 여러 다이어그램을 처리하면 startup이 한 번에 amortize). 위키에 다이어그램이 200개라도 nightly 3~4분, cron이 흡수 가능한 수준이다.
매 저장 시 돌리면? 다이어그램 하나 고치는 데 1초씩 hang은 받아들이기 어렵다. 그래서 Tier 1(매 저장, 100ms)과 Tier 2(nightly, ~1s/block)로 가르는 것이다. Tier 2의 결과는 morning report로 받아 깨진 다이어그램만 손보면 된다 — 발견까지 최대 24시간 지연되지만, 가시 실패(반쯤 그려진 그림)는 즉시 사고가 아니라 누적 신뢰 손실이라 이 cadence가 맞다.
호출 코드
import subprocess from pathlib import Path def tier2_mmdc(block: str, *, work: Path) -> str | None: """mmdc로 렌더 검증. 실패 시 에러 메시지 리턴, 성공 시 None. 설치: npm install -g @mermaid-js/mermaid-cli nightly에서만 실행 (다이어그램당 ~1초, Chromium startup 포함). """ src = work / "diagram.mmd" out = work / "diagram.svg" src.write_text(block, encoding="utf-8") r = subprocess.run( ["mmdc", "-i", str(src), "-o", str(out), "-p", str(work / ".puppeteer.json"), "-c", str(work / ".mermaidrc.json"), "--quiet"], capture_output=True, text=True, timeout=30, ) # exit 0 + 출력 파일 존재 = 통과 (parse + render 양쪽 다 OK) if r.returncode == 0 and out.exists(): return None # stderr에 line:col 포함된 에러 메시지 return r.stderr.strip() or "mmdc failed (no stderr)" def validate_page(md_text: str, *, work: Path, full: bool = False) -> list[dict]: """Tier 1은 항상, Tier 2는 full=True(nightly)에서만.""" bad = [] for block in FENCE.findall(md_text): first = next((l.strip().split()[0] for l in block.splitlines() if l.strip() and not l.strip().startswith("%%")), "") if first not in KNOWN_TOKENS: bad.append({"tier": 1, "reason": f"unknown token: {first}"}) continue # Tier 1 fail이면 Tier 2 안 돈다 — 같은 실패 두 번 확인은 낭비 if full: err = tier2_mmdc(block, work=work) if err: bad.append({"tier": 2, "reason": err}) return bad핵심 결정 두 가지이다. 첫째, Tier 2는
full=True일 때만 돈다 — 매 저장 시는 Tier 1만, nightly에서만 Tier 2까지 돌린다. 둘째, Tier 1 fail이면 Tier 2 안 돈다 — 첫 토큰부터 모르는 블록을 mmdc에 넣으면 같은 실패를 1초 비용으로 한 번 더 확인하는 셈이다.5. 왜 2-tier로 분리하는가 — cost와 cadence
한 도구로 모든 걸 잡으려면
mmdc를 매 저장 시 호출하면 된다. 그런데 그건 비용이 비싸다. headless Chromium startup만으로도 ~1초 — 매 저장 시 1초 hang을 운영자가 받아들이지 않는다. 매 commit hook에 박으면 commit 자체가 느려져 운영 부담이 누적된다.그래서 cost가 다른 두 도구를 분리한다. 빠른 쪽(Tier 1)은 가장 자주 발생하는 실패(빈 박스)를 매 저장에서 잡고, 무거운 쪽(Tier 2)은 그 뒤 깨짐을 매일 한 번 잡는다. 둘 다 깨끗하면 SPA 렌더링 안전. 각 tier가 잡는 실패 종류가 다르고 cost가 다르니, cadence도 다른 게 자연스럽다.
이 분리의 또 다른 효과는 fail이 났을 때 진단의 명확성이다. Tier 1 fail이면 "첫 토큰 오타" — 작성자가 5초 안에 고친다. Tier 2 fail이면 "안쪽 syntax/렌더 에러" — mmdc가 stderr에 정확히 어느 줄에 어떤 문제인지 알려주고, 렌더링 단계라면 부분 SVG까지 떨궈서 무엇이 잘못 그려졌는지 사람이 바로 본다. tier별 메시지가 다르니 진단도 자동으로 분기된다.
6. 어떤 문제를 해결하고 어떤 효과가 있나
2중 검증의 효과는 한 줄로 — "빈 박스도, 깨진 그림도, 사용자가 보기 전에 차단한다". Tier 1이 침묵 실패(가장 자주)를 매 저장에서 잡고, Tier 2가 가시 실패(가끔 발생하지만 잡지 않으면 누적되는 신뢰 손실)를 매일 잡는다. 둘 다 통과한 페이지만 SPA에 도달한다.
비용 구조도 운영 가능한 모양이다. Tier 1은 사실상 0초, 모든 commit hook에 박아도 무시할 만하다. Tier 2는 nightly에서 한 번, 위키 전체 다이어그램 수에 비례한 비용이지만 매일 한 번이라 흡수된다. 매 저장 시는 가볍게, nightly에서는 정확하게 — 이 구조가 운영 부담 없이 시각적 신뢰를 유지하는 자리다.
여기서 잡지 못하는 것도 분명히 짚어 두면 좋다. 의미적 어긋남 — 다이어그램이 syntax 깨끗해도, 다이어그램 안의 노드 이름이 코드와 안 맞을 수 있다. "
verify_token" 노드가 다이어그램에 있는데 코드에서는 그 함수가 사라졌다면, mermaid는 통과시킨다. 이 종류의 의미적 어긋남은 별도의 anchor/grounding 검증이 답할 영역이다. 2중 검증은 mermaid 자체의 syntax 안전망까지가 본분이다.7. 정리
mermaid 다이어그램의 깨짐은 빈 박스(침묵)와 깨진 그림(가시) 두 가지. 2중 검증으로 각각을 다른 문제를 잡는다 — Tier 1은 첫 토큰 사전 매칭(100ms, 매 저장), Tier 2는
mmdc로 실제 렌더링까지 돌려본다(~1s, nightly). 둘 다 깨끗하면 SPA에 안전한 다이어그램만 도달한다. parser를 다시 짜지 않으면서 가장 자주 발생하는 실패를 매 저장에 막고, 렌더 단계의 가시 실패는 mmdc가 매일 한 번 잡는 운영 가능한 안전망이다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
agent가 wiki로 task를 풀 수 있느냐가 ground truth — with-wiki vs without-wiki로 측정하기 (0) 2026.05.31 같은 소스에서 매번 같은 위키가 나와야 한다 — 위키 생성 파이프라인의 흔들림 제어 (0) 2026.05.31 외부 API 100%, 내부는 trend — 양적 검증을 두 층으로 가르는 이유 (0) 2026.05.30 위키가 거짓말하지 않게 — 모든 코드 인용에 file:line을 강제하는 doctrine (0) 2026.05.30 file.py:LINE anchor가 진짜 그 줄을 가리키는가 — 매일 AST와 대조해서 RAG 거짓말 끊기 (0) 2026.05.30 LLM이 다른 LLM의 답을 채점하는 법 — judge prompt·rubric.json·3가지 안티패턴 (0) 2026.05.29 LLM 검증을 싸게 — haiku 1차 + sonnet 재검증 + Redis SHA 캐시 (1) 2026.05.29 AI 에이전트가 보는 surface를 8개로 좁히다 — deep-wiki MCP gateway (0) 2026.05.28 Redis — 메모리 안의 작은 사전, 그리고 우리가 그것을 쓰는 자리들 (0) 2026.05.28 NetworkX 대표 알고리즘 3선 — 코드 베이스 분석에서 한 줄로 끝나는 일들 (0) 2026.05.27 - foreignObject 누락 렌더 — mermaid가 노드 라벨을