Markdown

지식 데이터 문서 포맷에 따라 AI 파이프라인 구축시 미치는 비용 분석과 전략

How Machine-Readable Document Formats Affect AI Pipeline Performance and Cost


연구 배경
2026년 3월 5일, 국가인공지능전략위원회는 정책 문서를 마크다운(.md) 형식으로 전환하여 작성·관리·공개한다고 발표하였다 1.

이 정책적 전환은 HWP 중심의 시각적 편집 문서가 AI 학습·활용에 적합하지 않다는 문제의식에서 출발한다.

본 연구노트는 이 전환의 기술적 근거를 벤치마크 문헌 분석을 통해 수립하고, AI 시대의 문서 데이터 프로세싱 전략에 필요한 실증 기반(evidence base)을 구축한다.


목차

  1. 왜 문서 포맷이 AI 성능을 결정하는가
  2. 인용 논문의 저명성 및 신뢰도
  3. 비정형 문서의 비용: OCR이 만드는 성능 천장
  4. 구조화된 포맷의 이점: 검색 정확도와 추론 품질
  5. 포맷이 결정하는 토큰 비용: 기업 규모의 체감치
  6. LLM이 인식하는 포맷의 스펙트럼
  7. 종합: 확인된 사실과 미해결 과제
  8. 다음 연구를 위한 방향
  9. 참고문헌

1. 왜 문서 포맷이 AI 성능을 결정하는가

AI 시스템, 특히 검색 증강 생성(RAG) 파이프라인에서 외부 문서는 핵심 입력 자원이다. 같은 정보라도 어떤 형태로 투입하느냐에 따라 검색 정확도, 추론 품질, 토큰 비용이 극적으로 달라진다.

%%{init: {'theme': 'neutral', 'themeVariables': {'fontSize': '14px', 'fontFamily': 'IBM Plex Sans, Pretendard, sans-serif'}}}%%
flowchart LR
    subgraph legacy["비정형 문서 파이프라인"]
        direction TB
        A["PDF / HWP / 스캔본"] --> B["OCR 텍스트 추출"]
        B --> C["포맷팅 노이즈 발생"]
        C --> D["글자수 기반 기계적 Chunking"]
        D --> E["검색 실패 · 환각 · 토큰 낭비"]
    end

    subgraph modern["기계가독형 문서 파이프라인"]
        direction TB
        F["Markdown / HTML"] --> G["태그 기반 의미 파싱"]
        G --> H["계층 구조 보존"]
        H --> I["의미 기반 Chunking"]
        I --> J["고정밀 검색 · 정확한 추론 · 토큰 절약"]
    end

    E -. "성능·비용 격차" .-> J

    style legacy fill:none,stroke:#b0b0b0,stroke-width:1px
    style modern fill:none,stroke:#2563eb,stroke-width:2px
    style E color:#b91c1c,stroke:#b91c1c
    style J color:#15803d,stroke:#15803d

그림 1. 비정형 문서와 기계가독형 문서의 RAG 파이프라인 구조 비교

국가AI전략위원회가 밝힌 전환 이유: "기존 한글 문서는 글꼴, 자간, 기호표 등 다양한 편집 요소가 적용돼 AI가 문장과 문단 구조를 정확히 인식하는 데 어려움이 있다" 1.


2. 인용 논문의 저명성 및 신뢰도

선정 시 특정 벤더의 자사 제품 홍보 성격이 강한 논문은 배제하였으며, 구조화 포맷의 효과를 일관되게 지지하는 독립적 연구만을 선별하였다.

논문 게재처 게재처 위상 저자·소속 학술적 기여
OHR-Bench 2 ICCV 2025 CV Top-3, h5-index 291, 수락률 27% 북경대 Wentao Zhang 그룹 OCR→RAG 연쇄 영향 최초 전용 벤치마크
HtmlRAG 3 WWW 2025 웹/IR 최고 학회, CORE A* 중국인민대 Ji-Rong Wen (IR 저명) HTML RAG 활용 최초 연구, GH 460+ stars
DocLLM 4 ACL 2024 NLP Top-1, h5-index 279 JPMorgan AI Research 레이아웃 인식 LLM 최초 제안, 인용 200+
LAD-RAG 5 arXiv 2025 프리프린트 USC 외 다기관 레이아웃 인식 동적 RAG, 4개 벤치마크 검증
UniDoc-Bench 6 arXiv 2025 프리프린트 Salesforce AI Research MM-RAG 통합 벤치마크 최초, 70k PDF 규모
KL3M 7 arXiv 2025 프리프린트 ALEA Institute (Stanford/MSU) 법률/금융 토크나이저 최초 공개, Apache 2.0
BigDocs 8 ICLR 2025 ML Top-3, h5-index 304 Mila (Bengio 연구소), 43명 공저 7.5M 문서 오픈셋, Yoshua Bengio 공저
Prompt Format 9 arXiv 2024 프리프린트 CVS Health 독립 연구팀 포맷별 LLM 성능 비교 최초 체계적 실험
MDEval 10 arXiv 2025 프리프린트 화중과기대(HUST) LLM Markdown 인식 평가 최초 전용 벤치마크
StructEval 11 arXiv 2025 프리프린트 Waterloo대 Wenhu Chen 그룹 18개 포맷·44개 태스크 구조적 출력 벤치마크
PDFbench 12 독립 벤치마크 2025 Applied AI (독립 리서치) 800+ 실제 업무 문서, 17개 파서 비교 텍스트 정확도와 구조 복원율을 분리 측정한 최초 대규모 벤치마크
Improving Agents 1314 독립 벤치마크 2025 Improving Agents (AI 에이전트 최적화 전문) 11개 포맷, 1,000건 테이블 + 1,000건 중첩 데이터 LLM 입력 포맷별 이해 정확도·토큰 효율 최초 대규모 비교
Skyvern 15 실무 보고 2024 Skyvern (브라우저 자동화 스타트업) 프로덕션 환경 HTML vs JSON 전환 토큰 11% 절감 + 성공률 3.9% 향상 실무 검증

표 1. 인용 논문 저명성 요약. h5-index는 Google Scholar Metrics 2025 기준.


3. 비정형 문서의 비용: OCR이 만드는 성능 천장

3.1 OHR-Bench — 모든 OCR은 실패한다

OHR-Bench (ICCV 2025) 2는 OCR이 RAG 시스템에 미치는 연쇄적 영향을 측정한 최초의 전용 벤치마크이다.

실험 규모: 7개 도메인 / 8,561개 문서 이미지 / 8,498개 QA 쌍

%%{init: {'theme': 'neutral', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'IBM Plex Sans, Pretendard, sans-serif'}}}%%
xychart-beta
    title "OHR-Bench: Ground Truth 대비 F1 하락폭 (%)"
    x-axis ["Qwen2.5-VL-72B", "MinerU", "GOT-OCR", "Pipeline OCR"]
    y-axis "F1 Score Loss (%)" 0 --> 25
    bar [14, 18, 20, 22]

그림 2. OCR 솔루션별 F1 하락폭. 최우수 솔루션에서도 14% 하락 관찰.

핵심 발견 수치 의미
최우수 OCR 전체 F1 하락 -14% 현존 최고 기술로도 원본 대비 14% 손실
EM@1 하락 -1.9 정확 매칭 기준 유의미 저하
F1@1 하락 -2.93 검색·생성 단계 모두에서 누적

연구진 결론: "평가된 모든 OCR 솔루션이 성능 저하를 보였으며, 고품질 RAG 지식베이스 구축에 적합한 솔루션은 존재하지 않았다."

해석: 비정형 문서→텍스트 추출→RAG 투입 파이프라인은, 어떤 OCR을 사용하든 **구조적 성능 상한선(ceiling)**을 가진다. 문서를 처음부터 기계가독형으로 생산하는 것만이 이 상한선 자체를 제거할 수 있다.


4. 구조화된 포맷의 이점: 검색 정확도와 추론 품질

4.1 HtmlRAG — 구조 보존이 RAG 품질을 높인다

HtmlRAG (WWW 2025) 3는 평문 대신 구조가 보존된 HTML을 RAG 입력으로 사용할 경우의 성능 우위를 6개 QA 데이터셋에서 검증하였다.

%%{init: {'theme': 'neutral', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'IBM Plex Sans, Pretendard, sans-serif'}}}%%
flowchart TD
    A["웹 문서 원본 HTML"] --> B{"RAG 투입 방식"}
    B -->|"평문 추출"| C["헤딩·표 구조 소실"]
    B -->|"HTML 보존 + Pruning"| D["구조적 의미 유지"]
    C --> E["6/6 QA 데이터셋에서 열위"]
    D --> F["6/6 QA 데이터셋에서 일관 우위"]

    style C stroke:#b91c1c,stroke-dasharray:5 5
    style D stroke:#2563eb,stroke-width:2px
    style E color:#b91c1c
    style F color:#15803d,font-weight:bold

그림 3. HtmlRAG의 실험 구조. 구조 보존 경로가 6개 데이터셋 전체에서 우위.

마크다운의 #, ##, |표| 구문은 HTML의 <h1>, <h2>, <table>을 경량화된 형태로 보존한다. HtmlRAG의 핵심 발견 — "구조적 의미가 보존될수록 RAG 품질이 높아진다" — 은 마크다운의 가치를 구조적 관점에서 뒷받침한다.

4.2 DocLLM — 레이아웃 정보의 정량적 가치

DocLLM (ACL 2024) 4은 bounding box 정보만으로 문서의 공간 구조를 파악하는 disentangled spatial attention 메커니즘을 도입하였다.

실험 설정 결과 비고
16개 데이터셋 SotA 달성 14/16 (87.5%) 이미지 인코더 없이 달성
미확인 데이터셋 일반화 4/5 (80%) STDD 설정
Llama2-7B 대비 성능 향상 +15% ~ +61% 미확인 4개 데이터셋 기준

표 3. DocLLM 핵심 실험 결과.

레이아웃 정보가 보존되면 LLM의 문서 이해 성능이 15~61% 향상된다. 마크다운의 제목 계층, 들여쓰기, 표 구문은 이 레이아웃 정보의 핵심을 텍스트 수준에서 보존하는 수단이다.

4.3 LAD-RAG — 구조 메타데이터가 검색을 결정한다

LAD-RAG (arXiv 2025) 5는 문서의 레이아웃 구조와 페이지 간 의존성을 심볼릭 그래프로 구축하여 동적으로 검색하는 프레임워크이다. 4개 벤치마크(MMLongBench-Doc, LongDocURL, DUDE, MP-DocVQA)에서 평가하였다.

측정 항목 수치 조건
평균 Perfect Recall 90% 이상 top-k 튜닝 없음
베이스라인 대비 Recall 향상 최대 +20% 동일 노이즈 수준 비교

표 4. LAD-RAG 핵심 실험 결과.

구조적 메타데이터(섹션 헤더, 페이지 간 관계, 요소 유형)를 명시적으로 인덱싱하면 고정 top-k 방식보다 현저히 높은 recall을 달성한다. 마크다운의 #, ##, --- 등이 이 메타데이터를 자연스럽게 표현하며, 평문에서는 불가능하다.

4.4 UniDoc-Bench — 텍스트만으로는 부족하다

UniDoc-Bench (arXiv 2025, Salesforce AI Research) 6은 70k PDF 페이지, 1,600개 멀티모달 QA 쌍으로 4가지 RAG 패러다임을 동일 조건에서 비교하였다.

순위 RAG 패러다임 Completeness 비고
1위 텍스트-이미지 융합 (text-image fusion) 68.4% 별도 텍스트·이미지 검색기를 결합
2위 텍스트 전용 (text-only) 65.3% 텍스트만으로도 융합의 95% 수준
3위 멀티모달 공동 임베딩 (joint retrieval) 64.1% 단일 임베딩으로 두 모달리티를 처리
4위 이미지 전용 (image-only) 54.5% 텍스트 대비 -10.8%p, 최하위

표 5. UniDoc-Bench 4가지 RAG 패러다임의 Completeness 점수(1,600 QA, GPT-4.1 기반 평가). top-k=10.

텍스트도 이미지도 단독으로는 불충분하다. 그러나 텍스트가 잘 구조화되어 있을수록 fusion의 품질도 높아진다. 마크다운은 이 "잘 구조화된 텍스트"를 가장 경량으로 생산하는 수단이다.


5. 포맷이 결정하는 토큰 비용: 기업 규모의 체감치

5.1 기업 서비스 규모에서의 비용 환산

LLM의 모든 연산은 "토큰" 단위로 이루어진다. 동일한 정보를 더 적은 토큰으로 전달할수록 GPU 사용 시간, 전기 비용, 응답 지연이 줄어든다.

아래 표는 KL3M 7의 9-17% 문서 전체 토큰 절감 결과를 기반으로, 실제 서비스 기업의 규모에서 비용을 환산한 것이다. 15% 절감(중간값)을 적용하였다.

규모 비구조화 평문 구조화 마크다운 (15% 절감) 절감 효과
일 10만 건 · 문서당 5,000토큰
— 일간 토큰 수 5억 4.25억 -7,500만 토큰/일
— 일간 API 비용 ($10/M) $5,000 $4,250 $750/일
연간 API 비용 (250 영업일) $1,250,000 $1,062,500 연 $187,500 절감
— 연간 A100 GPU 시간 약 2,083시간 약 1,770시간 313시간 절감
— 연간 전력 (A100 300W) 625 kWh 531 kWh 94 kWh 절감
일 100만 건 (대형 플랫폼)
연간 API 비용 $12,500,000 $10,625,000 연 $1,875,000 절감
— 연간 A100 GPU 시간 약 20,833시간 약 17,708시간 3,125시간 절감

표 6. 기업 서비스 규모에서의 토큰 비용 환산. 15% 절감 기준(KL3M 중간값).

참고: 도메인 특화 용어가 많은 경우(법률: 최대 83%, 금융: 39%) 절감폭은 이보다 훨씬 크다. 또한 토큰 절감은 API 비용뿐 아니라 Context Window 활용 효율, 응답 지연(latency), 배치 처리량에도 복합적으로 기여한다.

5.2 KL3M Tokenizers — 도메인 특화 토크나이저의 효과

KL3M Tokenizers (arXiv 2025, ALEA Institute) 7는 약 1.5T 토큰의 법률/금융 텍스트 코퍼스에서 훈련된 도메인 특화 BPE 토크나이저이다.

적용 범위 GPT-4o 및 Llama3 대비 절감
법률/금융/행정 문서 전체 9-17%
법률 전문 용어 (예: "Fed. R. Civ. P. 56(a)") 최대 83%
금융 전문 용어 (예: "EBITDA") 39%

표 7. KL3M 토크나이저의 토큰 절감 효과. 83%는 용어 단위 수치, 문서 전체는 9-17%.

5.3 BigDocs-Bench — 구조화 데이터의 훈련 가치

BigDocs (ICLR 2025, Mila / Yoshua Bengio 공저) 8는 7.5M 멀티모달 문서와 329k 훈련 샘플을 포함하는 대규모 오픈 데이터셋이다.

측정 항목 수치
BigDocs fine-tune 모델 vs GPT-4o (구조화 출력 태스크) +25.8%
인간 평가 선호도 vs GPT-4o 63%
인간 평가 선호도 vs Phi3.5 Instruct 88%

표 8. BigDocs-Bench 결과. 구조화 문서 훈련의 가치를 입증.

사내 문서를 마크다운으로 축적하면 향후 모델 fine-tuning 데이터로도 활용 가능하다.


6. 왜 Markdown인가 — 포맷 선택의 다차원 비교

6.1 측정 차원의 분리

포맷 비교에서 흔히 빠지는 오류는, LLM이 특정 포맷을 "생성하는 능력"과 특정 포맷을 "입력받아 이해하는 능력"을 혼동하는 것이다. JSON은 LLM이 생성할 때 90%+ 정확도를 보이지만(StructEval 11), 이것이 "JSON으로 데이터를 넣으면 AI가 잘 이해한다"를 의미하지는 않는다. 실제로는 정반대 결과가 나온다. 이 차이를 명확히 하기 위해, 포맷 성능을 3개 차원으로 분리한다.

6.2 차원 A — LLM의 포맷 생성 정확도

StructEval 11에서 측정한, LLM이 자연어로부터 각 포맷을 정확하게 생성하는 능력이다. JSON, HTML, CSV, Markdown 모두 90% 이상으로 이 차원에서는 4개 포맷 간 유의미한 차이가 없다.

포맷 생성 정확도
JSON / HTML / CSV / Markdown 모두 90% 이상 (AI 친화 포맷 군)

표 9-A. StructEval 기준 LLM 포맷 생성 정확도. 4종 모두 90%+로 차이 없음.

6.3 차원 B — 포맷을 입력으로 받았을 때의 이해·추론 정확도

이 차원에서 Markdown과 JSON의 차이가 벌어진다. 동일 데이터를 서로 다른 포맷으로 LLM에 입력했을 때의 정확도이다.

추론 태스크 (Prompt Format 9, GPT-4 MMLU):

포맷 정확도
Markdown 81.2%
Plain Text 77.0%
YAML 75.5%
JSON 73.9%

테이블 이해 태스크 (Improving Agents 13, 11개 포맷, 1,000건):

포맷 정확도
Markdown-KV 60.7%
JSON 50.8%
CSV 44.7%

중첩 데이터 이해 (Improving Agents 14, 1,000건 질의, GPT-5 Nano·Gemini):

포맷 결과
YAML 1위
Markdown 2위 (YAML과 근접)
JSON 하위권 (GPT-5 Nano·Gemini에서 성능 저조)
XML 최하위 (Markdown 대비 80% 더 많은 토큰 사용하면서도 최저 정확도)

표 9-B. 입력 포맷별 LLM 이해·추론 정확도. Markdown이 일관되게 상위권이며, JSON은 "생성은 잘 하지만 이해는 떨어지는" 역전 현상을 보인다.

6.4 차원 C — 토큰 효율성

동일 데이터를 각 포맷으로 표현했을 때의 토큰 소비량이다.

포맷 JSON 대비 토큰 비율 출처
Markdown -34~38% Improving Agents (중첩) 14
YAML Markdown보다 약 10% 많음 Improving Agents (중첩) 14
JSON 기준 (100%)
XML +80% (Markdown 대비) Improving Agents (중첩) 14

표 9-C. 동일 데이터의 포맷별 토큰 소비량.

6.5 3개 차원 종합: 왜 Markdown이 최적인가

차원 JSON HTML CSV Markdown
A. 생성 정확도 90%+ 90%+ 90%+ 90%+
B. 입력 시 추론 정확도 73.9% 44.7% 81.2%
C. 토큰 효율 (JSON 대비) 기준 -11% 최소 -34~38%
인간 가독성 낮음 중간 표 한정 높음
범용성 데이터 교환 특화 웹 특화 표 한정 문서 전반

표 10. 3개 차원 종합 비교.

핵심: JSON·HTML·CSV는 "LLM이 잘 만들어내는 포맷"이지 "LLM이 잘 이해하는 포맷"이 아니다. Markdown은 생성도 잘 하고(90%+), 이해도 잘 하며(81.2%), 토큰도 적게 쓰고(-34~38%), 사람도 읽기 쉬운 유일한 포맷이다.

6.6 비구조화 문서와의 격차

처리 방식 수치 출처
비구조화 PDF → 텍스트 추출 정확도 75% PDFbench 12
비구조화 PDF → 구조 복원율 13% PDFbench 12
비구조화 PDF → RAG F1 하락 -14% OHR-Bench 2

표 11. 비구조화 PDF의 처리 수치. "텍스트를 뽑았으니 괜찮다"는 가정이 위험한 이유.

%%{init: {'theme': 'neutral', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'IBM Plex Sans, Pretendard, sans-serif'}}}%%
xychart-beta
    title "포맷별 LLM 이해·추론 정확도 (%)"
    x-axis ["MD(MMLU)", "PlainText", "YAML", "JSON(MMLU)", "MD-KV(Tbl)", "JSON(Tbl)", "CSV(Tbl)", "PDF구조"]
    y-axis "Accuracy (%)" 0 --> 90
    bar [81, 77, 75, 74, 61, 51, 45, 13]

그림 4. "입력 시 이해 정확도" 기준 비교. Markdown이 최상위, 비구조화 PDF가 최하위.

6.7 MDEval — LLM은 이미 마크다운을 이해한다

MDEval (arXiv 2025) 10은 LLM 마크다운 인식 능력 평가 최초 벤치마크이다.

발견 상세
자발적 구조 생성 GPT-4o 등은 지시 없이도 Markdown 구조를 출력
능력 간 상관관계 Markdown Awareness와 코딩·추론 능력 간 양의 Spearman 상관
향상 가능성 QLoRA fine-tuning으로 개선 가능 (~20GB GPU, 13B)

표 12. MDEval 핵심 발견.

Markdown이 이해에서 우위를 보이는 근본 이유: LLM 훈련 데이터에 Markdown이 압도적으로 포함되어 있기 때문이다. GitHub README, 기술 문서, 위키, 블로그 등 웹 상의 구조화된 텍스트 대부분이 Markdown으로 작성되어 있다.


7. 종합: 확인된 사실

7.1 확인된 사실

영역 확인된 사실 핵심 수치 근거
OCR의 한계 모든 OCR이 RAG 성능을 저하시킴 F1 -14% (최우수) OHR-Bench 2
구조 보존 구조가 보존된 포맷이 평문보다 RAG에서 우위 6/6 QA 일관 우위 HtmlRAG 3
레이아웃 공간 레이아웃 정보가 LLM 이해도에 직접 기여 14/16 SotA, +15~61% DocLLM 4
구조 메타데이터 명시적 구조 인덱싱이 검색 recall을 극대화 90%+ Recall LAD-RAG 5
멀티모달 텍스트-이미지 융합이 단일 모달리티보다 우위 68.4% vs 54.5% (이미지단독) UniDoc-Bench 6
토큰 효율 구조화 포맷이 토큰을 절감 문서 전체 9-17% KL3M 7
훈련 가치 구조화 문서가 훈련 데이터로서 높은 가치 +25.8% vs GPT-4o BigDocs 8
포맷 선택 Markdown이 추론 태스크에서 최적 포맷 81.2% (GPT-4) Prompt Format 9
MD 인식 LLM이 이미 Markdown을 이해하도록 훈련됨 Spearman 양의 상관 MDEval 10
포맷 스펙트럼 JSON·HTML·CSV·MD가 AI 친화 포맷 군 형성 생성 90%+ StructEval 11
구조 vs 텍스트 텍스트 75% 추출 가능해도 구조 복원은 13% 구조 13% PDFbench 12
이해 정확도 역전 MD 입력 시 추론 정확도가 JSON보다 높음 81.2% vs 73.9% Prompt Format 9
테이블 이해 Markdown-KV가 11개 포맷 중 1위 60.7% vs JSON 50.8% Improving Agents 13
토큰 효율 (포맷) Markdown이 JSON 대비 34-38% 토큰 절감 -34~38% Improving Agents 14

표 13. 확인된 사실 종합.

9. 참고문헌


  1. 국가인공지능전략위원회, "국가AI전략위, 문서 작성 체계 혁신으로 AI 활용 기반 강화한다," 보도자료, 2026.3.5. https://aikorea.go.kr/web/board/brdDetail.do?menu_cd=000012&num=363  

  2. Zhang, J. et al., "OCR Hinders RAG: Evaluating the Cascading Impact of OCR on Retrieval-Augmented Generation," ICCV 2025, pp. 17443–17453. https://arxiv.org/abs/2412.02592    

  3. Tan, J. et al., "HtmlRAG: HTML is Better Than Plain Text for Modeling Retrieved Knowledge in RAG Systems," WWW 2025. https://arxiv.org/abs/2411.02959   

  4. Wang, D. et al., "DocLLM: A Layout-Aware Generative Language Model for Multimodal Document Understanding," ACL 2024, pp. 8529–8548. https://aclanthology.org/2024.acl-long.463/   

  5. Sourati, Z. et al., "LAD-RAG: Layout-aware Dynamic RAG for Visually-Rich Document Understanding," arXiv:2510.07233, 2025. https://arxiv.org/abs/2510.07233   

  6. Peng, X. et al., "UNIDOC-BENCH: A Unified Benchmark for Document-Centric Multimodal RAG," arXiv:2510.03663, 2025. https://arxiv.org/abs/2510.03663   

  7. Bommarito, M. et al., "KL3M Tokenizers," arXiv:2503.17247, 2025 (ALEA Institute). https://arxiv.org/abs/2503.17247    

  8. Rodriguez, J. et al., "BigDocs: An Open Dataset for Training Multimodal Models on Document and Code Tasks," ICLR 2025. https://arxiv.org/abs/2412.04626   

  9. He, J. et al., "Does Prompt Formatting Have Any Impact on LLM Performance?" arXiv:2411.10541, 2024. https://arxiv.org/abs/2411.10541    

  10. Li, Z. et al., "MDEval: Evaluating and Enhancing Markdown Awareness in Large Language Models," arXiv:2501.15000, 2025. https://arxiv.org/abs/2501.15000   

  11. Yang, J. et al., "StructEval: Benchmarking LLMs' Capabilities to Generate Structural Outputs," arXiv:2505.20139, 2025 (Univ. of Waterloo). https://arxiv.org/abs/2505.20139    

  12. Applied AI, "The State of PDF Parsing: What 800+ Documents and 7 Frontier LLMs Taught Us About Parser Selection," PDFbench, 2025. https://www.applied-ai.com/briefings/pdf-parsing-benchmark/    

  13. Improving Agents, "Which Table Format Do LLMs Understand Best? (Results for 11 Formats)," 2025. https://www.improvingagents.com/blog/best-input-data-format-for-llms/   

  14. Improving Agents, "Which Nested Data Format Do LLMs Understand Best? JSON vs. YAML vs. XML vs. Markdown," 2025. https://www.improvingagents.com/blog/best-nested-data-format/      

  15. Skyvern, "How we cut token count by 11% and boosted success rate by 3.9% by using HTML instead of JSON in our LLM calls," 2024. https://blog.skyvern.com/how-we-cut-token-count-by-11-and-boosted-success-rate-by-3-9-by-using-html-instead-of-json-in-our-llm-calls/ 

Markdown 엣지케이스 가이드 [중첩리스트]

보고된 증상

ordered list 항목 아래에 unordered list를 중첩할 때, 탭 키 2번이상 또는 특정 스페이스바 간격 이상 들여쓰기하면 하위 리스트가 렌더링되지 않는다.

렌더링 실패 예시:

1. 은하철도
        * 999
3. 메텔

위와 같이 * 999 앞에 일정 스페이스이상 (또는 탭 1개 이상)이 들어가면, * 999가 리스트 항목이 아닌 일반 텍스트로 렌더링된다.

원인

해당 현상은 CommonMark 스펙에 명시된 동작으로 확인된다.

BookStack은 프론트엔드(에디터 프리뷰)에서 markdown-it, 서버사이드(저장/열람/PDF)에서 league/commonmark을 사용하며, 두 파서 모두 CommonMark 스펙을 따른다.

CommonMark 스펙의 List Items 규칙 (Section 5.2)

스펙 원문 (CommonMark Spec 0.31.2, Section 5.2 — List items):

"Basic case. If a sequence of lines Ls constitute a sequence of blocks Bs starting with a character other than a space or tab, and M is a list marker of width W followed by 1 ≤ N ≤ 4 spaces of indentation, then the result of prepending M and the following spaces to the first line of Ls, and indenting subsequent lines of Ls by W + N spaces, is a list item with Bs as its contents."

핵심은 W + N 공식이다.

중첩 리스트가 부모의 하위 항목으로 인식되려면, 정확히 W + N칸만큼 들여쓰기해야 한다. 이보다 과도하게 들여쓰면 indented code block이나 paragraph continuation text로 해석된다.

실제 계산 예시

1. ㅅㅅㅅㅅ
^^^
|||
W=2 (리스트 마커 "1."의 폭)
 N=1 (마커 뒤 공백 1칸)
 → W + N = 3칸 들여쓰기 필요

따라서 중첩 리스트는 3칸 스페이스로 들여쓰기해야 한다:

1. ㅅㅅㅅㅅ
   * 999
3. ㅁㅁㅁ

* 999*과 같은 열(4번째 열)에서 시작하는 것이 올바른 위치이다.

리스트 마커별 필요 들여쓰기

부모 리스트 마커 W (마커 폭) N (뒤 공백) 필요 들여쓰기 (W+N)
* , - , + 1 1 2칸
1. ~ 9. 2 1 3칸
10. ~ 99. 3 1 4칸
100. ~ 999. 4 1 5칸

과도한 들여쓰기 시 발생하는 문제

CommonMark 스펙에서 리스트 항목 내부의 indented code block은 W + N + 4칸 이상의 들여쓰기로 시작된다. 즉 1. 리스트 항목(W+N=3) 아래에서 7칸 이상 들여쓰기하면 코드 블록으로 해석될 수 있다.

또한 중첩 리스트 마커(*, -)가 부모 항목의 텍스트 시작 위치보다 훨씬 뒤에 있으면, 파서는 이를 리스트 마커로 인식하지 않고 일반 텍스트의 일부로 처리한다.

다른 플랫폼에서의 동작

이 동작은 BookStack만의 문제가 아니라 CommonMark 스펙을 따르는 모든 마크다운 파서에서 동일하게 발생한다.

플랫폼 / 파서 CommonMark 기반 동일 증상 발생
GitHub (GFM) O O
GitLab O O
markdown-it (BookStack 프론트엔드) O O
league/commonmark (BookStack 서버사이드) O O
Hugo (Goldmark) O O
marked.js O O
Bitbucket 자체 구현 O (4칸 고정 필요)
Python-Markdown 자체 규칙 O (4칸 고정, 더 엄격)

CommonMark 스펙 Issue #399에서도 이 들여쓰기 규칙에 대한 사용자 혼란이 논의되었으나, 스펙 유지보수자들은 현재 규칙이 의도된 설계라는 입장이다.

Notion에서는 들여쓰기 동작이 코드블럭이 아닌경우 두번이상 원천적으로 불가능함.

올바른 작성법 가이드

기본 원칙

중첩 리스트의 마커(*, -, 1. 등)는 부모 항목의 텍스트 시작 위치에 맞춰 들여쓰기한다.

예시 1: ordered list 안에 unordered list

1. 첫 번째 항목
   * 하위 항목 A
   * 하위 항목 B
2. 두 번째 항목
   - 하위 항목 C

1. 뒤 텍스트가 4번째 열에서 시작하므로, 하위 리스트도 4번째 열에서 시작한다 (3칸 들여쓰기).

예시 2: unordered list 안에 ordered list

- 상위 항목
  1. 하위 번호 항목
  2. 하위 번호 항목
- 다른 항목

- 뒤 텍스트가 3번째 열에서 시작하므로, 하위 리스트도 3번째 열에서 시작한다 (2칸 들여쓰기).

예시 3: 다단계 중첩

1. 1단계
   * 2단계
     - 3단계
       1. 4단계

각 단계마다 부모의 텍스트 시작 위치에 맞춘다.

피해야 할 패턴

❌ 탭 키 사용 (에디터마다 탭 폭이 다름)
1. 항목
	* 하위 항목

❌ 과도한 스페이스
1. 항목
        * 하위 항목

❌ 텍스트 위치와 무관한 임의 들여쓰기
1. 항목
      * 하위 항목

외부 문서에서 붙여넣기할 때 주의사항

다른 에디터(Word, Notion, 메모장 등)에서 작성된 마크다운을 BookStack에 붙여넣을 때, 들여쓰기가 탭이나 과도한 스페이스로 되어 있을 수 있다. 이 경우 중첩 리스트가 정상 렌더링되지 않으므로, 붙여넣기 후 들여쓰기를 위 규칙에 맞게 조정해야 한다.

간단한 확인법: 프리뷰 패널에서 하위 리스트가 제대로 표시되는지 확인한다. 프리뷰에서 깨지면 저장 후에도 깨진다.

레퍼런스

문서 URL
CommonMark Spec 0.31.2 — Section 5.2: List items https://spec.commonmark.org/0.31.2/#list-items
CommonMark Spec — Motivation (들여쓰기 규칙 설계 의도) https://spec.commonmark.org/0.31.2/#motivation
CommonMark Tutorial — Nested Lists https://commonmark.org/help/tutorial/10-nestedLists.html
CommonMark Spec Issue #399 — 들여쓰기 규칙 논의 https://github.com/commonmark/commonmark-spec/issues/399
GitHub Flavored Markdown Spec (CommonMark 기반) https://github.github.com/gfm/
markdown-it Issue #215 — 중첩 리스트 들여쓰기 https://github.com/markdown-it/markdown-it/issues/215
Markdown Guide — Basic Syntax (Lists) https://www.markdownguide.org/basic-syntax/#lists-1

Mermaid 엣지케이스 가이드 [복잡도]

보고된 증상

Mermaid 다이어그램에 노드를 많이 추가하고, 서브그래프를 중첩하고, <br/>로 줄바꿈을 넣거나 HTML 서식 태그를 사용하면 다이어그램이 의도대로 렌더링되지 않는다. 구체적으로는 노드가 예상 밖의 위치에 배치되거나, 엣지가 다른 노드를 관통하거나, 레이아웃이 작은 변경에도 급격하게 바뀌는 현상이 발생한다.

이 현상은 Mermaid 자체의 설계 범위에서 비롯되는 한계이다.


case 1: 복잡한 서버 구성 아키텍처를 Mermaid로 옮겼을 때

아래는 실제 서버 구성을 Mermaid flowchart로 표현한 예시이다. 외부 트래픽이 로드밸런서를 거쳐 웹 서버, API 서버, 워커로 분기되고, 각 컴포넌트가 데이터베이스와 캐시에 연결되는 일반적인 구성이다.

복잡한 다이어그램 예시:

flowchart TD
    subgraph External ["외부"]
        Client["클라이언트"] --> DNS["DNS"] --> CDN["CDN"]
    end

    subgraph LB ["로드밸런서"]
        WAF["WAF"] --> ALB["ALB"]
    end

    subgraph App ["애플리케이션"]
        subgraph Web ["Web Tier"]
            Web1["Web #1<br/>(Nginx)"]
            Web2["Web #2<br/>(Nginx)"]
        end
        subgraph API ["API Tier"]
            API1["API #1<br/>(Node.js)"]
            API2["API #2<br/>(Node.js)"]
            API3["API #3<br/>(Node.js)"]
        end
    end

    subgraph Data ["데이터"]
        Primary["MySQL Primary"]
        Replica["MySQL Replica"]
        Redis["Redis"]
        S3["S3"]
        SQS["SQS"]
    end

    Worker["Worker (×3)"]

    CDN --> WAF
    ALB --> Web1 & Web2
    ALB --> API1 & API2 & API3
    Web1 & Web2 --> API1 & API2 & API3
    API1 & API2 & API3 --> Primary & Replica & Redis & S3
    API1 & API2 & API3 --> SQS --> Worker
    Worker --> Primary & S3
    Primary --> Replica

이 다이어그램은 노드 18개, 서브그래프 5개, 엣지 20개 이상을 포함한다. 실제로 렌더링해보면 다음 문제를 확인할 수 있다.

API 서버 3대가 데이터베이스, 캐시, 스토리지에 동시에 연결되면서 엣지가 교차하고, 어떤 선이 어디에서 어디로 가는지 추적하기 어렵다. Dagre 레이아웃 엔진이 노드를 예측 불가능한 위치에 배치하며, 서브그래프 간 간격이 불균일해진다. 읽는 사람이 이 다이어그램에서 트래픽 흐름을 파악하려면 한참을 들여다봐야 한다.

동일한 구성을 분할한 예시:

동일한 정보를 2개의 간결한 다이어그램으로 나누면 각각이 훨씬 명확해진다.

Diagram1

flowchart TD
    Client["클라이언트"] --> CDN --> WAF --> ALB
    ALB --> Web["Web (×2)"]
    ALB --> API["API (×3)"]
    API --> DB["MySQL Primary + Replica"]
    API --> Redis["Redis"]
    API --> S3["S3"]
    API --> SQS --> Worker["Worker (×3)"]
    Worker --> DB

Diagram2

flowchart LR
    Primary["MySQL Primary"] --> Replica["MySQL Replica"]
    Worker["Worker"] --> Primary
    Worker --> S3["S3"]

첫 번째 다이어그램은 트래픽 흐름을 5초 안에 파악할 수 있다. 두 번째는 데이터 쓰기 경로만 별도로 보여준다. 복잡한 하나보다 간결한 둘이 낫다.


case 2: HTML 라벨을 과도하게 사용했을 때

Mermaid 노드에는 <br/>로 줄바꿈을 넣고, <b>, <i> 등 HTML 서식 태그를 사용할 수 있다. 이 기능은 htmlLabels: true(기본값) 설정에서 SVG 내부의 <foreignObject> 태그를 통해 동작한다.

문법적으로 허용되고 동작도 하지만, HTML 라벨이 많아질수록 두 가지 문제가 심화된다.

가독성 저하 — 노드 크기 비대화:

flowchart TD
    subgraph Agent ["exdp.exe"]
        direction TB
        WSS["<b>WSS 서버</b><br/>(Port: 13440)<br/><i>WebSocket Secure</i>"]
        PKG["<b>패키지 식별자</b><br/>(Package Identifier)<br/><i>DLL 매핑 관리</i>"]
        WMng["<b>Worker Manager</b><br/>(IPC Client)<br/><i>Protobuf 통신</i>"]
    end

    subgraph Worker ["exdp-worker.exe"]
        direction TB
        IPC["<b>IPC Server</b><br/>(Named Pipe)<br/><i>프로세스 간 통신</i>"]
        Loader["<b>DLL 로더</b><br/>(Dynamic Loading)<br/><i>런타임 바인딩</i>"]
    end

    WSS -->|"호출 요청"| WMng
    WMng <-->|"Protobuf 직렬화"| IPC
    IPC -->|"LoadLibrary"| Loader
    PKG -->|"DLL 경로 조회"| WMng

각 노드에 <b>, <i>, <br/>가 3줄씩 들어가면서 노드 박스가 커지고, 실제로 중요한 연결 관계(화살표)가 상대적으로 눈에 들어오지 않는다. 다이어그램의 목적은 컴포넌트 간 관계를 보여주는 것인데, 각 노드의 상세 설명이 그 관계를 가린다.

동일한 구성을 간결하게 작성한 예시:

flowchart TD
    subgraph Agent ["exdp.exe"]
        WSS["WSS 서버"] --> WMng["Worker Manager"]
        PKG["패키지 식별자"] --> WMng
    end

    subgraph Worker ["exdp-worker.exe"]
        IPC["IPC Server"] --> Loader["DLL 로더"]
    end

    WMng <-->|Protobuf| IPC

노드 라벨을 핵심 이름만 남기니 컴포넌트 간 관계가 한눈에 보인다. Port 번호, 프로토콜 상세, 기술 스택 설명은 다이어그램 아래 본문에 텍스트로 보충하면 된다.

PDF 내보내기의 추가 제약 — foreignObject:

HTML 라벨은 브라우저에서는 정상 동작하지만, PDF 내보내기에서는 별도의 처리가 필요하다. Mermaid가 htmlLabels: true로 생성한 SVG에는 <foreignObject> 태그 안에 HTML이 포함되는데, PDF 변환을 담당하는 WeasyPrint는 이 태그를 지원하지 않아 라벨 텍스트가 사라진다. BookStack에서는 이 문제를 우회하기 위해 PDF 내보내기 시 Mermaid를 PNG 이미지로 변환하여 삽입하는 방식을 채택했다. 이 과정에서 블록당 Chromium 기동 오버헤드가 발생하므로, HTML 라벨이 많은 복잡한 다이어그램일수록 내보내기가 느려진다.


Mermaid의 설계 목적

Mermaid 프로젝트의 공식 문서는 다음과 같이 명시한다:

"The main purpose of Mermaid is to help documentation catch up with development. Doc-Rot is a Catch-22 that Mermaid helps to solve."
— Mermaid 공식 문서 (mermaid.js.org/intro)

Mermaid는 마크다운처럼 텍스트로 빠르게 다이어그램을 작성하고, 버전 관리 가능한 형태로 문서에 포함시키기 위해 만들어졌다. 창시자 Knut Sveidqvist는 Packt 출판사 인터뷰에서 "문서화가 번거롭고 지루할 필요가 없다"고 밝혔으며, Mermaid Chart 블로그에서는 UML 다이어그램이 지나치게 포괄적이 되어 오히려 개발자의 시간을 낭비하고 있었다는 문제의식을 공유한 바 있다.

특히 Mermaid 공식 Flowchart 문서에는 스웨덴어 개념인 "lagom" (너무 많지도 적지도 않은 적당함)이 직접 언급되어 있다. 과도한 체이닝은 가독성을 떨어뜨리며, lagom 원칙이 적용된다는 것이다. 스웨덴인인 Knut Sveidqvist가 이 원칙을 문서에 직접 포함시킨 것은 Mermaid의 설계 철학을 단적으로 보여준다.

Dagre 레이아웃 엔진의 구조적 한계

Mermaid의 기본 레이아웃 엔진인 Dagre는 노드와 엣지가 많아질수록 배치 품질이 떨어진다. 이 문제는 Mermaid 프로젝트 자체가 인정하고 있으며, 그 결과 ELK(Eclipse Layout Kernel)라는 대체 레이아웃 엔진을 추가했다.

Mermaid 공식 문서에는 다음과 같이 설명되어 있다:

"ELK: For those who need more sophisticated layout capabilities, especially when working with large or intricate diagrams, the ELK layout offers advanced options."
— Mermaid Syntax Reference (mermaid.js.org/intro/syntax-reference.html)

Dagre에서 ELK로의 전환을 요청한 GitHub Issue #1969에서는, 큰 다이어그램에서 연결이 하나뿐인 블록이 다이어그램 전체를 가로질러 예상 밖의 위치에 배치되고, 엣지가 다른 블록을 관통하는 문제가 보고되었다. 이 이슈는 Knut Sveidqvist 본인이 담당자로 지정되었다.

경험이 풍부한 개발자들의 평가도 일관적이다. Korny Sietsma는 "Mermaid의 엔진은 훨씬 혼란스럽고, 작은 변경이 전체 레이아웃을 급격하게 바꾼다"고 지적했으며, 자신은 단순한 것에는 Mermaid를, 복잡하거나 레이아웃 제어가 필요한 것에는 Excalidraw를 사용한다고 밝혔다.

foreignObject와 PDF 렌더링 파이프라인

Mermaid가 htmlLabels: true(기본값)로 생성한 SVG에는 <foreignObject> 태그 안에 HTML 라벨이 포함된다. 브라우저(Chromium)는 이를 정상적으로 렌더링하지만, PDF 변환을 담당하는 WeasyPrint는 <foreignObject>를 지원하지 않는다.

이것은 WeasyPrint만의 문제가 아니다. draw.io 프로젝트의 Issue #3350에서도 foreignObject를 처리할 수 있는 SVG 렌더러는 브라우저뿐이라는 점이 확인되었으며, CairoSVG의 공식 SVG 1.1 지원 목록에도 foreignObject는 누락되어 있다. WeasyPrint Issue #1441에서 코어 메인테이너 Guillaume Ayoub(liZe)는 foreignObject 구현의 난이도를 언급했고, 이 이슈는 4년 이상 오픈 상태로 남아 있다.

결과적으로, Mermaid 노드에 HTML 라벨을 사용할수록 브라우저 렌더링과 PDF 출력 사이의 일관성 유지에 더 많은 처리가 필요해진다. BookStack에서는 이를 PNG 변환으로 우회했으나, 이 방식에는 블록당 Chromium 기동 오버헤드와 래스터 품질 저하라는 비용이 수반된다.


올바른 작성법 가이드

기본 원칙

Mermaid 다이어그램은 시스템의 전체 구현을 담는 것이 아니라, 핵심 흐름을 요약하는 것이다. 읽는 사람이 5초 안에 전체 구조를 파악할 수 있어야 한다.

적정 규모:

노드 3~15개, 서브그래프 1~2단계 중첩까지가 Mermaid의 최적 사용 범위이다. 노드가 20개를 넘어가면 다이어그램을 분할하거나, 복잡한 레이아웃 제어가 필요한 경우 draw.io, Excalidraw 등 다른 도구를 고려한다.

복잡한 다이어그램을 분할하는 방법

하나의 복잡한 다이어그램 대신, 관심사 별로 분리된 여러 개의 간결한 다이어그램을 사용한다.

예를 들어 앞서 본 서버 아키텍처를 문서화할 때, 하나의 다이어그램에 모든 것을 넣는 대신 "트래픽 흐름", "데이터 쓰기 경로", "모니터링"으로 나누어 각각 별도의 다이어그램으로 작성하면, 각 다이어그램이 하나의 관심사에 집중하므로 읽는 사람이 필요한 정보를 빠르게 찾을 수 있다.

노드 라벨은 짧게 유지한다

<br/>로 한 노드에 여러 줄을 넣어야 할 정도로 텍스트가 길다면, 그 노드는 더 작은 단위로 분할하거나 약어를 사용하는 것이 낫다. Port 번호, 프로토콜 상세, 기술 스택 같은 부가 정보는 다이어그램 아래 본문에 텍스트로 보충한다.

<br/>와 HTML 서식 태그(<b>, <i> 등)는 문법적으로 허용되며, BookStack에서 브라우저 렌더링과 PDF 내보내기 모두 정상 동작한다. 사용을 금지하는 것이 아니라, 다이어그램의 가치가 시각적 단순함에 있다는 점을 고려하여 최소한으로 사용하는 것을 권장한다.

한 페이지에 다이어그램 수를 제한한다

PDF 내보내기 시 Mermaid 블록마다 Chromium이 기동되므로, 블록이 많을수록 내보내기 시간이 선형적으로 증가한다. 한 페이지에 5개 이상의 다이어그램이 필요하다면 페이지를 분할하는 것을 권장한다.

피해야 할 패턴

노드 수 과다:

flowchart TD
    A --> B & C & D & E
    B --> F & G
    C --> H & I & J
    D --> K & L
    E --> M & N & O
    F --> P
    G --> Q & R

노드가 20개를 넘어가면 Dagre 레이아웃이 불안정해지고, 한 곳을 수정하면 전혀 다른 곳의 배치가 바뀌는 현상이 발생한다. 이 시점에서 다이어그램을 분할해야 한다.

서브그래프 3단계 이상 중첩:

flowchart TD
    subgraph A ["1단계"]
        subgraph B ["2단계"]
            subgraph C ["3단계"]
                subgraph D ["4단계"]
                    Node["깊이 4"]
                end
            end
        end
    end

서브그래프가 깊어질수록 레이아웃 엔진의 공간 할당이 비효율적이 되어 다이어그램의 대부분이 빈 여백으로 채워진다. 2단계까지가 가독성의 실질적 한계이다.

HTML 라벨 과다 사용:

flowchart TD
    A["<b>서비스 A</b><br/>(v2.3.1)<br/><i>Java 17</i><br/>Port: 8080"]
    B["<b>서비스 B</b><br/>(v1.0.0)<br/><i>Go 1.21</i><br/>Port: 9090"]
    A -->|"gRPC<br/>(TLS 1.3)"| B

노드 하나에 4줄씩 들어가면 노드 박스가 커져서, 정작 중요한 연결 관계(화살표)가 눈에 들어오지 않는다. 또한 이 HTML 라벨은 내부적으로 SVG의 <foreignObject>를 통해 렌더링되므로, 브라우저 이외의 SVG 소비자(PDF 변환기, Inkscape 등)에서 호환성 문제를 일으킬 수 있다.


다른 플랫폼에서의 동작

이 한계는 BookStack만의 문제가 아니라 Mermaid를 사용하는 모든 플랫폼에서 동일하게 발생한다.

플랫폼 / 도구 Mermaid 지원 동일 복잡도 한계
GitHub (GFM) O O
GitLab O O
Notion O O
Obsidian O O
VS Code (미리보기) O O
Mermaid Live Editor O O

Mermaid의 복잡도 한계는 Dagre/ELK 레이아웃 엔진의 동작 방식에서 비롯되므로, 어떤 플랫폼에서 렌더링하든 동일하게 적용된다.

간단한 확인법: Mermaid Live Editor(https://mermaid.live)에서 다이어그램을 먼저 작성해보고, 레이아웃이 의도대로 나오는지 확인한 후 BookStack에 붙여넣는다. Live Editor에서 깨지면 BookStack에서도 깨진다.


레퍼런스

문서 URL
Mermaid 공식 문서 — About https://mermaid.js.org/intro/
Packt 인터뷰 — Knut Sveidqvist https://partnerships.packt.com/31533-2/
Mermaid Chart 블로그 — UML 한계 https://mermaid.ai/blog/posts/uml-diagram-tool
Mermaid 공식 Flowchart 문서 (lagom 원칙) https://mermaid.js.org/syntax/flowchart.html
Mermaid Syntax Reference — ELK 레이아웃 https://mermaid.js.org/intro/syntax-reference.html
GitHub Issue #1969 — Dagre→ELK 전환 요청 https://github.com/mermaid-js/mermaid/issues/1969
Korny's Blog — Revisiting Mermaid.js https://blog.korny.info/2025/03/14/mermaid-js-revisited
WeasyPrint Issue #1441 — foreignObject 미지원 https://github.com/Kozea/WeasyPrint/issues/1441
CairoSVG SVG 1.1 지원 현황 https://cairosvg.org/svg_support/
draw.io Issue #3350 — foreignObject 생태계 한계 https://github.com/jgraph/drawio/issues/3350
Mermaid Issue #2688 — foreignObject→SVG 전환 요청 https://github.com/mermaid-js/mermaid/issues/2688

test

BookStack PDF Export 전처리 파이프라인 분석

대상: BookStack v25.02.4 커스터마이징 참여 개발자 범위: Mermaid 다이어그램이 포함된 페이지의 PDF 내보내기 경로 최종 수정: 2026-03-24


1. 개요

BookStack의 PDF 내보내기는 WeasyPrint(CSS 기반 PDF 렌더링 엔진)를 사용한다. WeasyPrint는 브라우저가 아니기 때문에 JavaScript를 실행할 수 없고, SVG 내부의 <foreignObject>도 지원하지 않는다. 이 제약이 Mermaid 다이어그램 PDF 출력의 핵심 문제였으며, 최종적으로 PNG 이미지 방식으로 해결했다.

이 문서는 PDF export 요청이 들어왔을 때 HTML이 어떤 전처리를 거쳐 WeasyPrint에 도달하는지를 단계별로 분석한다.


2. 전체 파이프라인 흐름

사용자 → PDF 내보내기 클릭
  │
  ▼
pageToPdf($page)                          [ExportFormatter.php]
  │
  ├─ PageContent($page)->render()          Markdown → HTML 변환
  │
  ├─ view('exports.page')                  Blade 템플릿 렌더링
  │   └─ format=pdf, engine=command          (CSS/레이아웃 포함)
  │
  └─ htmlToPdf($html)                     ★ 전처리 시작
      │
      ├─ 1. containHtml($html)             이미지 base64, 링크 절대경로
      │
      ├─ 2. HtmlDocument 로드              DOM 파싱
      │
      ├─ 3. replaceIframesWithLinks()      iframe → 링크 텍스트
      │
      ├─ 4. openDetailElements()           <details> 태그 열기
      │
      ├─ 5. replaceMermaidWithImage()      ★ Mermaid → PNG 변환
      │
      ├─ 6. pdfGenerator->fromHtml()       WeasyPrint 실행
      │
      └─ 7. cleanupMermaidTempFiles()      임시 PNG 삭제 (finally)

2.1 전체 파이프라인 다이어그램

flowchart TD
    A["사용자: PDF 내보내기 클릭"] --> B["pageToPdf($page)"]
    B --> C["PageContent($page)->render()<br/>Markdown → HTML 변환"]
    C --> D["view('exports.page')<br/>format=pdf, engine=command"]
    D --> E["htmlToPdf($html)"]

    subgraph htmlToPdf ["htmlToPdf — 전처리 허브"]
        direction TB
        E1["① containHtml($html)<br/>이미지 base64 · 링크 절대경로"]
        E2["② HtmlDocument 로드<br/>DOM 파싱"]
        E3["③ replaceIframesWithLinks()"]
        E4["④ openDetailElements()"]
        E5["⑤ replaceMermaidWithImage()<br/>Puppeteer+mmdc → PNG"]
        E6["⑥ pdfGenerator→fromHtml()<br/>WeasyPrint 실행"]
        E7["⑦ cleanupMermaidTempFiles()<br/>finally 블록"]
        E1 --> E2 --> E3 --> E4 --> E5 --> E6 --> E7
    end

    E --> E1

    style E5 fill:#f4845f,color:#fff
    style E6 fill:#f0ad4e,color:#fff
    style E1 fill:#5bc0de,color:#fff

2.2 replaceMermaidWithImage 내부 흐름

flowchart TD
    S["XPath 쿼리<br/>pre > code.language-mermaid"] --> LOOP

    subgraph LOOP ["foreach block"]
        direction TB
        L1["다이어그램 소스 추출<br/>html_entity_decode(textContent)"]
        L2["임시 .mmd 파일 생성<br/>/tmp/mmd_in_xxx.mmd"]
        L3["mmdc 실행<br/>node cli.js -i input -o output.png -s 2"]
        L4["@unlink 입력 파일"]
        L5{"성공?<br/>exitCode===0<br/>filesize > 0"}
        L6["DOM에 img 삽입<br/>src=file:///tmp/xxx.png"]
        L7["mermaidTempFiles에<br/>경로 추가"]
        L8["@unlink 출력 파일"]

        L1 --> L2 --> L3 --> L4 --> L5
        L5 -- "성공" --> L6 --> L7
        L5 -- "실패" --> L8
    end

    style L3 fill:#f4845f,color:#fff
    style L6 fill:#5bc0de,color:#fff

2.3 htmlLabels 설정별 렌더링 경로 비교

flowchart TD
    SRC["Mermaid 소스코드"] --> PATH_OLD
    SRC --> PATH_NEW

    subgraph PATH_OLD ["과거: SVG 직접 삽입 ✗"]
        direction TB
        O1["htmlLabels: true<br/>mmdc → SVG"]
        O2["SVG 내부 foreignObject<br/>HTML div로 라벨 렌더링"]
        O3["WeasyPrint 렌더링<br/>foreignObject 미지원"]
        O4["PDF 텍스트 누락 ✗"]
        O1 --> O2 --> O3 --> O4
    end

    subgraph PATH_NEW ["현재: PNG 이미지 삽입 ✓"]
        direction TB
        N1["htmlLabels: true<br/>mmdc → PNG (-s 2)"]
        N2["Puppeteer(Chromium)가<br/>완벽히 렌더링"]
        N3["file:// 경로로<br/>img 태그 삽입"]
        N4["PDF 정상 출력 ✓"]
        N1 --> N2 --> N3 --> N4
    end

    style O4 fill:#d9534f,color:#fff
    style N4 fill:#5cb85c,color:#fff

3. 각 단계 상세

3.1 pageToPdf — 진입점

// app/Exports/ExportFormatter.php
public function pageToPdf(Page $page): string
{
    $page->html = (new PageContent($page))->render();
    $html = view('exports.page', [
        'page'   => $page,
        'format' => 'pdf',
        'engine' => $this->pdfGenerator->getActiveEngine(),
        'locale' => user()->getLocale(),
    ])->render();

    return $this->htmlToPdf($html);
}

PageContent->render()는 마크다운 소스를 HTML로 변환한다. 이 시점에서 Mermaid 코드 블록은 아직 <pre><code class="language-mermaid"> 형태로 남아 있다. JavaScript 실행 없이 순수 HTML 문자열이다.

3.2 htmlToPdf — 전처리 허브

이 메서드가 전처리의 핵심이다. 모든 변환이 여기서 순서대로 실행된다.

protected function htmlToPdf(string $html): string
{
    // 1단계: 이미지 base64 인코딩 + 링크 절대경로 변환
    $html = $this->containHtml($html);

    // DOM 파싱
    $doc = new HtmlDocument();
    $doc->loadCompleteHtml($html);

    // 2단계: iframe을 링크 텍스트로 교체
    $this->replaceIframesWithLinks($doc);

    // 3단계: <details> 요소를 열린 상태로
    $this->openDetailElements($doc);

    // 4단계: Mermaid → PNG (반드시 containHtml 이후에 실행)
    $this->replaceMermaidWithImage($doc);

    $cleanedHtml = $doc->getHtml();

    // 5단계: WeasyPrint로 PDF 생성 → 6단계: 임시 파일 정리
    try {
        return $this->pdfGenerator->fromHtml($cleanedHtml);
    } finally {
        $this->cleanupMermaidTempFiles();
    }
}

실행 순서가 중요한 이유: containHtml()<img> 태그의 src를 base64로 변환하는데, replaceMermaidWithImage()가 삽입하는 PNG는 file:// 경로를 사용한다. 만약 순서가 뒤바뀌면 containHtml()file:// 경로를 base64 변환하려 시도할 수 있다. 이를 방지하기 위해 containHtml()file:// 가드를 추가했다.

3.3 containHtml — 이미지/링크 전처리

protected function containHtml(string $htmlContent): string
{
    // 이미지 처리: src를 base64 data URI로 변환
    preg_match_all("/\<img.*?src\=(\'|\")(.*?)(\'|\").*?\>/i",
        $htmlContent, $imageTagsOutput);

    if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) {
        foreach ($imageTagsOutput[0] as $index => $imgMatch) {
            $oldImgTagString = $imgMatch;
            $srcString = $imageTagsOutput[2][$index];

            // ★ file:// 경로 가드 — Mermaid PNG 임시 파일 보호
            if (str_starts_with($srcString, 'file://')) {
                continue;
            }

            $imageEncoded = $this->imageService->imageUrlToBase64($srcString);
            if ($imageEncoded === null) {
                $imageEncoded = $srcString;
            }
            $newImgTagString = str_replace(
                $srcString, $imageEncoded, $oldImgTagString);
            $htmlContent = str_replace(
                $oldImgTagString, $newImgTagString, $htmlContent);
        }
    }

    // 링크 처리: 상대 경로를 절대 URL로 변환
    // 단, #으로 시작하는 앵커 링크는 제외 (각주 등 문서 내 링크 보존)
    preg_match_all("/\<a.*href\=(\'|\")(.*?)(\'|\").*?\>/i",
        $htmlContent, $linksOutput);

    if (isset($linksOutput[0]) && count($linksOutput[0]) > 0) {
        foreach ($linksOutput[0] as $index => $linkMatch) {
            $oldLinkString = $linkMatch;
            $srcString = $linksOutput[2][$index];
            if (!str_starts_with(trim($srcString), 'http')
                && !str_starts_with(trim($srcString), '#')) {
                $newSrcString = url($srcString);
                $newLinkString = str_replace(
                    $srcString, $newSrcString, $oldLinkString);
                $htmlContent = str_replace(
                    $oldLinkString, $newLinkString, $htmlContent);
            }
        }
    }
    return $htmlContent;
}

주의 사항:

3.4 replaceMermaidWithImage — 핵심 변환

이 메서드가 Mermaid 코드 블록을 PNG 이미지로 교체하는 핵심 로직이다.

protected function replaceMermaidWithImage(HtmlDocument $doc): void
{
    $blocks = $doc->queryXPath(
        '//pre/code[contains(@class, "language-mermaid")]'
    );
    \Log::debug('Mermaid blocks: ' . $blocks->length);

    $nodePath       = '/usr/local/bin/node';
    $mmdcScript     = '/var/www/bookstack/node_modules/'
                    . '@mermaid-js/mermaid-cli/src/cli.js';
    $puppeteerConfig = base_path('puppeteer-config.json');

    if (!file_exists($mmdcScript)) {
        \Log::debug('mmdc script not found');
        return;
    }

    $blockArray = iterator_to_array($blocks);
    foreach ($blockArray as $block) {
        $pre  = $block->parentNode;
        $code = html_entity_decode($block->textContent, ENT_QUOTES);

        // 임시 파일 생성
        $tmpIn  = tempnam(sys_get_temp_dir(), 'mmd_in_') . '.mmd';
        $tmpOut = tempnam(sys_get_temp_dir(), 'mmd_out_') . '.png';

        file_put_contents($tmpIn, $code);

        // mmdc 실행: Puppeteer(Chromium) → PNG
        $cmd = sprintf(
            'PUPPETEER_CACHE_DIR=/var/www/bookstack/storage/puppeteer '
            . '%s %s -i %s -o %s -c %s --puppeteerConfigFile %s -s 2 2>&1',
            $nodePath,
            $mmdcScript,
            escapeshellarg($tmpIn),
            escapeshellarg($tmpOut),
            escapeshellarg(base_path('mermaid-config.json')),
            escapeshellarg($puppeteerConfig)
        );

        $output = [];
        exec($cmd, $output, $exitCode);
        \Log::debug('exit: ' . $exitCode
            . ' png exists: ' . (file_exists($tmpOut) ? 'yes' : 'no')
            . ' output: ' . implode(' ', $output));

        // 입력 파일은 즉시 삭제
        @unlink($tmpIn);

        if ($exitCode === 0 && file_exists($tmpOut)
            && filesize($tmpOut) > 0) {
            // DOM에 <img> 삽입 — file:// 경로 참조
            $img = $doc->createElement('img');
            $img->setAttribute('src', 'file://' . $tmpOut);
            $img->setAttribute('style',
                'max-width:100%; height:auto;');
            $img->setAttribute('class', 'mermaid-diagram');

            $wrapper = $doc->createElement('div');
            $wrapper->setAttribute('class',
                'mermaid-diagram-wrapper');
            $wrapper->setAttribute('style',
                'text-align:center; margin:1em 0;');
            $wrapper->appendChild($img);

            $pre->parentNode->replaceChild($wrapper, $pre);

            // WeasyPrint가 읽을 때까지 PNG 유지
            $this->mermaidTempFiles[] = $tmpOut;
            \Log::debug('PNG file referenced: ' . $tmpOut
                . ' (' . filesize($tmpOut) . ' bytes)');
        } else {
            @unlink($tmpOut);
        }
    }
}

mmdc 명령어 옵션 설명

옵션 역할
-i 입력 .mmd 파일 Mermaid 소스
-o 출력 .png 파일 PNG 형식 지정 (확장자로 결정)
-c mermaid-config.json Mermaid 설정 (theme 등)
--puppeteerConfigFile puppeteer-config.json Chromium 실행 옵션
-s 2 스케일 팩터 2배 해상도 출력

설정 파일 내용

// mermaid-config.json — htmlLabels: true(기본값) 사용
{
  "theme": "default"
}

// puppeteer-config.json
{
  "args": ["--no-sandbox", "--disable-setuid-sandbox"],
  "cacheDirectory": "/var/www/bookstack/storage/puppeteer"
}

3.5 cleanupMermaidTempFiles — 임시 파일 정리

protected function cleanupMermaidTempFiles(): void
{
    foreach ($this->mermaidTempFiles as $tmpFile) {
        if (file_exists($tmpFile)) {
            @unlink($tmpFile);
            \Log::debug('Cleaned up Mermaid temp file: ' . $tmpFile);
        }
    }
    $this->mermaidTempFiles = [];
}

try/finally로 호출되므로 PDF 생성 성공/실패와 무관하게 실행된다. 이전에 대용량 이미지로 인한 메모리 초과 경험이 있어, 임시 파일이 /tmp에 누적되는 것을 방지하기 위해 이 패턴을 사용한다.


4. 왜 PNG인가 — 의사결정 기록

4.1 시도한 접근들

시도 방식 결과 실패 원인
1차 SVG + htmlLabels: true 텍스트 누락 WeasyPrint가 SVG 내 <foreignObject> 미지원
2차 SVG + htmlLabels: false 특수문자 깨짐 Mermaid 자체 버그 — <br/>, \, &, <> 처리 불량
최종 PNG + htmlLabels: true 정상 Puppeteer가 완벽히 렌더링한 결과를 래스터 이미지로 캡처

4.2 htmlLabels 설정의 영향

htmlLabels는 Mermaid가 노드 라벨을 렌더링하는 방식을 결정한다.

htmlLabels: true (기본값) — HTML/CSS로 라벨 렌더링:

<svg>
  <foreignObject>
    <div xmlns="http://www.w3.org/1999/xhtml">
      <span>노드 텍스트</span>
    </div>
  </foreignObject>
</svg>

htmlLabels: false — 순수 SVG 텍스트 요소:

<svg>
  <text>
    <tspan>노드 텍스트</tspan>
  </text>
</svg>

PNG 방식에서는 Puppeteer(Chromium)가 렌더링을 완료한 뒤 스크린샷을 찍으므로, <foreignObject> 문제가 발생하지 않는다. 따라서 htmlLabels: true(기본값)를 사용하여 줄바꿈, 특수문자, 긴 텍스트 등을 정상 처리한다.

4.3 임시 파일 참조 vs base64 인라인

base64 인라인 임시 파일 참조 (채택)
PHP 메모리 높음 (이미지 데이터 전체 로드) 낮음 (경로 문자열만)
PDF 생성 속도 느림 (HTML 문자열 비대) 빠름
과거 문제 base64로 HTML 크기 폭증 → 변환 중 뻗음 없음
임시 파일 관리 불필요 필요 (finally로 해결)

5. 실행 환경 의존성

컴포넌트 경로 / 설정 용도
Node.js /usr/local/bin/node mmdc 실행 (nvm이 아닌 복사본 — www-data 접근 가능)
mmdc node_modules/@mermaid-js/mermaid-cli/src/cli.js Mermaid → PNG 변환
Puppeteer cache /var/www/bookstack/storage/puppeteer Chromium 바이너리 저장
chrome-headless-shell storage/puppeteer 하위 Puppeteer가 사용하는 브라우저
WeasyPrint 시스템 명령어 HTML → PDF 최종 변환
mermaid-config.json /var/www/bookstack/mermaid-config.json Mermaid 렌더링 설정
puppeteer-config.json /var/www/bookstack/puppeteer-config.json Chromium 실행 옵션

중요: nvm으로 설치한 Node.js는 www-data(PHP-FPM 사용자)가 접근할 수 없다. /usr/local/bin/node에 바이너리를 복사해야 한다.


6. 로그 확인

PDF export 시 아래 로그가 storage/logs/laravel.log에 기록된다.

정상 동작 시:

[2026-03-23] production.DEBUG: Mermaid blocks: 2
[2026-03-23] production.DEBUG: exit: 0 png exists: yes output: Generating single mermaid chart
[2026-03-23] production.DEBUG: PNG file referenced: /tmp/mmd_out_xxx.png (45231 bytes)
[2026-03-23] production.DEBUG: Cleaned up Mermaid temp file: /tmp/mmd_out_xxx.png

mmdc 실패 시:

[2026-03-23] production.DEBUG: exit: 1 png exists: no output: Error: ...

7. 브라우저 렌더링과의 관계

PDF export 파이프라인은 브라우저 렌더링과 완전히 독립적이다.

항목 브라우저 (페이지 보기) PDF 내보내기
Mermaid 로드 mermaid-init.js (Custom HTML Head) mmdc CLI
렌더링 엔진 브라우저 JS 엔진 Puppeteer (Chromium headless)
출력 형식 SVG (인라인) PNG (file:// 참조)
뷰어 mermaid-viewer.js (클릭 확대) 없음 (이미지)
최종 변환 없음 (브라우저가 직접 표시) WeasyPrint → PDF
설정 파일 mermaid-init.js 내 initialize() mermaid-config.json

브라우저 쪽 파일(mermaid-init.js, mermaid-viewer.js, mermaid-viewer.css)을 수정해도 PDF export에는 영향이 없고, 그 반대도 마찬가지다.


8. 수정 이력

날짜 파일 변경 내용
2026-02-23 ExportFormatter.php replaceMermaidWithSvg 메서드 신규 추가 (SVG 방식)
2026-03-11 ExportFormatter.php SVG → PNG 전환, htmlToPdf에 try/finally 래핑
2026-03-11 mermaid-config.json htmlLabels: false 제거, 기본값 사용
2026-03-23 ExportFormatter.php 메서드명 replaceMermaidWithSvgreplaceMermaidWithImage
2026-03-23 ExportFormatter.php containHtml()file:// 경로 가드 추가

9. 관련 문서