Figma MCP가 준 코드, 정말 디자인 시스템을 쓰고 있나요?
AI로 Figma 디자인을 코드로 옮기는 워크플로우를 쓰다 보면, 생성된 UI가 원래 디자인과 잘 맞지 않는 경우가 잦습니다. 버튼은 위치가 어긋나고, 목록은 디자인에 없던 형태로 나옵니다. 무엇보다 이미 디자인 시스템에 Button, Table 같은 공통 컴포넌트가 있는데도, AI는 그것을 쓰지 않고 매번 비슷한 UI를 새로 그려냈습니다.
원인을 따라가 보니 두 갈래였습니다. 하나는 Figma Dev Mode MCP가 디자인 시스템 컴포넌트를 절반만 알아본다는 것이었습니다. 어떤 컴포넌트는 이름과 속성이 살아 있는 형태로 내려오지만, 조금이라도 손댄 컴포넌트는 정체 없는 <div> 덩어리가 되어 버립니다.
다른 하나는 그 출력이 지나치게 길다는 것이었습니다. 컴포넌트 하나가 수십 줄로 부풀어 응답이 컨텍스트를 가득 채우면, 정작 모델이 디자인을 옮기는 데 쓸 자리가 남지 않습니다.
이 글에서는 MCP가 컴포넌트를 어떤 기준으로 알아보고 어떤 순간에 놓치는지 실제 출력으로 확인한 뒤, 두 문제를 PostToolUse Hook 하나로 함께 푼 과정을 정리합니다.
MCP가 공통 컴포넌트를 언제 인식하고 언제 놓치는가
MCP가 컴포넌트를 다루는 방식은 인스턴스가 메인 컴포넌트와 같은지에 달려 있습니다.
메인 컴포넌트와 인스턴스: 메인 컴포넌트는 디자인 시스템에 정의된 원본이고, 인스턴스는 그 원본을 화면 곳곳에 복제해 쓴 사본입니다. 인스턴스는 색상·텍스트 같은 값을 개별적으로 덮어쓸(override) 수 있습니다.
경계가 어디인지 세 가지 사례로 확인해 봤습니다.
먼저 잘 되는 경우입니다. 디자인 시스템에 등록된 SelectControl를 메인 컴포넌트 그대로 배치한 인스턴스는, MCP가 공통 컴포넌트로 알아봅니다. 이때 응답은 단순한 태그가 아니라 variant를 props로 갖춘 함수 컴포넌트입니다.
// MCP는 이 인스턴스를 타입까지 갖춘 함수 컴포넌트로 내려준다
type SelectControlProps = {
checked?: boolean;
size?: 'small' | 'medium';
type?: 'Checkbox' | 'Radio' | 'Toggle';
};
function SelectControl({ checked = false, type = 'Checkbox' }: SelectControlProps) {
if (checked && type === 'Toggle') {
return <div data-name="Checked=TRUE, Type=Toggle" data-node-id="782:65462">{/* ... */}</div>;
}
// variant 조합마다 분기가 이어진다
return <div data-name="Checked=FALSE, Type=Checkbox" data-node-id="782:65434" />;
}
checked, type 같은 variant가 타입과 함께 살아 있으니, AI는 이 출력을 그대로 가져다 쓰면 됩니다. 사용부는 이렇게 짧습니다.
<SelectControl checked type="Toggle" />
이 출력은 두 가지를 동시에 보여줍니다. MCP가 이 인스턴스를 컴포넌트로 분명히 인식했다는 사실, 그리고 그 대가로 인스턴스마다 이런 함수 정의가 통째로 따라붙는다는 점입니다.
체크박스 하나가 수십 줄이니, 인스턴스가 수십 개 모이면 함수 정의도 그만큼 쌓입니다. 뒤에서 다룰 토큰 문제가 여기서 시작됩니다.
문제는 인스턴스가 메인 컴포넌트에서 조금이라도 벗어나는 순간 시작됩니다. NavBar는 디자인 시스템에 등록돼 있지만, 이 인스턴스는 색상·테두리·텍스트를 메인과 다르게 바꿔 쓰고 있었습니다. MCP는 이렇게 달라진 인스턴스를 더 이상 공통 컴포넌트로 확신하지 못하고, 이름표 하나만 붙은 <div>로 펼쳐 버립니다.
// 색상·테두리·텍스트를 메인과 다르게 바꾼 인스턴스 → div로 펼쳐진다
<div className="absolute h-[44px] left-0 w-[360px]" data-name="NavBar" data-node-id="9946:43917">
컴포넌트였다면 담겨 있어야 할 variant도, 어떤 값을 덮어썼는지도 응답에는 없습니다. 남는 단서는 data-name="NavBar" 하나뿐입니다.
Banner는 실패하는 이유가 조금 다릅니다. 레이어 이름은 붙어 있지만 디자인 시스템 라이브러리와 연동돼 있지 않아, 애초에 컴포넌트로 취급될 근거 자체가 없습니다. 그래도 도착하는 결과는 NavBar와 똑같은 <div> 덩어리입니다.
| 컴포넌트 | Figma에서의 상태 | MCP 처리 결과 |
|---|---|---|
SelectControl | 메인 컴포넌트를 변경 없이 쓴 인스턴스 | 컴포넌트로 인식 → <SelectControl /> |
NavBar | 스타일을 덮어쓴 인스턴스 | 인스턴스로 취급 → <div data-name> |
Banner | 디자인 시스템에 연동되지 않은 인스턴스 | 인스턴스로 취급 → <div data-name> |
세 사례를 종합하면 규칙은 분명합니다. MCP는 인스턴스가 메인 컴포넌트와 완전히 같을 때만 컴포넌트로 인식하고, 텍스트 한 줄이라도 달라지면 <div>로 떨어뜨립니다. 그리고 <div>로 떨어지는 순간, 그 컴포넌트가 지녔던 variant와 override 값은 응답에서 사라지고 data-name만 남습니다. 이 상태로는 AI가 기댈 단서가 이름표뿐이라, 어느 컴포넌트인지 짐작해 매핑하더라도 어떤 속성이었는지 몰라 결국 잘못 씁니다.
여기에 앞서 본 함수 중복까지 겹치면, 컴포넌트 정확도와 토큰이 한꺼번에 나빠집니다.
먼저 플러그인으로 아이디어를 검증
MCP가 <div>로 펼치며 버린 속성은 사라진 게 아닙니다. 인스턴스가 어떤 variant를 골랐고 무엇을 override했는지는 Figma 파일의 노드 데이터에 그대로 남아 있고, MCP가 이 값을 응답에 옮기지 않았을 뿐입니다. 그렇다면 필요한 속성을 Figma에서 직접 가져와 MCP 출력에 되붙이면 됩니다.
그래서 먼저 수동으로 검증해 봤습니다. 선택한 프레임 아래의 모든 인스턴스를 훑어 상세 속성을 뽑아내는 임시 Figma 플러그인을 만들고, 거기서 나온 속성을 MCP 출력의 각 노드에 맞춰 붙였습니다.
결과는 기대대로였습니다. 테스트한 페이지에서 생성된 UI는 새 컴포넌트를 만들지 않고 기존 공통 컴포넌트를 그대로 썼고, 넘긴 props도 디자인 시스템 정의와 일치했습니다.
다만 이 방식은 매번 플러그인을 실행하고 결과를 손으로 이어 붙여야 했습니다. 그리고 플러그인이 뽑아낸 속성은 Figma REST API로도 똑같이 가져올 수 있는 값이었습니다. 그렇다면 이 과정을 사람이 반복할 이유가 없습니다. 검증된 아이디어를 훅으로 자동화하기로 한 이유입니다.
PostToolUse Hook: 먼저 API로 정보를 얻고, 그 위에서 압축한다
Claude Code의 PostToolUse Hook은 도구 호출이 끝난 직후 그 응답을 가로채는 자리입니다. 훅은 도구의 원본 응답을 stdin으로 받아 원하는 형태로 바꾼 뒤 updatedMCPToolOutput으로 돌려주고, 모델은 바뀐 응답을 받습니다. 이 자리에 get_design_context 응답만 걸어 두었습니다.
// get_design_context 응답만 가로챈다
if (input.tool_name === 'mcp__claude_ai_Figma__get_design_context') {
return handleDesignContext(input);
}
return {}; // 나머지 도구 호출은 그대로 통과
이 훅에서 중요한 건 처리 순서입니다. 훅은 MCP 출력을 곧바로 줄이지 않습니다. 먼저 Figma API로 원본 정보를 확보하고, 그 정보를 기준으로 삼은 다음에야 출력을 압축합니다. 어떤 태그가 어떤 컴포넌트이고 어떤 props인지를 API로 먼저 확인해 두면, 뒤에서 응답을 줄일 때 무엇을 지워도 되는지 판단할 수 있습니다. 이 순서를 세 단계로 나눠 보겠습니다.
1. 먼저 Figma API로 원본 속성을 확보한다
MCP 호출에는 어떤 Figma 파일의 어떤 노드를 봤는지가 fileKey와 nodeId로 함께 담겨 옵니다. 이 둘이면 REST API로 같은 노드를 다시 조회할 수 있습니다.
const url = `https://api.figma.com/v1/files/${fileKey}/nodes?ids=${nodeId}`;
const res = await fetch(url, { headers: { 'X-Figma-Token': token } });
const doc = (await res.json()).nodes[nodeId].document;
응답으로 받은 노드 트리를 재귀로 훑어 인스턴스 노드만 모읍니다. 각 인스턴스는 자신이 고른 variant(componentProperties)와 메인 컴포넌트에서 바꾼 값(overrides)을 그대로 담고 있습니다. MCP가 <div>로 펼치며 버렸던 바로 그 속성입니다.
// INSTANCE 노드만 재귀로 모아 이름·속성을 확보한다
const instances = collectInstances(doc);
// → [{ id, name, componentProperties, overrides }, ...]
이 인스턴스 목록이 이후 모든 가공의 기준점입니다. 이 목록을 먼저 확보해 두었기 때문에, 뒤에서 응답을 줄이더라도 잃었던 컴포넌트 정보를 되돌린 뒤 안전하게 덜어낼 수 있습니다. 목록이 없다면 같은 압축이 정보를 버리는 손실 압축이 됐을 것입니다.
네트워크 호출이라 실패하거나 30초를 넘길 수 있어, 그럴 때는 이 정보 없이 원본 출력 그대로 넘어가도록 두었습니다.
2. 확보한 정보로 정체성과 속성을 복원한다
이제 API가 알려준 인스턴스 정보를 MCP 출력에 되심습니다. 첫 단계는 <div data-name="X">를 <X> 형태의 컴포넌트 태그로 승격하는 것입니다. 다만 MCP 출력에는 컴포넌트가 아닌 일반 레이아웃에도 data-name이 붙어 있어서, 이름만 보고 전부 승격하면 엉뚱한 div까지 컴포넌트로 둔갑합니다. 그래서 API가 실제 인스턴스라고 확인해 준 이름만 승격 대상으로 거릅니다.
// API가 확인해 준 인스턴스 이름만 승격 대상으로 삼는다
[...collectDataNames(code)]
.filter((name) => instanceNames.has(name))
.reduce((acc, name) => promoteDataName(acc, name), code);
// <div data-name="Primary Button"> → <PrimaryButton>
이어서 API로 가져온 componentProperties의 variant 값을 data-variant 속성으로 태그에 주입합니다. <div>로 펼쳐지며 사라졌던 컴포넌트 이름과 variant가, 승격과 주입이라는 두 단계를 거쳐 다시 태그에 담깁니다.
3. 그 위에서 무손실로 압축한다
앞 단계에서 컴포넌트와 variant를 되살려 두었으니, 이제 응답에서 장황한 부분을 덜어낼 차례입니다. 이 단계의 목표는 토큰을 줄이되 컴포넌트와 props 정보는 남기는 것입니다.
가장 큰 절감은 중복 함수 제거에서 나옵니다. 앞서 봤듯 MCP는 인스턴스마다 함수 정의를 통째로 붙이는데, 우리는 이미 태그를 컴포넌트로 승격하고 variant까지 주입해 두었습니다. 그러니 그 함수 본문은 같은 정보를 한 번 더 적어 둔 사본일 뿐입니다. 함수 정의와 딸린 타입 정의를 지워도 잃는 정보가 없습니다.
다음은 반복되는 스타일입니다. MCP는 같은 컴포넌트를 쓸 때마다 동일한 Tailwind 클래스 문자열을 태그마다 되풀이해 붙입니다. 같은 태그가 세 번 이상 나오면, 그 태그들에 공통으로 들어간 클래스를 뽑아 주석 한 곳에 모으고 개별 태그에서는 지웁니다.
표처럼 같은 구조가 반복되는 경우엔 여기서 더 나아가, 구조가 동일한 <Table> 행을 하나만 남기고 나머지는 {/* × N 동일 구조 */} 주석으로 대신합니다.
마지막은 UI와 무관한 노이즈입니다. 기기 상태 표시줄이나 안드로이드 내비게이션 바처럼 화면 골격이 아닌 플랫폼 요소는 통째로 걷어내고, MCP가 코드 뒤에 덧붙이는 스타일 설명 텍스트나 잎 노드까지 붙은 data-node-id처럼 구현에 쓰이지 않는 메타데이터도 지웁니다.
이 과정을 지나면 응답에는 실제 UI 구조와, API로 되살린 컴포넌트·속성만 남습니다. 얼마나 줄었는지는 훅이 가공 전후 길이를 로그로 남겨 눈으로 확인할 수 있게 했습니다.
[figma-post] tokens | original=... → processed=...
매핑 문서로 코드 컴포넌트에 연결
승격과 주입까지 마쳐도 마지막 간극이 하나 남습니다. 훅이 만들어 준 태그 이름은 어디까지나 Figma 레이어 이름이라, 코드의 컴포넌트 이름이나 import 경로와는 다릅니다. 실제로 Figma의 Primary Button은 코드에서 Button이고, Dropdown Field는 Dropdown입니다. AI가 이 연결을 스스로 알 방법은 없습니다.
그래서 인스턴스 이름과 코드 컴포넌트, import 경로를 한 줄씩 짝지은 매핑 문서를 두고, Figma MCP를 호출할 때 컨텍스트에 함께 실어 줍니다.
| Figma 인스턴스명 | 코드 컴포넌트 | Import 경로 |
|---|---|---|
Primary Button, Icon Button | Button | @ui → button |
Dropdown Field, Menu Item | Dropdown | @ui → dropdown |
Data Table | Table | @ui → table |
variant가 여러 갈래로 갈리는 컴포넌트는 한 단계를 더 둡니다. 예를 들어 Figma에서는 SelectControl 하나가 Type 속성으로 체크박스·라디오·토글을 모두 표현하지만, 코드에서는 이들이 서로 다른 컴포넌트입니다. 이 대응까지 문서에 적어 둡니다.
| Figma Variant | 코드 컴포넌트 |
|---|---|
Type=Checkbox | Checkbox |
Type=Radio | RadioGroup, RadioItem |
Type=Toggle | Toggle |
결국 훅과 매핑 문서는 서로 다른 일을 합니다. 훅은 출력에 컴포넌트와 variant를 되살려 주고, 매핑 문서는 그 이름을 실제 import 경로로 이어 줍니다. 이 둘이 맞물릴 때 AI가 올바른 컴포넌트를 골라 제자리에 가져다 씁니다.
결과와 남은 한계
이 파이프라인을 붙이고 나서, 대부분의 요소가 공통 컴포넌트로 짜인 페이지에서 결과가 달라졌습니다. 생성된 UI는 새 컴포넌트를 만드는 대신 기존 공통 컴포넌트를 가져다 썼고, 넘긴 props도 디자인 시스템 정의와 맞아떨어졌습니다.
인라인 함수와 반복 구조가 사라지면서 응답 토큰도 줄었고, 출력이 컨텍스트를 덜 차지한 만큼 모델이 디자인을 옮기는 데 쓸 자리가 늘어 결과 UI도 원래 화면에 더 가까워졌습니다.
다만 완전하지는 않습니다. 인스턴스에서 직접 스타일을 덮어쓴 부분은 여전히 정확히 따라가지 못합니다. 훅은 각 인스턴스의 override 값을 API로 들고 오면서도, 정작 UI는 메인 컴포넌트의 기본값을 기준으로 채우는 경우가 남습니다. 이 지점은 override를 어떻게 프롬프트로 풀어 줄지의 문제로, 아직 다듬고 있습니다.
마무리
핵심은 순서였습니다. 먼저 Figma API로 원본 정보를 확보하고 그것을 기준점으로 삼았기 때문에, 뒤따르는 압축이 정보를 잃지 않는 무손실 압축이 될 수 있었습니다. API로 속성을 되돌리는 단계를 건너뛰고 출력부터 줄였다면, 토큰은 줄었겠지만 컴포넌트와 props를 함께 버리는 손실 압축이 됐을 것입니다.
이 훅은 결국 도구와 모델 사이에 얇은 보정 층을 하나 끼운 것입니다. Figma MCP가 프로젝트의 컴포넌트 맥락까지 담아 준다면 필요 없었겠지만, 그렇지 못한 지금은 부족한 정보를 별도 경로로 채워 주는 편이 도구를 갈아타는 것보다 빨랐습니다. 남은 override 문제도 같은 자리에서 이어 풀어 볼 생각입니다.