MCP 위젯 아키텍처, 삽질 끝에 정착하기까지
사내 AI 어시스턴트에 MCP 위젯 UI를 붙이며 Router, iframe, 공통 위젯 패키지 구조로 정착하기까지의 설계 과정을 정리했다.
사내 AI 어시스턴트에 MCP(Model Context Protocol)를 붙이면서 UI 아키텍처를 설계한 경험을 공유하고자 합니다. 결과물 구성도만 보면 꽤 단순해 보이는데, 처음 해보는 구성이다 보니 많은 회의와 논의를 거쳐 구성하게 되었습니다.
두가지 선택이 있었습니다.
- MCP Router, 원래는 안 붙이려고 했습니다. "프론트가 MCP를 직접 부르면 되지 않나?" 했다가 확장성을 고려해 추가하게 되었습니다.(초기 개발단계 이후에 진행하고자 합니다.)
- 공통 위젯을 하나의 모노레포에 다 몰아넣으려고 했습니다. 포트별로 한 번에 띄우는 그림까지 그렸다가, 관리 포인트가 애매해져서 나눴습니다.
그래서 이 글은 "최종 구성도가 이렇습니다" 보다는, 왜 이 모양이 됐는지에 가깝습니다. 삽질기가 궁금하신 분은 아래로 쭉 내려가시고, 결과부터 보고 싶으신 분을 위해 큰 그림부터 깔겠습니다.
큰 그림 — 두 개의 존, 하나의 방화벽
구성은 크게 두 영역으로 나뉩니다.
- 서비스 존 — 우리 팀이 관리하는 영역. 프론트(WEB), AI 모델, 그리고 새로 만든 MCP Router가 여기 존재합니다.
- 사내망 — 다른 팀이 관리하는 영역. 실제 MCP 서버들, 위젯 UI를 서빙하는 Widget 서버, 그리고 EAI/SAP 같은 기존 백엔드가 여기 있습니다.

두 존 사이엔 방화벽이 있고, 여길 넘는 경로는 딱 두 개입니다.
- ① tool 실행 경로: MCP Router → MCP 서버. tool 호출 트래픽은 전부 Router 한 곳으로 모아서 내보냅니다.
- ② 위젯 로드 경로: WEB → Widget 서버(:5137). iframe이 실제 UI를 받아오는 길이에요.
Widget 서버가 사내망에 남아 있어서 방화벽을 넘어야 하는 건, 조직 구조상 어쩔 수 없는 부분이었습니다. 대신 tool 트래픽을 Router로 단일화하니 방화벽 룰이랑 감사 로그가 한결 깔끔해졌어요.
SSE는 "텍스트"가 아니라 "앱 블록"을 내려준다
일반적인 챗봇이면 AI가 SSE로 토큰(텍스트)을 흘려보내죠. 여기서는 한 발 더 나갑니다. AI 모델이 응답 중간에 "앱 블록" 을 내려줄 수 있어요. 대충 이런 느낌입니다.
event: message
data: { "type": "app", "widget": "settlement", "data": { "orderId": "..." } }프론트(WEB)는 SSE 스트림을 읽다가 이 앱 블록을 만나면, 텍스트로 그리는 대신 iframe을 렌더합니다. widget 필드로 어떤 UI를 띄울지 정하고, data는 초기 데이터로 넘겨주고요.
iframe의 src는 WEB의 nginx proxy 경로를 씁니다.
<iframe src="/mcp-widgets/settlement?sessionId=..." />
└─ nginx proxy target: http://widget-server:5137/...프론트가 사내망 주소를 직접 들고 있지 않고 proxy 뒤로 숨기니까, 위젯 서버 주소가 바뀌어도 프론트는 몰라도 됩니다.
iframe과 host의 대화 — postMessage 프로토콜
iframe을 띄웠으면 이제 부모(WEB host)랑 통신을 해야겠죠. 여기는 postMessage로 주고받는데, 메시지 이름에 방향을 박아뒀습니다. app/*는 iframe이 host에게, host/*는 host가 iframe에게 보내는 거예요.
| 메시지 | 방향 | 언제 |
|---|---|---|
app/ready | iframe → host | 위젯이 다 로드됐어요 |
host/init | host → iframe | 초기 데이터 받아라 (SSE의 data) |
app/call-tool | iframe → host | tool 좀 실행해줘 |
host/tool-result | host → iframe | tool 결과 여기 있다 |
흐름으로 풀면 이렇습니다.
- iframe 로드 완료 →
app/ready - host가 초기 데이터 주입 →
host/init - 위젯에서 데이터가 필요해지면 →
app/call-tool - host가 결과 돌려줌 →
host/tool-result
여기서 중요한 설계 원칙 하나. iframe은 절대 백엔드를 직접 부르지 않습니다. tool이 필요하면 무조건 host한테 부탁하고, host가 대신 실행합니다. iframe은 신뢰 경계 바깥에 있는 놈이라, 걔가 아무 tool이나 직접 부르게 두면 안 되거든요. 이 격리가 나중에 권한 설계의 핵심이 됩니다.
tool은 누가 실행하나 — 그리고 여기서 첫 번째 삽질
app/call-tool이 오면 host가 실제로 tool을 실행해야 하는데, 이걸 누가 하느냐가 첫 번째 갈림길이었습니다.
처음 생각은 단순했어요. "프론트(WEB host)가 MCP를 직접 부르면 되잖아?" MCP 서버 하나 띄우고, host가 걔한테 직접 요청하면 끝. Router 같은 중간 계층은 오버엔지니어링 아닌가 싶었죠.
그런데 요구사항을 파다 보니 MCP가 하나가 아니게 되더라고요. 팀마다 자기 도메인 MCP를 붙이고 싶어 했고, MCP는 계속 늘어날 예정이었습니다. 그러면:
- 프론트가 "이 tool은 어느 MCP로 보내야 하지?"를 알아야 하고,
- MCP 하나가 죽으면 프론트가 그 장애를 직접 떠안고,
- 권한 체크를 프론트가 매번 하고, 방화벽도 프론트가 여러 군데로 뚫어야 하고…
이쯤 되니 답이 나왔습니다. 그래서 MCP Router를 뒀어요. 역할은 딱 세 가지 — tool 실행, 권한, 라우팅.

AI가 스스로 tool을 부르는 경로(AI → Router → MCP)와, 위젯이 app/call-tool로 부르는 경로(iframe → WEB host → Router → MCP), 둘 다 Router를 거칩니다. 사내망으로 나가는 출구를 한 곳으로 모으니, 다중 MCP 추상화 · 권한 통제 · 방화벽 단일화가 공짜로 따라왔어요. 결국 "안 붙이려던" Router가 이 구조의 중심이 됐습니다.
두 번째 삽질 — 위젯을 모노레포에 다 넣으려 했다
공통 위젯을 어떻게 관리할지도 한참 헤맸습니다. 처음 그림은 이랬어요. 위젯을 전부 하나의 모노레포에 몰아넣고, 포트별로 한 번에 띄우자. 예전에 OpenAI Apps 만들때 이렇게 진행했습니다. 모노레포 구조로 만들어서 협업하고, 각각 빌드할 수 있게 만들어서 각각 호스팅하는 걸로.
app
├── Common # 공통 위젯
├── A팀 MCP # A팀 위젯
├── B팀 MCP # B팀 위젯
└── C팀 MCP # C팀 위젯한 레포 안에 공통 위젯이랑 각 팀 위젯을 다 넣고, 빌드해서 포트별로 한 번에 올리는 거죠. 관리 지점이 하나라 처음엔 깔끔해 보였습니다.
그런데 이 그림의 치명적인 문제가 뒤늦게 보였어요. 저 레포를 우리 팀이 관리해야 한다는 것.
A팀이 자기 위젯 하나 고치려고 해도 우리 레포에 PR을 넣어야 하고, 배포도 우리 파이프라인을 타야 합니다. 팀이 늘어날수록 우리 팀의 CI/CD 관여율이 폭발하는 구조였어요. 남의 위젯 배포까지 우리가 책임지는 병목이 되는 거죠. 위젯 하나 릴리스하는데 우리 팀 눈치를 봐야 한다니, 이건 아니다 싶었습니다.
그래서 방향을 틀었습니다. 공통 부분만 따로 떼서 공통 레포로 만들고, 각 팀 MCP 개발자가 자기 MCP에 그걸 import 해서 쓰는 형태로요.
[ 우리 팀 ] 공통 위젯 레포 ──▶ npm publish (예: @company/mcp-widgets)
│
▼ (빌드 시 import)
[ A팀 ] A팀 MCP 레포 ──▶ 자기 파이프라인으로 배포
[ B팀 ] B팀 MCP 레포 ──▶ 자기 파이프라인으로 배포이렇게 바꾸니:
- 우리 팀은 공통 위젯의 계약(스키마·컴포넌트)만 책임집니다.
- 각 팀은 자기 MCP를 자기 파이프라인으로 배포합니다. 우리 눈치 안 봐도 됩니다.
- 공통 위젯은 런타임 컴포넌트가 아니라 빌드타임 의존성이 됩니다. 물리적으로 묶이는 게 아니라, 버전으로 느슨하게 연결되는 거죠.
포인트는 버전 호환성이었어요. 공통 위젯이 기대하는 tool 응답 형태(host/init · host/tool-result의 data 스키마)를 패키지 계약으로 고정해두고, 타입 정의까지 같이 배포했습니다. 그러면 각 팀은 버전만 맞춰 올려도 안 깨져요.
정리하며
돌아보면 두 번의 삽질 모두 같은 교훈이었던 것 같습니다. "우리가 다 들고 있으면 편하다"는 착각.
- Router 없이 프론트가 다 부르면 편할 것 같았지만 → MCP가 늘어나는 순간 프론트가 다 떠안는 구조가 됐고,
- 위젯을 한 레포에 다 넣으면 편할 것 같았지만 → 남의 배포까지 우리가 책임지는 병목이 됐습니다.
결국 소유권을 잘게 나누고, 경계는 계약으로 연결하는 방향으로 갈 때 각자 자기 속도로 움직일 수 있더라고요. Router는 tool 트래픽의 경계를, 공통 위젯 패키지는 UI의 경계를 계약으로 만들어준 셈입니다.
이렇게 진행했고, MCP 서버 개발이 아닌 MCP를 붙이는 서비스 개발은 처음이니 이게 맞는지 잘 모르겠습니다. 일단은 이렇게 진행해보고 점차 고도화해나가면 좋겠죠.
이상 여자처자 "MCP 서비스 구성도 만들기 고난기" 였습니다. 읽어주셔서 감사합니다!
프론트엔드 개발자이자 여행·기록·경제에 관심이 많은 사람. 직접 겪은 것만 씁니다.