Figma 퍼블리싱 파이프라인 07. v5: LLM이 비교하지 않는 설계
핵심 문제가 뭔지 알게 됐다
v4까지 계속 같은 패턴으로 실패했다. Figma JSON과 코드를 LLM에게 던지면서 "이 둘이 다른 게 뭔지 찾아줘"라고 했는데, 결과가 매번 달랐다. 색상을 틀리게 읽거나, sublayer를 슬쩍 생략하거나, 있지도 않은 컴포넌트를 추측으로 언급하는 식이었다.
한참 뒤에야 왜 그랬는지 이해했다. 비교는 언어의 문제가 아니라 데이터 구조의 문제였다. #374151과 grey.700이 같은 색상인지 판단하려면 토큰 맵을 알아야 하고, px={4}가 16px인지 알려면 Chakra의 spacing 규칙을 알아야 한다. LLM이 이걸 매번 추론에 의존해서 하니까 틀렸던 거였다.
그래서 v5의 설계 원칙은 하나였다. LLM이 비교하는 구간을 없앤다.
Figma JSON → Design IR
Code → Code IR
↓
Mapping Engine
↓
Diff Engine
↓
diff.json
↓
LLM (설명 / 설계 / 코드 생성)비교는 규칙 기반 Python 엔진이 맡고, LLM은 그 결과를 읽고 코드만 고치도록 했다.
13개 Phase로 구성한 이유
단계를 잘게 쪼갠 건 의도적인 선택이었다. 각 Phase가 하나의 책임만 갖도록 설계하면, 중간에 뭔가 잘못됐을 때 그 단계만 다시 돌릴 수 있었다. 전체를 처음부터 반복하는 건 너무 비쌌다.
Phase 0─3 : Python (0토큰) ── Figma REST API 수집
Phase 4─8 : Python (0토큰) ── IR 생성 + 정규화 + 비교
Phase 9─11 : LLM (~3,500토큰) ── 리포트 + 설계 + 코드 수정
Phase 12─13: Shell (0토큰) ── 검증 + 정리전체 토큰의 95%를 차지하던 "비교" 구간이 0토큰이 됐다. LLM을 쓰는 건 설명과 코드 생성 구간뿐이었다.
Phase 0~3: 데이터 수집
Phase 0: Figma 원본 수집
Figma URL에서 fileKey와 nodeId를 파싱해서 REST API를 1회 호출했다.
GET https://api.figma.com/v1/files/{fileKey}/nodes?ids={nodeId}
X-FIGMA-TOKEN: $FIGMA_TOKEN전체 노드 트리를 /tmp/figma_v5_raw.json으로 저장했다. LLM 없이 순수 HTTP 호출이었다.
Phase 1: 섹션 선택
raw.json의 직접 자식 노드를 분류해서 사용자에게 제시했다.
[v] 마케터 정보 (7개 노드)
[v] 광고 정보 (8개 노드)
[ ] 콘텐츠 게시 채널 (6개 노드)multiSelect 방식으로 원하는 섹션만 골랐다. 전체 페이지를 다 분석하지 않아도 됐다.
Phase 2: 선택 섹션 완전 분석
Phase 1에서 선택된 nodeId마다 REST API를 개별 호출해서 모든 속성을 뽑아냈다.
- 기하학: x, y, width, height, rotation
- 레이아웃: layoutMode, padding(상하좌우), gap
- 스타일: fills, strokes, fontSize, fontWeight, cornerRadius
- 컴포넌트: variant, colorPalette 등 props
- 상태: visible, hidden, opacity
- 자식 노드 재귀 전부
Phase 1 → Phase 2 연결은 자동화했다. semantic_areas에서 nodeId 배열을 파싱해서 자동 전달하도록.
Phase 3: 에셋 추출
exportSettings가 있는 노드만 추려서 다운로드했다.
- 아이콘 →
public/icons/ - 이미지 →
public/images/ - 매니페스트 →
asset_manifest.json
수동 다운로드가 없어졌다.
Phase 4~8: IR 생성과 비교 (핵심)
Phase 4: Design IR 생성
Figma JSON을 정규화된 중간 표현(IR) 으로 변환하는 단계였다. 이게 v5 전체의 핵심이었다.
type DesignIRNode = {
nodeId: string
name: string
role: 'text' | 'button' | 'input' | 'container' | 'image' | 'icon' | ...
visible: boolean
opacity: number
textContent?: {
value: string
fontSize: number // 정규화된 px
fontWeight: number
color: string // 정규화된 hex
lineHeight: number
}
layout: {
mode: 'flex' | 'grid' | 'absolute' | 'none'
direction?: 'row' | 'column'
gap?: number
padding: { top, right, bottom, left }
width?: number
height?: number
}
style: {
color?: string
backgroundColor?: string
borderRadius?: number
borderColor?: string
}
state: {
variant?: string
disabled?: boolean
}
children: DesignIRNode[]
}숨겨진 노드를 visible: false로 포함시킨 게 중요한 결정이었다. 기존 파이프라인은 숨긴 노드를 생략했는데, 그러면 코드와 비교할 때 "누락"으로 잘못 판정하는 경우가 생겼다.
Phase 5: Code IR 생성
현재 코드베이스의 TSX/JSX 파일을 AST 파싱해서 Design IR과 동일한 구조로 변환했다.
// 프로젝트 코드
<Text fontSize="md" color="grey.900">제목</Text>
<Box px={4} gap="2">...</Box>// Code IR
{
"role": "text",
"textContent": { "fontSize": 16, "color": "#1b1c1d" },
"layout": { "padding": { "left": 16, "right": 16 }, "gap": 8 }
}Chakra UI props, Tailwind 클래스, 디자인 토큰을 모두 해석해서 실제 값으로 변환했다.
Phase 6: Normalize
Design IR과 Code IR 양쪽의 값을 동일 단위로 통일하는 단계였다. 이 단계가 없으면 비교 자체가 불가능했다.
| 입력 | 통일 값 |
|---|---|
px={4}, padding="16px", gap="2" | 16 |
"gray.700", #374151, rgb(55,65,81) | "#374151" |
"text-sm", fontSize="14px", fontSize={14} | 14 |
rounded="md", borderRadius={6} | 6 |
Chakra UI의 1 unit = 4px 규칙, 토큰 → hex 변환 같은 것들이 여기서 처리됐다.
Phase 7: Mapping Engine
정규화된 Design IR과 Code IR을 1:1 매핑했다. 신뢰도(confidence)를 포함해서 매핑한 게 핵심이었다.
| 순위 | 방법 | confidence |
|---|---|---|
| 1 | 텍스트 exact match | 1.0 |
| 2 | role + 구조 일치 | 0.95 |
| 3 | 컴포넌트 이름 exact match | 0.85 |
| 4 | 구조 Fingerprint (자식 수 + 타입 패턴) | 0.75 |
| 5 | LLM fallback | 0.5 |
{
"mappings": [
{
"designNodeId": "123:456",
"codeNodePath": "Table > Row[1] > Cell[2]",
"confidence": 0.95
}
]
}LLM fallback은 위 4단계가 모두 실패했을 때만 발동했다. 대부분의 경우엔 0토큰이었다.
Phase 8: Diff Engine
매핑된 쌍을 정규화된 값으로 정확 비교했다. LLM이 전혀 개입하지 않았다.
8가지 diff 타입을 정의했다:
| Type | 설명 | Severity |
|---|---|---|
missing_in_code | Figma에 있는데 코드에 없음 | HIGH |
extra_in_code | 코드에만 있고 Figma에 없음 | MEDIUM |
text_mismatch | 텍스트 내용 다름 | HIGH |
color_mismatch | 색상 다름 | MEDIUM |
spacing_mismatch | 간격/패딩 다름 | MEDIUM |
font_mismatch | 폰트 크기/굵기 다름 | MEDIUM |
asset_mismatch | 에셋 참조 다름 | HIGH |
visibility_mismatch | visible 상태 다름 | MEDIUM |
{
"summary": { "total": 15, "high": 3, "medium": 8, "low": 4 },
"diffs": [
{
"type": "spacing_mismatch",
"field": "gap",
"design": 16,
"code": 12,
"severity": "MEDIUM"
}
]
}이 diff.json이 이후 모든 LLM 단계의 입력이 됐다.
Phase 9~11: LLM은 여기서만 쓴다
Phase 9: 비교 리포트 (~500토큰)
diff.json을 사람이 읽을 수 있는 형식으로 바꾸는 역할만 맡겼다.
## Summary
- 총 불일치: 15개
- HIGH: 3개 (즉시 수정 필수)
- MEDIUM: 8개 (권장)
## HIGH Priority
| 컴포넌트 | 문제 | 해결책 |
|---------|------|--------|
| Title | missing_in_code | 추가 필요 |
## MEDIUM Priority
| 컴포넌트 | 문제 | Design | Code | 해결책 |
|---------|------|--------|------|--------|
| Button | font_mismatch | 16px | 14px | fontSize 수정 |Phase 10: 설계서 작성 (~1,000토큰)
diff 리포트 기반으로 구체적인 구현 설계서를 만들었다. 어느 컴포넌트를 추가/수정할지, 변경 전/후 코드 예시, 디자인 값에서 프로젝트 토큰으로의 매핑이 들어갔다.
// 토큰 매핑 예시
const colorMap = {
"#374151": "grey.700",
"#111827": "grey.10",
}
const spacingMap = {
16: "4", // 4 * 4px
8: "2",
}Phase 11: 코드 수정 (~1,500토큰)
설계서를 보고 diff에서 지적된 항목만 수정했다. 전체 파일 재작성이 아니었다.
핵심 규칙은 단순했다:
- diff 항목만 수정
- 기존 로직 100% 보존
- 리팩터링 금지
- 추측에 의한 수정 금지
v5.3에서 한 가지를 바꿨다. Phase 11이 더 이상 anthropic 패키지로 별도 API를 호출하지 않았다. /tmp/figma_v5_phase11_context.md를 생성한 뒤 현재 Claude Code 세션이 직접 파일을 편집하는 방식으로 전환했다. 별도 API 키가 필요 없어졌다.
Phase 12~13: 마무리
Phase 12: 검증
pnpm type-check # .publishrc.json의 validateCommand 사용HIGH 항목이 남아 있으면 Phase 11을 재실행했다. type-check 통과가 필수였다.
Phase 13: 정리
임시 파일을 지우고 최종 보고서를 출력했다.
퍼블리싱 완료
- 시작: 15개 불일치
- 완료: 2개 불일치 (LOW만 남음)
- 해결율: 86%
- 총 토큰: ~3,200
- 총 소요시간: 52초v4와 비교하면
| 항목 | v4 | v5 |
|---|---|---|
| 비교 주체 | LLM | Python (Diff Engine) |
| 정규화 | 없음 | Phase 6에서 통일 |
| 매핑 방식 | LLM 추론 | 7-tier 규칙 + confidence |
| sublayer 누락 | 가능 | 불가 (재귀 전부 포함) |
| 숨김 노드 | 생략 | visible: false로 포함 |
| 프레임워크 | 프로젝트 종속 | Chakra/Tailwind 모두 지원 |
토큰 수는 v4와 비슷했다. 하지만 정확도가 달랐다. v4는 LLM이 Figma JSON과 코드를 동시에 읽고 머릿속에서 비교했다. v5는 Python이 정규화된 값으로 비교한 결과를 LLM에 전달했다. 같은 3,500토큰이어도 LLM이 하는 일의 성격이 완전히 달라진 거였다.