O(n)에서 O(n)까지: AI 시대를 위한 스트리밍 마크다운 렌더러 구축

발행: (2026년 1월 8일 오후 12:32 GMT+9)
10 min read
원문: Dev.to

Source: Dev.to

AI 채팅 애플리케이션을 만든 적이 있다면, 아마도 짜증나는 현상을 눈치챘을 것입니다: 대화가 길어질수록 렌더링 속도가 느려진다.

이유는 간단합니다 — AI가 새로운 토큰을 출력할 때마다 기존 마크다운 파서는 문서 전체를 처음부터 다시 파싱합니다. 이는 근본적인 아키텍처 문제이며, AI 출력이 길어질수록 상황은 더욱 악화됩니다.

우리는 이를 해결하기 위해 Incremark를 만들었습니다.


2025년 AI에 대한 불편한 진실

If you’ve been following AI trends, you know the numbers are getting crazy:

연도일반적인 출력
2022GPT‑3.5 응답? 몇 백 단어, 별거 아님
2023GPT‑4가 2,000–4,000 단어로 늘림
2024‑2025추론 모델(o1, DeepSeek R1)이 **10,000+ 단어 “사고 과정”**을 출력

We’re moving from 4K‑token conversations to 32K, even 128K. And here’s the thing nobody talks about: 500단어의 마크다운을 렌더링하는 것과 50,000단어의 마크다운을 렌더링하는 것은 완전히 다른 엔지니어링 문제.

Most markdown libraries? They were built for blog posts, not for AI that thinks out loud.

왜 당신의 마크다운 파서는 당신에게 거짓말을 하는가

전통적인 파서를 통해 AI 출력을 스트리밍할 때 내부에서 일어나는 일은 다음과 같습니다:

Chunk 1: Parse 100 chars ✓
Chunk 2: Parse 200 chars (100 old + 100 new)
Chunk 3: Parse 300 chars (200 old + 100 new)
...
Chunk 100: Parse 10,000 chars 😰

총 작업량: 100 + 200 + 300 + … + 10,000 = 5,050,000 문자 연산.

이것은 O(n²) 입니다. 비용이 단순히 증가하는 것이 아니라 폭발합니다.

20 KB AI 응답에 대해 이것은 다음을 의미합니다:

라이브러리파싱 시간
ant‑design‑x1,657 ms
markstream‑vue5,755 ms (거의 6 초에 달하는 파싱!)

이들은 모두 인기 있고 잘 유지 관리되는 라이브러리입니다. 문제는 코드가 나쁘다는 것이 아니라 잘못된 아키텍처에 있습니다.


핵심 통찰

마크다운 블록이 “완료”되면 다시는 변경되지 않습니다.

생각해 보세요. AI가 다음과 같이 출력할 때:

# Heading

This is a paragraph.

두 번째 빈 줄 이후에, 그 단락은 완료된 것입니다. 잠겨버렸습니다. 이후에 코드 블록, 리스트, 더 많은 단락이 오더라도 그 단락은 다시 건드려지지 않습니다.

그렇다면 우리는 왜 그것을 500번이나 다시 파싱할까요?


Incremark가 실제로 작동하는 방식

우리는 Incremark를 이 통찰을 바탕으로 구축했습니다. 핵심 알고리즘:

  1. 안정적인 경계 감지 — 빈 줄, 새로운 헤딩, 펜스 종료.
  2. 완료된 블록 캐시 — 다시 건드리지 않음.
  3. 대기 중인 블록만 재파싱 — 아직 입력을 받고 있는 블록.
Chunk 1: Parse 100 chars → cache stable blocks
Chunk 2: Parse only ~100 new chars
Chunk 3: Parse only ~100 new chars
...
Chunk 100: Parse only ~100 new chars

총 작업량: 100 × 100 = 10,000 문자 연산.

이는 500배 적은 작업입니다. 각 문자는 최대 한 번만 파싱 → O(n).

전체 벤치마크 데이터

우리는 실제 마크다운 파일 38개 — AI 대화, 문서, 코드‑분석 보고서(합성 데이터 아님)를 벤치마크했습니다. 총 6,484줄, 128.55 KB.

파일줄 수크기IncremarkStreamdownmarkstream‑vueant‑design‑x
test‑footnotes‑simple.md150.09 KB0.3 ms0.0 ms1.4 ms0.2 ms
simple‑paragraphs.md160.41 KB0.9 ms0.9 ms5.9 ms1.0 ms
introduction.md341.57 KB5.6 ms12.6 ms75.6 ms12.8 ms
footnotes.md520.94 KB1.7 ms0.2 ms10.6 ms1.9 ms
concepts.md914.29 KB12.0 ms50.5 ms381.9 ms53.6 ms
comparison.md1095.39 KB20.5 ms74.0 ms552.2 ms85.2 ms
complex‑html‑examples.md1473.99 KB9.0 ms58.8 ms279.3 ms57.2 ms
FOOTNOTE_FIX_SUMMARY.md2363.93 KB22.7 ms0.5 ms535.0 ms120.8 ms
OPTIMIZATION_SUMMARY.md3916.24 KB19.1 ms208.4 ms980.6 ms217.8 ms
BLOCK_TRANSFORMER_ANALYSIS.md4899.24 KB75.7 ms574.3 ms1984.1 ms619.9 ms
test‑md‑01.md91617.67 KB87.7 ms1441.1 ms5754.7 ms1656.9 ms
총 (38 파일)6,484128.55 KB519.4 ms3,190.3 ms14,683.9 ms3,728.6 ms

솔직히 말하면: 우리가 더 느린 부분

특이한 점을 발견하실 겁니다: footnotes.mdFOOTNOTE_FIX_SUMMARY.md에 대해 Streamdown이 훨씬 빠르게 보입니다.

파일IncremarkStreamdown이유?
footnotes.md1.7 ms0.2 msStreamdown은 각주를 지원하지 않습니다
FOOTNOTE_FIX_SUMMARY.md22.7 ms0.5 ms같음 — 단순히 건너뜁니다

이는 성능 문제가 아니라 기능 차이입니다.

Streamdown이 [^1] 각주 구문을 만나면 단순히 무시합니다. Incremark는 각주를 완전히 구현했으며, 스트리밍 전용 문제인 참조가 정의보다 먼저 도착하는 경우를 해결해야 했습니다.

Chunk 1: "See footnote[^1] for details..."   // reference first
Chunk 2: "More content..."
Chunk 3: "[^1]: This is the definition"      // definition later

전통적인 파서는 전체 문서를 가정합니다. 우리는 스트리밍 중에 불완전한 링크/이미지를 우아하게 처리하고, 정의가 도착하면 해결하는 “낙관적 참조”를 구축했습니다.

또한 수학 블록($…$)과 커스텀 컨테이너(:::tip)를 완전히 구현했습니다. 이는 AI 생성 콘텐츠에서 흔히 사용되기 때문입니다.


우리가 실제로 빛나는 곳

각주 파일을 제외하고, 표준 마크다운 성능을 살펴보세요:

파일라인 수IncremarkStreamdown이점
concepts.md9112.0 ms50.5 ms4.2×
comparison.md10920.5 ms74.0 ms3.6×
complex‑html‑examples.md1479.0 ms58.8 ms6.6×
OPTIMIZATION_SUMMARY.md39119.1 ms208.4 ms10.9×
test‑md‑01.md91687.7 ms1441.1 ms16.4×

패턴이 명확합니다: 문서가 클수록 우리의 이점이 커집니다.

가장 큰 파일(17.67 KB)의 경우:

라이브러리시간상대
Incremark88 ms
ant‑design‑x1,657 ms18.9× 느림
markstream‑vue5,755 ms65.6× 느림

O(n) vs O(n²) 실전 예시

전통적인 파서는 매 청크마다 전체 문서를 다시 파싱합니다:

Chunk 1: Parse 100 chars
Chunk 2: Parse 200 chars (100 old + 100 new)
Chunk 3: Parse 300 chars (200 old + 100 new)
...
Chunk 100: Parse 10,000 chars

총 작업량: 100 + 200 + … + 10,000 = 5,050,000 문자 연산.

Incremark는 새로 추가된 내용만 처리합니다:

Chunk 1: Parse 100 chars → cache stable blocks
Chunk 2: Parse only ~100 new chars
Chunk 3: Parse only ~100 new chars
...
Chunk 100: Parse only ~100 new chars

총 작업량: 100 × 100 = 10,000 문자 연산.

이는 500배 차이이며, 문서가 커질수록 격차는 더욱 커집니다.

Incremark를 언제 사용할까

Incremark를 사용할 경우:

  • 스트리밍 출력이 있는 AI 채팅 (Claude, ChatGPT 등)
  • 장문의 AI 콘텐츠 (추론 모델, 코드 생성)
  • 실시간 마크다운 편집기
  • 각주, 수학, 혹은 커스텀 컨테이너가 필요한 콘텐츠
  • 100K 토큰 이상 대화

⚠️ 다음 경우에는 대안을 고려하세요:

  • 일회성 정적 마크다운 렌더링 (marked를 직접 사용)
  • 매우 작은 파일 (예: 몇 줄의 텍스트)
import { ref } from 'vue'
import { IncremarkContent } from '@incremark/vue'

const content = ref('')
const isFinished = ref(false)

async function handleStream(stream) {
  for await (const chunk of stream) {
    content.value += chunk
  }
  isFinished.value = true
}

우리는 Vue 3, React 18, 그리고 Svelte 5를 동일한 API로 지원합니다—하나의 코어, 세 개의 프레임워크, 동작 차이 없음.

다음은

버전 0.3.0은 시작에 불과합니다.

AI 세계는 더 긴 출력, 더 복잡한 추론 흐름, 그리고 풍부한 포맷팅을 향해 나아가고 있습니다. 기존 파서는 따라잡을 수 없습니다—그들의 O(n²) 구조가 이를 보장합니다.

우리는 필요해서 Incremark를 만들었습니다. 여러분도 유용하게 사용하시길 바랍니다.

  • 📚 문서:
  • 💻 GitHub:
  • 🎮 실시간 데모:
    • Vue:
    • React:
    • Svelte:

이 도구가 디버깅 시간을 절약해 주었다면, GitHub에 ⭐️를 눌러 주시면 큰 힘이 됩니다. 질문이 있나요? 이슈를 열거나 아래에 댓글을 남겨 주세요.

Back to Blog

관련 글

더 보기 »