Figma → 코드 자동화 스킬 개발기: 엣지케이스와의 전쟁
Claude Code 스킬 시스템을 이용해 Figma 디자인 시안을 Next.js 코드로 자동 퍼블리싱하는 워크플로우를 구축했다. 단순한 "Figma 읽고 코드 쓰기"를 넘어, 대규모 디자인을 안정적으로 처리하기 위한 아키텍처를 5라운드에 걸쳐 개선한 기록이다.
왜 만들었나
Figma 시안을 코드로 옮기는 작업은 반복적이다. 색상 토큰 매핑, 레이아웃 스펙 추출, 아이콘/이미지 다운로드, 컴포넌트 조립... 매번 같은 실수를 반복했다.
- Figma MCP가 반환하는 값을 그대로 믿었다가 실제 px 값과 달라서 다시 수정
- 아이콘 이름을 추측으로 골랐다가 존재하지 않는 컴포넌트를 import
- 큰 페이지를 한 번에 구현하다가 컨텍스트 초반에 분석한 내용을 후반에 잊어버림
세 번째가 가장 치명적이었다. AI 에이전트에게 "이 페이지 전체를 구현해줘"라고 하면, 토큰(문맥) 한계 때문에 페이지 앞부분 분석 내용을 뒷부분 구현 시점에 잊어버린다. React로 치면 거대한 단일 컴포넌트를 만드는 것과 비슷한 문제다.
그래서 오케스트레이터 스킬을 만들었다. AI가 직접 코드를 쓰는 게 아니라, 각 단계를 전문 스킬에 위임하고 결과를 검증하는 감독자 역할이다. React에서 컨테이너 컴포넌트가 하위 컴포넌트를 조합하듯, 오케스트레이터가 전문 스킬들을 조합한다.
전체 아키텍처
사용자 입력: Figma URL + 옵션(페이지 경로, 컴포넌트 이름)
│
▼
┌─ PRE ──────────────────────────────┐
│ URL에서 fileKey, nodeId 파싱 │
│ 형식 검증 (잘못된 입력 즉시 차단) │
└────────────────────────────────────┘
│
▼
┌─ PHASE 0 ─────────────────────────────┐
│ Figma REST API로 디자인 스펙 수집 │
│ 레이아웃, 색상, 자산, 컴포넌트 트리 │
└───────────────────────────────────────┘
│
▼
┌─ PHASE 1 ──────────────────────┐
│ 아이콘 SVG 추출 + 최적화 │
│ 이미지 다운로드 + 경로 생성 │
└────────────────────────────────┘
│
▼
┌─ PHASE 1.5 ────────────────────────────────┐
│ 디자인을 섹션으로 분할 │
│ 섹션 간 의존성 분석 → 병렬/순차 결정 │
└────────────────────────────────────────────┘
│
┌────────┼────────┐
▼ ▼ ▼
┌──────────┐┌──────────┐┌──────────┐
│ Agent 1 ││ Agent 2 ││ Agent 3 │ ← PHASE 2: 섹션별 병렬 구현
│ Header ││ Hero ││ Footer │
└──────────┘└──────────┘└──────────┘
│ │ │
└────────┼────────┘
▼
┌─ PHASE 3 ──────────────────────────┐
│ 섹션 결과물 병합 │
│ page.tsx 조립 + 타입 체크 │
└────────────────────────────────────┘
│
▼
┌─ POST ─────────────────┐
│ 임시 파일 정리 │
└────────────────────────┘핵심 원칙은 메인 컨텍스트는 감독만 한다는 것이다. 실제 구현은 서브에이전트가 맡는다. 큰 디자인도 각 에이전트가 자기 섹션 스펙만 보면 되므로 컨텍스트 소실이 없다.
React 컴포넌트 설계로 비유하면 이렇다:
// 오케스트레이터 = 컨테이너 컴포넌트
function Orchestrator({ figmaUrl, pagePath }) {
// 직접 UI를 그리지 않고, 하위 컴포넌트에 위임
const spec = useFigmaSpec(figmaUrl) // PHASE 0
const assets = useAssetExtract(spec) // PHASE 1
const sections = useSectionSplit(spec) // PHASE 1.5
return sections.map(section =>
<SectionAgent key={section.id} spec={section} /> // PHASE 2
)
}섹션 분할 전략
디자인 크기에 따라 에이전트 수를 조절한다. Figma 노드 트리에서 루트의 직계 자식 수를 기준으로 판단한다.
| 규모 | 루트 직계 자식 수 | 전략 | React 비유 |
|---|---|---|---|
| 소형 | 1~3개 | 단일 에이전트 | 컴포넌트 하나로 충분 |
| 중형 | 4~7개 | 2~3개 그룹 | 논리적 영역별 분리 |
| 대형 | 8개+ | 섹션별 개별 에이전트 | 페이지를 섹션 컴포넌트로 분해 |
섹션 간 import 의존성도 분석한다. 예를 들어 CardGrid가 Card 컴포넌트를 쓴다면, Card가 먼저 만들어져야 한다. 독립적인 섹션은 병렬로, 의존 관계가 있으면 순차 실행한다.
이건 Promise.all vs await 순차 실행을 선택하는 것과 같은 로직이다:
// 독립적인 섹션 → 병렬 실행
const [header, hero, footer] = await Promise.all([
buildSection('Header', headerSpec),
buildSection('HeroSection', heroSpec),
buildSection('Footer', footerSpec),
])
// 의존 관계 있는 섹션 → 순차 실행
const card = await buildSection('Card', cardSpec)
const cardGrid = await buildSection('CardGrid', cardGridSpec) // Card에 의존5라운드 개선 과정
처음 만든 스킬은 단순했다. "Figma URL 받아서 스펙 저장하고 에이전트 돌려라." 하지만 실제로 쓰다 보니 엣지케이스가 쏟아졌다. 매번 실패할 때마다 원인을 분석하고 방어 로직을 추가하는 식으로 개선했다.
Round 1: 기본 안전장치
발견된 문제 3가지:
1) 오래된 캐시 값 재사용. 이전 세션에서 사용한 Figma fileKey가 임시 파일에 남아있으면, 새 URL을 입력해도 옛날 파일을 조회한다. React로 치면 useEffect에서 이전 요청의 응답이 현재 상태를 덮어쓰는 stale closure 문제와 비슷하다.
2) 불완전한 스펙을 통과시킴. Figma API에서 데이터를 받아왔는데, 필수 섹션(레이아웃, 색상, 자산 목록, 컴포넌트 트리) 중 일부가 빠져있어도 다음 단계로 넘어갔다. form validation 없이 submit하는 것과 같다.
3) 병렬 에이전트의 경쟁 조건(Race Condition). 여러 에이전트가 동시에 같은 파일(shared.md)에 결과를 쓰려다 내용이 뒤섞였다.
해결 방향:
// 1) fileKey 검증 — URL에서 직접 파싱하고 즉시 검증
function parseAndValidate(url: string) {
const fileKey = extractFileKey(url)
if (!fileKey) throw new Error('fileKey 파싱 실패')
const nodeId = extractNodeId(url)
if (!/^\d+:\d+$/.test(nodeId)) throw new Error(`nodeId 형식 오류: ${nodeId}`)
return { fileKey, nodeId }
}
// 2) 스펙 완전성 검증 — 필수 섹션이 모두 있는지 체크
const requiredSections = ['레이아웃 스펙', '색상 스펙', '자산 목록', '컴포넌트 트리']
const missing = requiredSections.filter(s => !specContent.includes(s))
if (missing.length > 0) throw new Error(`스펙 누락: ${missing.join(', ')}`)경쟁 조건은 에이전트마다 개별 결과 파일을 쓰게 하고, 오케스트레이터가 나중에 순차 병합하는 방식으로 해결했다:
// 변경 전 (위험): 모든 에이전트가 같은 파일에 동시 쓰기
Agent 1 ──write──▶ shared.md ◀──write── Agent 2 ← 내용 충돌!
// 변경 후 (안전): 각자 독립 파일 → 오케스트레이터가 순차 병합
Agent 1 ──write──▶ result-1.md
Agent 2 ──write──▶ result-2.md
Agent 3 ──write──▶ result-3.md
│
Orchestrator ◀───── 검증 후 병합 (빈 파일이나 내용 부실하면 에러)프론트엔드에서도 같은 패턴을 쓴다. 여러 비동기 요청이 같은 상태를 직접 수정하면 꼬인다. 각각 결과를 받아두고 마지막에 한 번에 합치는 게 안전하다.
Round 2: URL 파싱 강화
발견된 문제:
Figma URL이 항상 figma.com/design/:fileKey/... 형태가 아니었다. branch URL, Make URL 등 변형이 있었다.
일반: figma.com/design/abc123/MyPage?node-id=10-20
^^^^^^ fileKey
브랜치: figma.com/design/abc123/branch/xyz789/MyPage
^^^^^^ 이게 진짜 fileKey
Make: figma.com/make/def456/MyPrototype
^^^^^^ fileKey하나의 정규식으로 처리하려다 실패하고, URL 패턴별 분기를 만들었다:
function extractFileKey(url: string): string {
if (url.includes('/make/')) {
return url.match(/figma\.com\/make\/([^/?]+)/)?.[1]
}
if (url.includes('/branch/')) {
// branch URL에서는 branchKey가 실제 fileKey
return url.match(/\/branch\/([^/?]+)/)?.[1]
}
// 기본 design URL
return url.match(/figma\.com\/design\/([^/?]+)/)?.[1]
}또 하나, 타입 체크 명령어가 프로젝트마다 달랐다. pnpm type-check인 곳도, pnpm typecheck인 곳도, 아예 스크립트가 없는 곳도 있었다. package.json의 scripts 필드를 확인해서 자동 감지하도록 했다.
Round 3: 단계 간 상태 유실
발견된 문제:
사용자가 입력한 --page src/app/dashboard 옵션이 중간에 사라졌다. PRE 단계에서 파싱한 값을 PHASE 3에서 읽으려는데, 그 사이에 값이 전달되지 않았다.
이건 React에서 흔히 겪는 prop drilling이 끊기는 문제와 같다. 부모 → 자식 → 손자로 값이 전달되다가 중간 컴포넌트가 전달을 빼먹으면 손자에서 undefined가 된다.
PRE (파싱) ──── pageePath = "src/app/dashboard"
│
PHASE 0~2 ──── (여러 단계를 거치면서...)
│
PHASE 3 ──── pagePath = ??? ← 어디로 갔지?해결: 파싱한 값을 임시 파일에 저장해서, 어느 단계에서든 읽을 수 있게 했다. React의 Context API처럼, 중간 단계를 거치지 않고 직접 접근하는 방식이다.
// Context처럼 동작하는 임시 파일
PRE에서 저장 → /tmp/.current-page = "src/app/dashboard"
PHASE 3에서 읽기 → const pagePath = readFile('/tmp/.current-page')
POST에서 삭제 → cleanup() // 다음 실행에 영향 안 주도록POST에서 삭제하는 것도 중요했다. 안 지우면 Round 1과 같은 stale 값 문제가 재발한다.
Round 4: 템플릿 변수 치환 문제
발견된 문제 2가지:
1) 빈 파일 목록 처리. 섹션별 스펙 파일을 순회할 때, 파일이 하나도 없으면 와일드카드 패턴(*.md) 자체가 문자열로 처리되어 "파일을 못 찾겠다"는 에러가 났다. JavaScript의 Array.prototype.map은 빈 배열이면 아무것도 안 하지만, 셸의 glob 패턴은 다르게 동작한다.
2) 플레이스홀더가 치환되지 않음. 공유 문서에 [여기에 페이지 경로] 같은 플레이스홀더를 넣었는데, 실제 값으로 치환되지 않고 그대로 에이전트에게 전달됐다.
해결: 두 문제 모두 "방어적 코딩"으로 해결했다. 순회 전에 파일 존재를 확인하고, 플레이스홀더 대신 실제 값을 미리 주입한 뒤 문서를 생성했다. TypeScript의 타입 시스템이 런타임 전에 잘못된 타입을 잡아주듯, 데이터를 사용하기 전에 검증하는 패턴이다.
Round 5: 최종 조립 단계 명확화
발견된 문제: PHASE 3에서 page.tsx를 조립할 때, "모든 섹션 컴포넌트를 import하라"고만 지시했다. 그런데 어떤 컴포넌트가 존재하는지 목록이 없었다. 있지도 않은 컴포넌트를 import하거나, 만들어진 컴포넌트를 빠뜨렸다.
// page.tsx를 조립해야 하는데...
import { Header } from './components/Header'
import { HeroSection } from './components/HeroSection'
import { CardGrid } from './components/CardGrid' // 이 컴포넌트가 진짜 있나?
// Footer는 빠뜨림 — 있는지 몰랐으니까해결: PHASE 2가 끝나면 실제로 생성된 파일 목록을 수집하고, 그 목록을 기준으로 page.tsx를 조립하도록 했다. 또한 page.tsx가 아예 없는 경우에도 명확하게 "새로 만들어라"라는 지시를 추가했다.
// 구현된 컴포넌트 목록을 수집
const implementedFiles = [
'src/components/dashboard/Header.tsx',
'src/components/dashboard/HeroSection.tsx',
'src/components/dashboard/CardGrid.tsx',
'src/components/dashboard/Footer.tsx',
]
// 이 목록을 기준으로 page.tsx 조립
// → import 누락이나 존재하지 않는 컴포넌트 참조 방지실제 동작 예시
대시보드 페이지를 퍼블리싱한다고 가정하자.
입력: Figma URL + --page src/app/dashboard --name DashboardPage각 단계가 어떻게 흘러가는지:
| 단계 | 수행 내용 | 결과 |
|---|---|---|
| PRE | URL에서 fileKey=abc123, nodeId=10:20 파싱 + 검증 | 통과 |
| PHASE 0 | Figma API로 전체 디자인 스펙 수집 (48KB) | 4개 필수 섹션 모두 확인 |
| PHASE 1 | 아이콘 3개 SVG 추출, 이미지 1개 다운로드 | 자산 준비 완료 |
| PHASE 1.5 | 루트 자식 4개 → 중형 → 전부 독립 | 4개 병렬 에이전트 결정 |
| PHASE 2 | Agent 1~4가 각각 Header, Hero, CardGrid, Footer 구현 | 4개 컴포넌트 파일 생성 |
| PHASE 3 | 4개 컴포넌트를 import하는 page.tsx 조립 + 타입 체크 | 에러 0개 |
| POST | 임시 파일 정리 | 완료 |
최종 결과: 5개 파일 생성 (4개 섹션 컴포넌트 + page.tsx), 타입 에러 0개.
핵심 설계 원칙
1. 감독자 패턴 (Orchestrator Pattern)
메인 에이전트는 코드를 직접 쓰지 않는다. 서브에이전트에 위임만 한다.
이 패턴의 장점은 컨텍스트 격리다. 대형 페이지를 하나의 에이전트가 처리하면 앞부분 분석 내용을 뒷부분에서 잊어버리지만, 섹션별로 나누면 각 에이전트가 자기 스펙만 집중해서 본다.
프론트엔드 비유: 거대한 App.tsx 하나에 모든 로직을 넣는 대신, 각 페이지/섹션을 독립 컴포넌트로 분리하는 것과 같다.
2. 파일 기반 상태 공유
에이전트 간 데이터 전달은 임시 파일로 한다. 메모리(변수)가 아닌 파일이므로 에이전트가 교체되어도 상태가 유지된다.
/tmp/.current-filekey ← Figma 파일 식별자
/tmp/.current-page ← 출력 경로
/tmp/figma-spec-cache.md ← 전체 디자인 스펙
/tmp/figma-result-section-N.md ← 각 에이전트의 구현 결과프론트엔드 비유: localStorage나 sessionStorage처럼, 컴포넌트(에이전트)가 언마운트되어도 데이터는 남아있는 구조.
3. 조기 실패 (Fail Fast)
잘못된 입력은 첫 단계에서 즉시 차단한다. fileKey가 없거나, nodeId 형식이 틀리거나, 스펙이 불완전하면 PHASE 0 이전에 에러를 던진다.
프론트엔드 비유: API 요청 전에 입력값을 검증하는 것, TypeScript가 빌드 타임에 타입 에러를 잡는 것과 같은 원리.
4. 경쟁 조건 방지
병렬 에이전트가 같은 파일에 쓰지 않는다. 각자 독립 파일에 쓰고, 오케스트레이터가 순차적으로 병합한다.
프론트엔드 비유: React의 useState 업데이트가 batching되는 것처럼, 동시 수정을 허용하지 않고 한 곳에서 통합 관리한다.
남은 한계
- 스펙에 없는 정보 요청: 서브에이전트가 구현 중 스펙에 없는 디자인 정보가 필요할 수 있다. 설계상 서브에이전트의 Figma 직접 호출은 금지되어 있고, 이 경우 PHASE 0의 스펙 수집이 불완전했다는 신호로 처리해야 한다.
- CSS 변수명 충돌: 스펙 문서에
$color-primary같은 CSS 변수명이 포함되면, 셸 스크립트가 이를 환경 변수로 착각해서 치환할 수 있다. 실제로는 AI가 파일 쓰기 도구를 사용하므로 문제없지만, 셸에서 직접 실행하면 주의해야 한다.
정리
5라운드에 걸쳐 수정한 항목:
| 라운드 | 문제 | 프론트엔드 비유 |
|---|---|---|
| R1 | 오래된 캐시, 불완전 검증, 동시 쓰기 충돌 | Stale closure, form validation, race condition |
| R2 | URL 변형 미대응, 프로젝트별 설정 차이 | 라우트 파라미터 파싱, 환경별 분기 |
| R3 | 단계 간 상태 유실 | Prop drilling 실패, Context 필요 |
| R4 | 빈 배열 처리, 플레이스홀더 미치환 | Nullish 체크, 템플릿 리터럴 오류 |
| R5 | 조립 대상 목록 부재, 생성 지시 누락 | 동적 import 목록, 조건부 렌더링 |
스킬 개발에서 얻은 교훈은 하나다: 엣지케이스는 처음부터 보이지 않는다. 워크플로우를 한 번 처음부터 끝까지 시뮬레이션하고 나서야 "이 값이 저기서 사라지는구나", "이 두 에이전트가 동시에 같은 파일에 쓰는구나"가 보인다.
이건 프론트엔드 개발에서도 마찬가지다. 컴포넌트 하나를 만들 때는 잘 동작하지만, 실제 페이지에 통합하고 다양한 데이터를 넣어보면 예상 못한 케이스가 나온다. 재귀적으로 도출하고 수정하는 과정 자체가 시스템을 강화하는 방법이다.