Figma 퍼블리싱 파이프라인 07. LLM이 비교하지 않는 v5 설계
계속 같은 자리에서 실패하고 있었다. Figma JSON과 코드를 LLM에게 던지면서 둘이 뭐가 다른지 찾아달라고 했는데, 돌릴 때마다 결과가 달랐다. 색상을 틀리게 읽거나, 하위 레이어를 슬쩍 건너뛰거나, 있지도 않은 컴포넌트를 추측으로 언급했다.
비교는 언어 문제가 아니라 데이터 문제였다
#374151과 grey.700이 같은 색인지 판단하려면 토큰 맵을 알아야 한다. px={4}가 16px인지 알려면 Chakra의 1 unit = 4px 규칙을 알아야 한다. LLM은 이걸 매번 추론으로 처리하고 있었다. 추론이니까 흔들리는 게 당연했다.
그래서 원칙을 하나만 세웠다. LLM이 비교하는 구간을 없앤다.
비교는 규칙 기반 Python 엔진이 하고, LLM은 그 결과를 읽고 코드만 고친다.
단계를 13개로 쪼갠 이유
잘게 나눈 건 의도한 것이다. 각 단계가 책임을 하나만 가지면 중간에 잘못됐을 때 그 단계만 다시 돌릴 수 있다. 전체를 처음부터 반복하는 건 너무 비쌌다.
| 구간 | 처리 주체 | 토큰 | 하는 일 |
|---|---|---|---|
| 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이 됐다.
필요한 섹션만 골라 완전히 읽는다
Phase 0은 Figma URL에서 fileKey와 nodeId를 파싱해 REST API를 한 번 호출한다.
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개 노드)Phase 2는 고른 nodeId마다 API를 개별 호출해서 모든 속성을 뽑는다. 좌표와 크기, layoutMode와 padding, gap, fills와 strokes, fontSize와 fontWeight, cornerRadius, 컴포넌트 variant, visible과 opacity, 그리고 자식 노드를 재귀로 전부 가져온다.
한 번에 다 읽지 않고 고른 것만 깊게 읽는 구조라, 필요 없는 영역에 토큰과 시간을 쓰지 않는다. Phase 1이 넘겨주는 semantic_areas의 nodeId 배열을 Phase 2가 자동으로 받아서 사람이 중간에 낄 일이 없다.
Phase 3은 exportSettings가 달린 노드만 추려 다운로드한다. 아이콘은 public/icons/, 이미지는 public/images/로 가고 목록은 asset_manifest.json에 남는다.
양쪽을 같은 모양으로 만들었다
Phase 4는 Figma JSON을 정규화된 중간 표현으로 바꾼다. 여기가 전체의 핵심이다.
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는 코드베이스의 TSX 파일을 AST로 파싱해 같은 구조로 만든다.
<Text fontSize="md" color="grey.900">제목</Text>
<Box px={4} gap="2">...</Box>이게 이렇게 변환된다.
{
"role": "text",
"textContent": { "fontSize": 16, "color": "#1b1c1d" },
"layout": { "padding": { "left": 16, "right": 16 }, "gap": 8 }
}Chakra props든 Tailwind 클래스든 디자인 토큰이든 전부 실제 값으로 풀어낸다.
Phase 6은 양쪽 값의 단위를 통일한다. 이 단계가 없으면 비교 자체가 성립하지 않는다.
| 입력 | 통일 값 |
|---|---|
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 |
매칭에 신뢰도를 붙였다
Phase 7은 정규화된 두 IR을 1:1로 잇는다. 그냥 잇는 게 아니라 얼마나 확실한지를 같이 매긴다.
| 순위 | 방법 | confidence |
|---|---|---|
| 1 | 텍스트 완전 일치 | 1.0 |
| 2 | role과 구조 일치 | 0.95 |
| 3 | 컴포넌트 이름 완전 일치 | 0.85 |
| 4 | 구조 지문 (자식 수 + 타입 패턴) | 0.75 |
| 5 | LLM 폴백 | 0.5 |
{
"mappings": [
{
"designNodeId": "123:456",
"codeNodePath": "Table > Row[1] > Cell[2]",
"confidence": 0.95
}
]
}LLM 폴백은 위 네 단계가 전부 실패했을 때만 돈다. 대부분은 0토큰으로 끝난다. 신뢰도를 남겨두면 나중에 diff가 이상할 때 매칭이 잘못된 건지 값이 진짜 다른 건지 구분할 수 있다.
Phase 8은 매칭된 쌍을 정규화된 값으로 비교한다. LLM이 전혀 개입하지 않는다.
| 타입 | 설명 | 심각도 |
|---|---|---|
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 단계의 입력이 된다.
LLM은 세 단계에서만 쓴다
Phase 9는 diff.json을 사람이 읽을 형식으로 바꾸는 일만 한다. 약 500토큰이다.
## Summary
- 총 불일치: 15개
- HIGH: 3개 (즉시 수정 필수)
- MEDIUM: 8개 (권장)
## HIGH Priority
| 컴포넌트 | 문제 | 해결책 |
|---------|------|--------|
| Title | missing_in_code | 추가 필요 |Phase 10은 그 리포트를 보고 구현 설계서를 만든다. 약 1,000토큰이다. 어느 컴포넌트를 추가하거나 고칠지, 변경 전후 코드, 디자인 값에서 프로젝트 토큰으로 가는 매핑이 들어간다.
const colorMap = {
"#374151": "grey.700",
"#111827": "grey.10",
}
const spacingMap = {
16: "4", // 4 * 4px
8: "2",
}Phase 11이 실제로 파일을 고친다. 약 1,500토큰이다. diff에 잡힌 항목만 손대고, 기존 로직은 그대로 두고, 리팩터링은 하지 않고, 추측으로 고치지 않는다. 이 네 줄을 프롬프트에 못 박았다. 이걸 안 걸어두면 파일 전체를 다시 쓰려 든다.
v5.3에서 Phase 11의 실행 방식을 바꿨다. 이전에는 anthropic 패키지로 별도 API를 호출했는데, /tmp/figma_v5_phase11_context.md를 만들어두고 현재 Claude Code 세션이 직접 파일을 편집하도록 바꿨다. 별도 API 키가 필요 없어졌다.
검증과 정리
Phase 12는 .publishrc.json에 적힌 검증 명령을 돌린다.
pnpm type-checkHIGH 항목이 남아 있으면 Phase 11을 다시 돌린다. 타입 체크 통과는 필수다.
Phase 13은 임시 파일을 지우고 결과를 출력한다. 실제로 돌렸을 때 불일치 15개로 시작해 2개가 남았고(둘 다 LOW), 해결율 86%에 총 3,200토큰, 52초가 걸렸다.
이전 버전과 무엇이 달라졌나
| 항목 | v4 | v5 |
|---|---|---|
| 비교 주체 | LLM | Python Diff Engine |
| 정규화 | 없음 | Phase 6에서 통일 |
| 매핑 방식 | LLM 추론 | 규칙 기반 + confidence |
| 하위 레이어 누락 | 발생 | 재귀로 전부 포함 |
| 숨김 노드 | 생략 | visible: false로 포함 |
| 프레임워크 | 프로젝트 종속 | Chakra와 Tailwind 모두 지원 |
토큰 수 자체는 v4와 비슷했다. 달라진 건 정확도다. v4는 LLM이 Figma JSON과 코드를 같이 읽고 머릿속에서 비교했고, v5는 Python이 정규화된 값으로 비교한 결과를 LLM에 넘긴다. 같은 3,500토큰이어도 LLM이 하는 일의 성격이 완전히 다르다.