디자인시스템에 MCP 서버를 달았다 — 조회에서 검증, 피드백 루프까지
AI 에이전트가 디자인시스템 컴포넌트의 prop을 지어내는 문제를 MCP 서버로 풀었다. 조회 도구 6개로 시작한 서버가 왜 지금은 도구 12개 + 자동 주입 instructions + 피드백 루프가 됐는지, 그 사이에 만난 네 개의 벽과 설계 변천의 이유를 기록한다.
MCP가 뭔데?
본론 전에 MCP를 짧게 정리하자. MCP(Model Context Protocol) 는 Anthropic이 공개한 표준 프로토콜로, LLM 기반 도구(Claude Code, Cursor 등)가 외부 시스템과 대화하는 방법을 규격화한 것이다. 흔히 "AI계의 USB-C"라고 부른다 — 도구(호스트)마다 제각각이던 연동 방식을 하나의 포트로 통일한다는 의미다.
구조는 단순하다. 호스트(Claude Code 같은 AI 도구)가 MCP 서버(내가 만드는 것)에 연결하면, 서버는 자신이 할 수 있는 일을 선언하고 모델이 그것을 호출한다. 서버가 노출할 수 있는 채널은 크게 네 가지고, 이 글 전체가 사실 "네 채널을 각각 언제 써야 하는가"를 몸으로 배운 이야기 다.
| 채널 | 발동 주체 | 성격 |
|---|---|---|
| Tools | 모델이 자율 호출 | 함수. "컴포넌트 목록 줘", "이 코드 검사해줘" |
| Resources | 사용자가 명시적으로 첨부 | 문서. @로 붙이는 레퍼런스 |
| Prompts | 사용자가 슬래시 메뉴에서 선택 | 시작 템플릿. 워크플로 진입점 |
| Instructions | 연결 시 자동 주입 | 항상 컨텍스트에 실리는 규칙 |
전송은 로컬이면 stdio(표준 입출력), 원격이면 HTTP를 쓴다. shak-mcp는 npm으로 설치해 로컬에서 stdio로 붙는 서버고, 사용자 입장에서 "서버를 단다"는 것은 설정 파일에 이 네 줄을 넣는 일이 전부다:
{ "mcpServers": { "shak": { "command": "shak-mcp" } }}이 순간부터 그 프로젝트의 모든 AI 세션은 shak의 사실(컴포넌트 API·토큰·워크플로)에 접근할 수 있다.
왜 만들었나 — 마이그레이션의 진짜 병목
우리 팀은 Panda CSS 기반 디자인시스템 shak을 만들었고, 사내 서비스들이 여기로 갈아타야 했다. 그런데 마이그레이션에는 구조적인 문제가 있다. 어느 서비스팀에게도 마이그레이션은 기능 개발보다 우선순위가 낮다. 급하지 않은 일은 영원히 시작되지 않는다.
"그럼 AI에게 시키자"가 자연스러운 다음 수였는데, 여기서 두 번째 문제를 만났다. 에이전트가 컴포넌트 API를 지어낸다. 실제로 겪은 사례를 나중에 메타데이터 소스 주석에 그대로 박아뒀다:
Loop 1에서 에이전트가(1) `Tab`이 Panel인 걸 모르고 루트로 착각하거나,(2) Modal.Bottom 안 버튼 배치(Bottom.Group)·RadioGroup 조합을 추측했다.원인은 명확하다. AI 도구는 Storybook을 열어볼 수 없고, 학습 데이터에 우리 사내 패키지가 있을 리 없다. 참조할 사실이 없으면 모델은 그럴듯한 것을 생성한다 — 존재하지 않는 prop, 존재하지 않는 조립 구조를.
정리하면 요구사항은 이렇다. 에이전트가 (1) 정확한 사실을 조회하고 (2) 생성한 결과를 스스로 검증할 수 있는 채널. 이게 MCP 서버를 직접 만든 이유다.
철학 — 손으로 쓰는 스펙 문서는 반드시 어긋난다
서버를 만들기 전에 정한 원칙이 하나 있다. MCP가 제공하는 데이터는 사람이 쓰지 않는다. 전부 소스에서 빌드 타임에 추출한다.
디자인시스템 문서의 고질병은 드리프트다. 컴포넌트에 prop이 추가됐는데 문서는 그대로인 상황 — 사람이 쓰는 문서는 소스와 별개의 사본이라 반드시 어긋난다. 그 어긋난 문서를 에이전트에게 먹이면 할루시네이션을 막으려던 도구가 할루시네이션의 출처가 된다.
그래서 추출 파이프라인을 세웠다. react-docgen-typescript로 Props를 전개하고, 배럴 파일(src/index.ts)로 공개 표면을 확정하고, docgen이 못 읽는 부분은 TS Compiler API로 AST를 직접 파싱해 갭을 메운다. 컴포넌트 설명은 스토리 파일의 description을 단일 출처로 끌어온다. 이렇게 만든 components.json·tokens.json이 MCP 도구의 응답이 된다.
react 소스 (@pinkfong-tech/shak/src) │ ┌──────────┬───┼────────┬──────────────┐ ▼ ▼ ▼ ▼ ▼ docgen barrel compound types-ast stories Props 전개 공개표면 이름정규화 AST 갭필 설명 추출 │ │ │ │ │ └──────────┴───────┴────┬───┴───────────┘ ▼ components.json / tokens.json ▼ MCP 도구 응답주의할 점도 배웠다. 이 파이프라인은 정답이 고정된 영역이 아니라 회색지대다. "어떤 prop을 노출할지, 설명을 어디서 끌어올지"는 react 패키지의 컨벤션 약속에 기대는 정규화 작업이라, 컨벤션이 바뀌면 추출 규칙도 함께 흔들린다. 그래서 추출이 잘 됐는지의 진실 판정은 스펙이 아니라 출력물이다 — 생성된 JSON을 직접 열어 컴포넌트 수와 prop 전개를 확인하는 것이 1차 방어선이다.
첫 버전 — 조회 도구 6개면 충분할 줄 알았다
첫 릴리즈의 모양은 소박했다. MCP 패키지 하나에 전부 들어 있었다:
- 조회 도구 6개 —
get_design_system_overview(시작점),list_components,search_components,get_component,get_usage_examples,list_tokens - 추출 스크립트 —
scripts/extract.ts(802줄짜리 단일 파일)가 빌드 때 JSON을 생성해 번들에 인라인
"에이전트가 추측하는 게 문제니까, 조회할 수 있게 해주면 끝"이라는 가설이었다. 도구 이름도 의도적으로 흐름을 짰다 — 개요 → 목록/검색 → 상세 → 사용법 → 토큰. 에이전트가 이 순서로 파고들 것을 기대했다.
절반은 맞았다. 도구를 호출하기만 하면 정확한 답이 나갔다. 문제는 나머지 절반이었다.
첫 번째 벽 — 도구는 있는데 아무도 안 쓴다
만들고 보니 도구가 발견되지 않았다. 에이전트는 shak MCP가 연결돼 있어도 UI 작업을 할 때 도구를 부를 생각을 안 하고 하던 대로 추측했다. 사용자도 마찬가지 — "이 서버로 뭘 할 수 있는데?"에 대한 답이 도구 목록뿐이면, 어떤 순서로 뭘 해야 하는지 아무도 모른다.
그래서 프롬프트 4종과 공통 규칙 문서(common.md)를 추가했다. build_with_shak(신규 제작), migrate_to_shak(마이그레이션) 같은 워크플로별 진입점을 슬래시 메뉴에 노출해서, 사용자가 하나를 고르면 에이전트가 따라야 할 단계 전체가 컨텍스트에 실리게 했다.
이때 배운 것: MCP 서버의 UX는 도구 시그니처가 아니라 "언제 무엇을 하게 되는가"의 설계다. 사람 대상 라이브러리로 치면 API 레퍼런스만 있고 Getting Started가 없는 상태였던 셈이다. 다만 이 해법에는 남은 구멍이 있었고, 한참 뒤에 더 큰 개편으로 이어진다. (뒤에서 계속.)
두 번째 벽 — 데이터가 MCP 안에 갇혀 있다
다음 압력은 예상 못 한 방향에서 왔다. CLI가 필요해졌다.
MCP는 에이전트용 채널이다. 그런데 같은 정보 — "이 컴포넌트의 정확한 props", "이 코드가 shak 규약을 지키는가" — 는 사람도, CI도, 에디터 훅도 필요로 한다. 그때마다 MCP를 띄울 수는 없으니 shak CLI를 만들기 시작했는데, 여기서 구조 문제가 드러났다. 추출 파이프라인이 MCP 패키지 안에 있었다. CLI가 같은 데이터를 쓰려면 (a) MCP에 의존하거나 (b) 추출 로직을 복사해야 한다. (a)는 배보다 배꼽이 크고, (b)는 시한폭탄이다 — 두 사본은 반드시 어긋난다.
그래서 패키지를 셋으로 쪼갰다:
@pinkfong-tech/shak-metadata ← 추출 파이프라인 + 데이터 + 조회/검증 엔진 (정본)@pinkfong-tech/shak-mcp ← metadata를 에이전트 채널(MCP)로 노출@pinkfong-tech/shak-cli ← metadata를 사람/CI 채널(터미널)로 노출지금 MCP 패키지의 데이터 모듈은 통째로 재노출 한 파일이 전부다. 실제 코드가 이렇게 생겼다:
/* * 데이터 로드·조회 헬퍼는 @pinkfong-tech/shak-metadata가 단일 소스다. * MCP 핸들러가 기존 경로를 그대로 쓰도록 여기서는 재노출만 한다. */export { components, tokens, icons, patterns, findComponent, recipeFor, examplesFor, // ...} from '@pinkfong-tech/shak-metadata';이 분리의 효과는 유지보수 편의를 넘어선다. 데이터 정합성 검증이 추출 파이프라인 테스트 하나로 수렴한다. MCP가 주는 답과 CLI가 주는 답이 다를 수 있는 구조 자체가 사라졌기 때문이다. 나중에 eslint 플러그인이 추가됐을 때도 소비자가 하나 늘었을 뿐, 정본은 그대로였다.
802줄 단일 파일이던 추출 스크립트도 이때 전후로 패스별 분리(1파일 1패스, 파일당 ~150줄)를 거쳤다. 어느 패스가 깨졌는지 추적하는 비용이 파이프라인 유지의 병목이었기 때문이다.
세 번째 벽 — 조회했는데도 틀린다: 검증의 3단계
조회 도구가 자리 잡자 다음 층의 문제가 보였다. 정확한 사실을 조회하고도 결과물이 틀린다. 조회는 입력의 품질을 보장할 뿐, 출력의 품질은 별개다. 그래서 검증 채널을 층층이 쌓았다.
1단 — 정적 검증: check_usage. 에이전트가 작성한 코드를 받아 shak 규약 위반(존재하지 않는 prop, 허용값 밖 variant, raw 토큰 직접 사용 등)을 잡는다. 핵심 결정은 검증 엔진을 MCP에 두지 않고 metadata의 checkSource로 두고 CLI shak check와 공유한 것이다. 에이전트가 통과한 코드는 CI에서도 통과한다 — 검증 기준의 정본도 한 곳이다.
2단 — 렌더 검증: preview_page. 정적 검증의 한계는 실제 핸들러 주석이 잘 말해준다:
check_usage(정적)는 "컴파일·prop·구조"만 보장하고 "실제 렌더"는 못 본다 —매 에포크 반복된 렌더 버그(Tab 패널 게이팅·Radio 라벨 위치·ToggleButton 노브)는전부 정적 게이트를 통과했다.컴파일도 되고 prop도 맞는데 화면이 이상한 부류가 끈질기게 남았다. preview_page는 실행 중인 로컬 앱을 헤드리스 브라우저로 띄워 렌더된 ARIA 트리를 돌려준다. 에이전트가 "작성 → 미리보기 → 자기 수정" 루프를 혼자 돌 수 있게 됐다. 설계 디테일 두 가지: playwright는 동적 import + graceful degrade로 하드 의존을 피했고(미설치면 에러 대신 안내 텍스트), 에이전트가 자율 호출하는 도구라 로컬 URL만 허용한다 — localhost.evil.com류 우회까지 정규식으로 막는다. 범용 web-fetch로 오용(SSRF)되는 것을 원천 차단하기 위해서다.
3단 — 시각 동등성: visual_diff. 마이그레이션의 최종 판정은 "픽셀이 같은가"다. 원본 사이트와 교체본을 같은 경로·여러 뷰포트로 풀페이지 캡처해 픽셀 diff 리포트를 만드는 프롬프트+캡처 스크립트 리소스를 추가했다. 코드는 수정하지 않고 차이만 보고한다 — 판정과 수정을 분리해야 판정을 신뢰할 수 있다.
네 번째 벽 — 사람은 프롬프트를 고르지 않는다
첫 번째 벽에서 남겨뒀던 구멍이 여기서 터진다. 프롬프트 방식의 전제는 "사용자가 작업 전에 슬래시 메뉴에서 워크플로를 고른다"인데, 현실의 사용자는 그냥 말한다. "이 페이지 shak으로 바꿔줘." 프롬프트는 선택되지 않았고, 에이전트는 조회 없이 코드부터 만들었다.
답은 채널을 옮기는 것이었다. 프롬프트(수동 발동)에 있던 핵심 규칙을 server instructions(자동 주입)로 승격했다. instructions는 MCP 연결 시 항상 모델 컨텍스트에 실리므로, 사용자가 아무것도 고르지 않아도 에이전트는 "조회 우선 → 토큰 사용 → 검증" 순서를 안다. 실제 instructions 소스의 주석이 이 결정을 요약한다:
Prompt(슬래시 메뉴)는 사용자가 명시적으로 골라야 발동되지만 instructions는 항상 실린다.그래서 "UI 작업이면 사용자가 아무것도 안 골라도 따라야 하는" 핵심 순서를 여기 압축한다.매 세션 컨텍스트를 점유하므로 짧게 유지하고, 상세는 리소스·프롬프트로 위임한다.트레이드오프에 주목할 것 — instructions는 매 세션 토큰을 점유한다. 그래서 전문을 넣는 대신 순서 요약만 넣고, 상세 워크플로는 get_workflow라는 도구로 뺐다. 에이전트가 마이그레이션 요청을 감지하면 get_workflow(kind: migrate)를 호출해 그때 상세 절차를 받아간다. 같은 본문이 프롬프트로도 남아 있어 수동 진입도 가능하다. 자동 채널(instructions→도구)과 수동 채널(슬래시 메뉴→프롬프트)이 같은 정본을 읽는 구조다.
이 시기에 채널 정리를 한 번 더 했다. 초기에 만들었던 컴포넌트별 resource 노출은 들어냈다 — 조회는 전부 도구가 하므로 중복이었다. 지금 서버 조립 코드의 주석이 최종 원칙이다:
컴포넌트 조회·검색은 전부 도구가 한다(모델 자율 호출).Resource는 "사용자가 @로 첨부하는 레퍼런스 문서"에만 쓴다.Instructions는 사용자 선택 없이 항상 컨텍스트에 실린다 —"말만 해도 shak 규약대로" 동작하게 하는 자동 트리거 지점.MCP의 네 채널을 "무 엇이 가능한가"가 아니라 "발동 주체가 누구인가" 로 나눠 쓰게 된 것 — 모델이 스스로 판단할 일은 tools, 사람이 의도를 밝힐 일은 prompts, 사람이 자료를 줄 일은 resources, 누구도 챙기지 않아도 지켜져야 할 일은 instructions. 이게 반 년 운영이 준 채널 사용법이다.
기계가 못 주는 것 — 사람이 큐레이션한 조립 레시피
자동 추출 원칙에는 한 가지 의도적 예외가 있다. docgen은 개별 컴포넌트의 props는 완벽하게 주지만, "어느 part가 컨테이너고 어떤 순서로 중첩되는가"는 타입 어디에도 없다. 서두의 실패 사례(Tab을 루트로 착각, Modal.Bottom 배치 추측)가 정확히 이 빈칸에서 나왔다.
그래서 compound 컴포넌트의 조립 구조만은 사람이 쓴 레시피(structure 한 줄 힌트 + example 실제 tsx + notes 함정 목록)로 채우고, 이것도 metadata에 둬서 MCP와 CLI가 같이 읽는다. 원칙은 "자동 추출"이 아니라 "정본 한 곳" 이었다는 걸 여기서 다시 확인했다 — 자동이냐 수동이냐는 수단이고, 사본이 생기지 않는 것이 목적이다.