파서가 풀어야 할 문제
1편에서 소개한 ascii2card의 핵심은 파서다. ASCII 텍스트를 받아서 구조화된 JSON을 뱉어야 한다.
문제는 Claude가 만드는 ASCII 다이어그램이 천차만별이라는 점이다. 어떤 건 ┌─┐ └─┘ 박스가 중첩되어 있고, 어떤 건 │──>│ 화살표가 날아다니고, 어떤 건 └─ ├─ 트리 구조다. 심지어 하나의 다이어그램 안에 박스, KV표, 불릿 리스트가 섞여서 나온다.
파서는 이 혼돈 속에서 “이건 아키텍처 박스다”, “이건 타임라인이다”, “이건 비교 다이어그램이다"를 자동으로 판별해야 한다.
타입 감지 우선순위
파서가 텍스트를 받으면 가장 먼저 하는 일은 “이게 어떤 종류의 다이어그램인가"를 판별하는 것이다. 아래 순서로 확인한다:
[우선순위 1] tree
조건: /[└├]─/.test(text) && !/^┌/.test(text.trim())
의미: └─ 또는 ├─가 있고, 첫 줄이 ┌로 시작하지 않으면 → 박스 없는 순수 트리
[우선순위 2] sequence
조건: /│[─]+.*>│|│<[─]+.*│/.test(text) && !/┌.*┐/.test(text)
의미: │──>│ 화살표가 있고, 박스(┌┐)가 없으면 → 시퀀스 다이어그램
[우선순위 3] hierarchy
조건: 같은 줄에 ┐ ┌ (공백 구분 다중 박스) && /▼/.test(text)
의미: 한 줄에 여러 박스 시작 + 아래 화살표 → 계층 구조
[우선순위 4] compare
조건: /──+→/.test(text) && /└──[^┌]+──+┘/.test(text)
의미: ──→ 결과 화살표 + └── label ──┘ 바닥 라벨 → 비교 다이어그램
[나머지] 통합 파서
→ 줄 단위로 분석하면서 timeline, kv, section, list 등을 판별
감지 순서가 중요하다. tree와 hierarchy 모두 ├─를 포함할 수 있고, sequence와 일반 박스 모두 │를 포함한다. 가장 특수한 패턴부터 확인하고, 실패하면 점점 일반적인 패턴으로 폴백하는 구조다. 순서를 잘못 잡으면 트리를 계층 구조로 오인하거나, 시퀀스를 일반 박스로 파싱해버린다.
통합 파서의 줄 단위 처리 흐름
4가지 전용 타입(tree, sequence, hierarchy, compare)에 해당하지 않으면 통합 파서가 돌아간다. 매 줄을 순서대로 읽으면서 14단계 패턴 매칭을 시도한다:
for (각 줄) {
// [전처리] 외곽 테두리 제거
┌───┤, └───┘, ├───┤ → skip
│ content │ → "content" 추출
// [노이즈 필터링]
빈 줄, ─── 구분선, │┼┬┴▼▲←→─ 구조 문자만 있는 줄 → skip
// [내부 박스 감지] ┌가 포함된 줄
┌의 개수만큼 박스 생성, └ 만날 때까지 내용 수집
// [패턴 매칭] 순서대로 시도
① 첫 번째 텍스트 줄 → title
② N월: ... → timeline 아이템
③ text: → section 헤더
④ - bullet → section 아이템 추가
⑤ ※ → note
⑥ → highlight → highlight
⑦ • bullet (2컬럼 모드) → columns 아이템
⑧ [a] [b] [c] → branch
⑨ 📌 text: → note
⑩ ✅❌⚠️💡🔥⭐ text → 이모지 section
⑪ key: value → kv (짧으면) / section (길면)
⑫ [N] 또는 N. 또는 N️⃣ → numbered list
⑬ -> 또는 → subtext → list 하위항목
⑭ text text (6칸+ 공백) → kv 스페이스 구분
}
// 루프 종료 후 누적 데이터 모두 flush
순서가 중요하다. ※를 먼저 체크하지 않으면 note가 section 아이템으로 빨려 들어가고, 📌을 이모지 section보다 먼저 체크하지 않으면 note가 section으로 분류된다. 14단계의 순서는 수십 번의 시행착오로 잡은 것이다.
주요 파싱 기법
기법 1: 외곽 테두리 제거
모든 ASCII 박스의 “액자"를 벗기고 순수 내용만 추출하는 첫 단계다.
// ┌ └ ├로 시작하는 줄은 구조선이므로 통째로 무시
if (line.trim().startsWith('┌') || line.trim().startsWith('└')
|| line.trim().startsWith('├')) continue;
// │로 감싸진 내용에서 │ 제거
let content = line;
if (content.startsWith('│')) content = content.slice(1);
if (content.endsWith('│')) content = content.slice(0, -1);
기법 2: 내부 박스 감지
외곽 테두리를 벗긴 후에도 ┌이 남아있다면, 그건 내부에 중첩된 박스다. ┌의 x좌표(인덱스)를 기록해두면, 이후 줄에서 같은 위치 범위의 텍스트를 잘라서 각 박스의 내용을 분리할 수 있다.
if (trimmed.includes('┌')) {
innerBoxCount = (trimmed.match(/┌/g) || []).length;
// ┌의 위치를 기록 → 나중에 컬럼 분리에 사용
innerBoxPositions = [];
let pos = 0;
for (let b = 0; b < innerBoxCount; b++) {
pos = content.indexOf('┌', pos);
innerBoxPositions.push(pos);
pos++;
}
}
┌이 한 줄에 3개 있으면 3-column 박스로 인식한다. 이 방식으로 아래 같은 병렬 박스를 정확히 분리한다:
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ RDS │ │ InfluxDB │ │ ElastiCache │ │
│ │ PostgreSQL │ │ (EC2 자체) │ │ Redis │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
기법 3: 이모지 키캡 숫자 매칭
생성형 AI는 번호를 세 가지 형식으로 쓴다. 하나의 정규식으로 모두 처리한다:
const numMatch = trimmed.match(/^(?:\[(\d+)\]|(\d+)\.|(\d)\uFE0F?\u20E3)\s*(.+)/);
[1] 텍스트— 대괄호 번호1. 텍스트— 마침표 번호1️⃣ 텍스트— 이모지 키캡 (\uFE0F= 변형 선택자,\u20E3= 결합 키캡)
이모지 키캡이 까다로운데, 1️⃣는 실제로 1 + \uFE0F + \u20E3 세 코드포인트의 조합이다. \uFE0F는 선택적이라 있을 수도 없을 수도 있어서 ?로 처리한다.
기법 4: 들여쓰기 기반 depth 계산
트리 구조에서 깊이를 판별하는 방법이다:
const depth = Math.floor((line.match(/^\s*/)[0].length) / 3);
앞쪽 공백 수를 3으로 나눈다. Claude가 만드는 트리는 보통 3칸 들여쓰기를 사용하기 때문이다. └─ 제도·약관이 공백 0개면 depth 0, └─ 전기요금제도가 공백 3개면 depth 1이 된다.
기법 5: 위치 기반 파싱
계층 다이어그램에서 자식 박스들이 가로로 나열될 때, 텍스트의 x좌표(컬럼 위치)가 “어떤 박스에 속하는가"를 결정한다:
childBoxPositions.forEach((startPos, boxIdx) => {
const endPos = childBoxPositions[boxIdx + 1] || line.length;
const segment = line.substring(startPos, endPos);
// segment에서 │ 사이의 내용 추출
});
문자열을 2D 그리드처럼 다루는 기법이다. ASCII 아트가 본질적으로 2차원 텍스트이기 때문에 x좌표 정보가 파싱에 핵심적인 역할을 한다.
기법 6: 중첩 모듈 감지
hierarchy 내부에서 박스 안의 박스를 처리해야 할 때가 있다. 재귀 없이 상태 머신으로 처리한다:
// │ ┌───┐ │ → 중첩 박스 시작
if (/│\s*┌[─]+┐\s*│/.test(segment)) {
box.currentModule = { lines: [] };
}
// │ ├───┤ │ → 모듈 경계
if (/│\s*├[─]+┤\s*│/.test(segment)) {
box.modules.push(box.currentModule);
box.currentModule = { lines: [] };
}
// │ └───┘ │ → 중첩 박스 종료
if (/│\s*└[─]+┘\s*│/.test(segment)) {
box.modules.push(box.currentModule);
box.currentModule = null;
}
currentModule이 null이 아니면 “지금 모듈 안이다"라는 뜻이다. 시작/경계/종료 세 가지 상태 전이만으로 중첩 구조를 처리한다.
기법 7: flush 패턴
줄 단위 파서에서 “여러 줄이 하나의 노드를 구성"하는 문제를 해결하는 패턴이다:
function flushPending() {
if (currentSection) { result.nodes.push(currentSection); currentSection = null; }
if (currentList) { result.nodes.push(currentList); currentList = null; }
if (kvItems.length > 0) {
result.nodes.push({ type: 'kv', items: [...kvItems] });
kvItems = [];
}
}
section, list, kv는 여러 줄에 걸쳐 누적된다. 새로운 타입의 줄을 만나면 이전에 누적된 데이터를 nodes에 push하고 초기화한다. 루프가 끝난 후에도 한 번 더 flush해서 마지막 노드를 놓치지 않는다.
flush 타이밍을 잘못 잡으면 section 아이템이 다음 section에 붙거나, kv 항목이 list에 섞이는 버그가 생긴다. 14단계 매칭의 각 분기에서 “이 타입이 시작되면 이전 타입을 flush한다"는 규칙을 엄격하게 지켜야 한다.
파서 진화 과정
파서는 v1에서 v12까지 진화했다. 새 샘플을 추가할 때마다 기존 샘플이 깨지는 회귀 문제와의 싸움이었다.
v1 → 단순 리스트 (bullet만 파싱)
v2 → KV 쌍 추가
v3 → 박스 감지
v4 → 내부 박스 + 비용 파싱
v5 → 이모지 섹션
v6 → 타임라인
v7 → 2컬럼 레이아웃
v8 → 비교 다이어그램
v9 → 트리 구조
v10 → 시퀀스 다이어그램
v11 → 계층 구조
v12 → 중첩 모듈 + 브랜치 (10/10 테스트 통과)
전형적인 패턴은 이랬다. v6에서 타임라인을 추가했더니 “7월:“을 key: value KV로 파싱하던 기존 로직과 충돌했다. N월: 패턴을 KV보다 먼저 체크하도록 순서를 바꿨더니 이번엔 “장점:” 같은 섹션 헤더가 타임라인으로 잡혔다.
결국 14단계 매칭 순서와 flush 타이밍 조정이 파서 개발의 핵심 과제였다. 새 타입을 추가할 때마다 기존 11개 샘플을 전부 돌려보고, 깨지는 게 있으면 우선순위를 조정하는 과정을 반복했다.
회귀 테스트 설계
파서가 복잡해질수록 회귀 테스트가 필수가 됐다. 에디터에 내장된 11개 프리셋 샘플이 곧 테스트 케이스 역할을 한다:
const asciiSamples = {
timeline: `...`, // 타임라인 (월별 흐름)
season: `...`, // 비교 (여름 vs 겨울)
spec: `...`, // KV 스펙표
diagram: `...`, // 2컬럼 + 중앙박스 + 분기
sections: `...`, // 이모지 섹션 (✅❌)
tree: `...`, // 트리 경로
arch: `...`, // 아키텍처 박스
hierarchy: `...`, // 계층 다이어그램
sequence: `...`, // 시퀀스 다이어그램
sequenceCtrl: `...`, // 시퀀스 (제어 흐름)
hierarchyNested: `...` // 중첩 모듈 계층
};
새 타입을 추가할 때의 규칙은 단순하다:
- 새 ASCII 샘플을 추가한다
- 파서를 수정한다
- 기존 11개 + 새 샘플 전부 돌려본다
data.title이 존재하고,data.nodes.length > 0이고, 각 노드의type이 기대값과 일치하면 통과
자동화된 테스트 러너도 별도로 만들어뒀다(card-parser-tests.js). 제목 포함 여부, 노드 타입 배열 일치, 서브아이템 존재 여부 등을 검증한다. 파서 수정 후 node card-parser-tests.js를 돌리면 10개 테스트의 통과/실패가 바로 나온다.
다음 편 예고
이번 글에서는 ASCII 텍스트를 JSON AST로 변환하는 파서의 내부 구조를 다뤘다. 타입 감지 우선순위, 14단계 줄 단위 매칭, 7가지 핵심 파싱 기법, 그리고 v1에서 v12까지의 진화 과정을 살펴봤다.
다음 편에서는 이 JSON을 실제 카드 UI로 렌더링하는 과정을 다룬다. NodeRenderer 컴포넌트 매핑, 6개 테마 시스템, 20색 팔레트, 그리고 CSS-in-JS로 동적 테마를 구현하는 방법을 풀어본다.