본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 19. 17:25

Markdown 리스트만으로 마인드맵을 구축한 방법 (그리고 이것이 어떻게 AI 스트리밍을 거의 무료로 만드는가)

요약

Markdown 리스트의 들여쓰기를 활용하여 마인드맵을 구축하는 기술적 방법론을 소개합니다. 데이터를 텍스트 기반의 단일 원천으로 관리함으로써 데이터 직렬화와 실시간 AI 스트리밍을 효율적으로 구현하는 구조를 설명합니다.

핵심 포인트

  • Markdown 들여쓰기를 활용한 트리 구조 파싱
  • 좌표와 스타일을 배제한 텍스트 중심의 데이터 모델
  • O(n) 복잡도의 단일 패스 파싱을 통한 효율성 확보
  • 텍스트-트리-레이아웃-SVG로 이어지는 단방향 데이터 흐름
  • 데이터 경량화를 통한 저비용 실시간 AI 스트리밍 구현

대부분의 마인드맵 도구들은 데이터를 독점적인 바이너리 블롭(binary blob) 형태로 저장하거나 WYSIWYG 에디터에 사용자를 가둡니다. 제가 React 컴포넌트인 Open MindMap을 시작했을 때, 저는 다른 모든 것을 결정짓게 된 한 가지 결정을 미리 내렸습니다:

신뢰할 수 있는 단일 원천(source of truth)은 들여쓰기가 된 평범한 Markdown 스타일의 리스트입니다. 그 외에는 아무것도 없습니다.

그 하나의 제약 조건은 제가 원했지만 완전히 계획하지는 않았던 여러 속성들로 이어졌습니다. 즉, diff(차이점 비교)에 친화적인 맵, 사소한 프로그래밍 방식의 생성, 그리고 사람들이 가장 많이 묻는 것인 실시간 AI 스트리밍(real-time AI streaming)이 그것입니다. 이 포스트에서는 텍스트에서 트리(tree)로, 그리고 SVG로 이어지는 각 요소들이 어떻게 결합되는지 살펴봅니다.

직접 체험해보고 싶다면: 데모는 mindmap.u14.app에서 확인할 수 있으며, 코드는 GitHub (@xiangfa/mindmap, Apache-2.0)에 있습니다.

전체 과정을 한 문장으로 요약하면

text  →  tree  →  layout  →  SVG

모든 단계는 순수(pure)하며 단방향(one-directional)이고, 모든 단계는 다시 텍스트로 되돌아가는 라운드트립(round-trip)이 가능합니다. 이러한 대칭성이 나머지 과정을 쉽게 만드는 비결입니다.

1. 데이터 모델 (The data model)

마인드맵은 트리들의 숲이므로, 노드(node) 타입은 예상하는 것만큼이나 평범합니다:

interface MindMapData {
  id: string;
  text: string;
...

중요한 세부 사항은 여기에 '없는' 것들입니다: 좌표(coordinates), 너비(widths), 색상(colors)이 없습니다. 위치와 스타일은 나중에 트리로부터 파생되며, 절대 저장되지 않습니다. 이를 통해 데이터 모델을 직렬화(serializable) 가능하게 유지하고, 텍스트를 유일한 신뢰할 수 있는 원천(single source of truth)으로 유지합니다.

2. 파싱(Parsing): 들여쓰기가 계층 구조입니다

파서(parser)의 유일한 실제 작업은 앞부분의 공백(whitespace)을 부모/자식(parent/child) 관계로 변환하는 것입니다. 그 핵심은 들여쓰기 깊이(indentation depth)를 키(key)로 사용하는 스택(stack)입니다:

function parseList(source: string): MindMapData[] {
  const lines = source.split("
").filter((l) => l.trim().length > 0);
  const roots: MindMapData[] = [];
...

이것이 핵심 아이디어의 전부입니다. 이전 줄보다 더 많이 들여쓰기된 줄은 그 줄의 자식(child)이 되며, 들여쓰기가 같거나 더 낮은 줄은 실제 부모를 찾을 때까지 스택(stack)을 pop 합니다. 여러 개의 최상위 줄(또는 빈 줄로 구분된 트리들)은 단순히 여러 개의 루트(roots)가 되며, 이를 통해 하나의 캔버스 위에 여러 개의 독립적인 맵을 구성할 수 있습니다.

이는 줄(line)에 대해 단 한 번의 패스(single pass)로 수행되는 O(n) 복잡도를 가지며, 백트래킹 (backtracking)이 없습니다. 이 속성을 기억해 두세요. 이것이 섹션 5에서 스트리밍 (streaming) 비용을 저렴하게 만드는 핵심입니다.

3. 두 가지 구문 계층 (The two layers of syntax)

단순한 들여쓰기만으로 트리를 얻을 수 있지만, 단순한 문자열로만 이루어진 트리는 지루합니다. 더 풍부한 구문은 구조적 파싱 (structural parse)이 수행된 이후에 실행되는 두 가지 계층에 존재합니다.

**줄 단위 마커 (Line-level markers)**는 텍스트가 저장되기 전에 해당 줄이 어떤 종류의 노드인지 결정합니다:

- React #framework #frontend      → tags: ["framework", "frontend"]
- [x] Ship the parser             → taskStatus: "done"
- [ ] Write the layout engine     → taskStatus: "todo"
...

**인라인 포맷팅 (Inline formatting)**은 노드가 렌더링될 때만 지연 처리 (lazily parsed)되어 작은 토큰 스트림 (token stream)으로 변환됩니다:

// "**bold** and `code`" → [{type:"bold",...}, {type:"text"," and "}, {type:"code",...}]
parseInlineMarkdown(node.text);

인라인 파싱을 지연 처리하는 것이 중요한 이유는, 현재 화면 밖에 있거나 접혀 있는(collapsed) 노드의 **bold**를 토큰화하기 위해 비용을 지불할 필요가 없기 때문입니다.

이러한 각각의 추가 기능은 **플러그인 (plugin)**입니다. 7가지가 내장되어 있으며 (태그, 폴딩, 교차 링크, LaTeX, 점선, 다중 행, 프론트매터), 모두 기본적으로 활성화되어 있고 각각 트리 쉐이킹 (tree-shakeable)이 가능합니다. 즉, 가져오지(import) 않은 플러그인은 번들 (bundle)에 포함되지 않습니다. 플러그인은 본질적으로 한 쌍의 훅 (hooks)입니다. 하나는 파싱 중에 줄을 점유하여 노드에 필드를 쓰는 역할을 하고, 다른 하나는 렌더링에 기여하는 역할을 합니다. 핵심 파서 (core parser)는 이 모든 것들을 알지 못합니다.

4. 라운드 트립 (round-trip)이 중요한 이유

위치와 스타일이 파생되는 것이기 때문에, 모든 변환 (transform)은 가역적 (reversible)입니다:

parseMarkdownList(text)        // text  → MindMapData
toMarkdownList(node)           // MindMapData → text
parseMarkdownMultiRoot(text)   // text  → MindMapData[]
...

이것이 바로 맵을 _diffable (차이 비교 가능)_하고 _version-controllable (버전 관리 가능)_하게 만드는 핵심입니다. 즉, 노드 하나가 변경되면 git에서 불투명한 이진 델타 (binary delta)가 아닌 단 한 줄의 diff로 나타납니다. 또한 내장된 텍스트 에디터가 시각적 맵과 원시 Markdown 사이를 양방향 손실 없이 전환할 수 있는 이유이기도 합니다. 이들은 동일한 문자열에 대한 두 가지 뷰 (view)일 뿐입니다.

5. 스트리밍 (Streaming): 거의 공짜나 다름없는 부분

여기에 보상이 있습니다. LLM은 마인드맵을 Markdown 리스트 형태로 토큰 (token) 단위로 생성합니다. 어느 순간이든 지금까지 받은 버퍼 (buffer)는 _이미 유효한 Markdown_입니다. 부분적인 리스트는 그저 더 작은 리스트일 뿐입니다. 따라서 스트리밍 소비자 (consumer)는 거의 모욕적일 정도로 단순합니다:

let buffer = "";
for await (const chunk of llmStream) {
  buffer += chunk;
...

매 틱 (tick)마다 전체 버퍼에 대해 O(n) 파서 (parser)를 다시 실행하여 React에 새로운 트리 (tree)를 전달합니다. 다음 두 가지 요소 덕분에 끊김 없이 매끄럽게 작동합니다:

  1. 파서가 저렴하고 완전합니다 (total). 불완전한 입력에 대해 절대 오류를 던지지 않습니다. 절반만 작성된 줄은 그저 다음 틱에서 길어질 짧은 텍스트를 가진 노드일 뿐입니다. 작동을 멈추게 할 "완전한 요소 대기" 상태 머신 (state machine)이 필요 없습니다.
  2. 틱 사이의 차이 (diff)가 매우 작습니다. 재파싱 (re-parsing)을 통해 생성된 트리는 이전 트리와 거의 동일하므로, React의 재조정 (reconciliation)과 SVG 재배치 (re-layout)는 변경된 부분만 건드립니다. 맵은 깜빡이며 다시 그려지는 대신 눈에 보이게 _성장_합니다.

선택 사항인 내장 AI 입력창은 이를 감싸서 모든 OpenAI 호환 엔드포인트 (endpoint)와 통신하지만, 스트리밍 동작이 이를 위해 특별하게 처리된 것은 아닙니다. 증가하는 문자열로 setMarkdown을 호출할 수 있는 것이라면 무엇이든 동일한 효과를 얻습니다. 컴포넌트는 LLM이 존재하는지조차 알 필요가 없었습니다. 그것이 바로 텍스트를 인터페이스로 만드는 핵심 목적입니다.

한 가지 솔직한 주의사항: AI 입력창은 브라우저에서 API 키를 직접 전송합니다. 이는 로컬 프로토타이핑 (local prototyping)에는 괜찮지만, 프로덕션 (production) 환경에서는 키가 서버 측에 머물 수 있도록 그 앞에 프록시 (proxy)를 두어야 합니다.

6. 레이아웃 (Layout): 트리에서 좌표로

여기에 진짜 작업이 숨겨져 있습니다. SVG는 자동 레이아웃 (automatic layout)을 전혀 제공하지 않기 때문에, 모든 픽셀을 직접 배치해야 합니다.

레이아웃 패스 (layout pass)는 각 노드에 (x, y) 좌표를 할당하는 재귀적 탐색 (recursive walk)입니다:

  • x는 깊이 (depth)에서 결정됩니다. 각 레벨은 루트 (root)로부터 일정 거리만큼 떨어집니다.
  • y는 서브트리 (subtree) 크기에서 결정됩니다. 노드는 자식들의 수직 범위 중앙에 배치되므로, 부모는 해당 브랜치 (branch)의 중간 지점 맞은편에 위치하게 됩니다.
function measureSubtree(node: MindMapData): number {
  if (!node.children?.length || node.collapsed) return NODE_HEIGHT;
  return node.children.reduce((h, c) => h + measureSubtree(c), 0);
...

기본적인 균형 잡힌 ("both") 레이아웃의 경우, 루트의 최상위 브랜치들은 양쪽의 높이를 대략적으로 균등하게 유지하기 위해 왼쪽과 오른쪽으로 나뉘며, 그 후 각 측면이 독립적으로 배치됩니다 (왼쪽 측면은 대칭 처리됨). leftright 모드는 모든 브랜치를 한 방향으로만 보냅니다.

각 최상위 브랜치는 또한 안정적인 **브랜치 인덱스 (branch index)**를 갖게 되는데, 이것이 색상 지정(coloring)이 작동하는 방식입니다. 인덱스는 CSS 변수 (--mindmap-branch-0-9)에 매핑되며, SVG 요소에 data-branch-index로 전달되므로 하나의 셀렉터 (selector)만으로 전체 브랜치의 테마를 지정할 수 있습니다.

텍스트 너비는 까다로운 부분입니다. SVG는 문자열이 DOM에 들어가기 전까지는 렌더링된 너비를 알려주지 않으므로, 노드 크기를 정할 때 렌더링된 텍스트를 측정하거나 근사치를 구해야 합니다. 이 과정에서 오차가 발생하면 가장자리가 박스에 정확히 맞지 않게 됩니다. 이는 컴포넌트 전체에서 가장 화려하지 않으면서도 가장 까다로운 부분입니다.

7. 렌더링 (Rendering): 왜 순수 SVG인가

모든 것은 SVG로 그려집니다. 캔버스 (canvas)도, 외부 그래프/레이아웃 라이브러리 (external graph/layout library)도 사용하지 않습니다. 이는 의도적인 트레이드오프 (tradeoff)입니다:

  • 어떤 확대 수준에서도 선명함 (Crisp at any zoom). 벡터는 픽셀이 깨지지 않으므로, 깊게 확대해도 선명함이 유지됩니다.
  • 일반 CSS로 테마 설정 가능 (Themeable with plain CSS). 노드(nodes), 엣지(edges), 태그(tags)는 의미론적 클래스(semantic classes)를 가진 실제 요소이므로, 비트맵을 다시 렌더링하는 대신 약 30개의 CSS 변수(variables)로 스타일을 지정할 수 있습니다.
  • 무료로 내보내기 가능 (Exportable for free) (다음 섹션).

엣지(Edges)는 SVG 경로(paths)입니다. 즉, 부모의 앵커 포인트(anchor point)에서 각 자식으로 이어지는 곡선입니다. 선택(selection), 자식 추가 버튼, 접기 토글(fold toggles), 태그는 모두 그 위에 레이어링된 추가적인 SVG 노드일 뿐입니다. 이에 따른 대가는 매우 큰 트리(trees)의 경우 DOM 노드가 매우 많아진다는 점이며, 따라서 순수 처리량(throughput) 측면에서는 캔버스(canvas)가 승리하는 한계점이 존재합니다. 하지만 이 도구가 설계된 문서 크기의 맵(maps)에서는 벡터(vectors)를 사용하는 것이 올바른 선택입니다.

8. 내보내기(Export)는 렌더링 모델에서 자연스럽게 도출됩니다

라이브 뷰(live view) 자체가 SVG이기 때문에, 이를 내보내는 것은 대부분 직렬화(serialization) 과정입니다:

  • **SVG 내보내기 (SVG export)**는 렌더링된 트리를 순회하며, 해결된 (resolved) CSS 값과 동일한 의미론적 클래스 및 data-branch-index 속성이 포함된 <style> 블록을 인라인(inline)으로 삽입합니다. 결과물은 외부 스타일시트 없이도 독립된 파일로서 올바르게 렌더링됩니다.
  • **PNG 내보내기 (PNG export)**는 해당 SVG를 고해상도(high DPI) 캔버스(canvas)에 그리고 블롭(blob)을 읽어옵니다.
  • **Markdown 내보내기 (Markdown export)**는 섹션 4에서 다룬 toMarkdownList를 다시 실행하는 것일 뿐입니다.
const svg = ref.current.exportToSVG();      // string
const png = await ref.current.exportToPNG(); // Promise<Blob>
const md  = ref.current.exportToOutline();   // markdown list

화면에 보이는 것과 동기화해야 할 별도의 "내보내기 렌더러 (export renderer)"는 존재하지 않습니다. 렌더러는 오직 하나뿐이기 때문입니다.

9. 여전히 어려운 점

판매하기보다는 정직하게 말하자면:

  • **텍스트 측정 (Text measurement)**은 앞서 언급했듯이 레이아웃 버그의 고질적인 원인입니다. 웹 폰트(Web fonts)가 늦게 로드되면 첫 번째 페인트(first paint) 이후 크기가 변할 수 있습니다.
  • **거대한 트리 (Large trees)**는 DOM 노드 수의 한계치를 밀어붙입니다. 접기(Folding) 기능이 도움이 되지만, SVG 트리의 가상화(virtualization)는 그 자체로 하나의 거대한 프로젝트입니다.
  • **교차 링크 (Cross-links)**는 의도적으로 깔끔한 트리 모델을 깨뜨립니다. 이는 임의의 앵커(anchors) 사이에 그려지는 두 번째 에지(edge) 레이어이며, 교차 없이 밀집된 맵을 통해 이를 라우팅하는 것은 여기서 진정으로 해결되지 않은 문제입니다.
  • AI 바(bar)를 위한 **브라우저 측 API 키 (Browser-side API keys)**는 실제 배포 시 프록시(proxy)가 필요합니다.

이 중 어느 것도 이 프로젝트에만 국한된 문제는 아닙니다. 이는 "텍스트로부터 브라우저에서 그래프를 실시간으로 렌더링한다"는 작업을 수행하기 위해 지불해야 하는 표준적인 비용(taxes)입니다.

요점 (Takeaways)

여기서 전이 가능한 아이디어가 하나 있다면 바로 이것입니다: 단순 텍스트 표현(plain-text representation)을 신뢰할 수 있는 단일 원천(source of truth)으로 선택하면, 놀라울 정도로 많은 기능들이 더 이상 별도의 기능이 아니라 자연스러운 결과물(consequences)이 됩니다. 차이점 비교(Diffability), 버전 관리(version control), 프로그래밍 방식의 생성(programmatic generation), 손실 없는 편집(lossless editing), 그리고 증분/스트리밍 렌더링(incremental/streaming rendering)은 모두 "그저 Markdown 리스트일 뿐이다"라는 점으로부터 파생되었습니다. 저는 이것들을 하나씩 직접 구축하지 않았습니다.

코드는 오픈 소스(github.com/u14app/mindmap, Apache-2.0)이며 npm에서 @xiangfa/mindmap으로 사용할 수 있습니다. 구문 설계(syntax design)와 파싱/스트리밍(parsing/streaming) 방식에 대한 피드백을 진심으로 환영합니다. 만약 텍스트 측정 문제를 처리하는 더 스마트한 방법을 발견하신다면 꼭 알려주세요.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0