오늘도 공부
Pencil 앱이 Claude Code와 실시간 통신하는 방법 본문
Pencil 앱의 아키텍처를 분석하고, 동일한 방식으로 Claude Code와 실시간 통신하는 웹 애플리케이션을 구현하는 방법을 알아봅니다.
다운로드
Pencil – Design on canvas. Land in code.
Pencil fundamentally increases your engineering speed by bringing designing directly into your preferred IDE.
www.pencil.dev
배경: Pencil 앱이란?
Pencil은 macOS용 디자인 도구로, 로컬에 설치된 Claude Code와 실시간으로 통신하여 AI 기반 디자인 작업을 수행합니다. 사용자가 채팅으로 요청하면 Claude가 디자인 파일을 직접 수정하고, 그 과정이 UI에 실시간으로 표시됩니다.
Pencil의 UI를 보면:
- Thinking... 상태 표시
- 체크리스트 형태의 작업 진행 상황
- 실시간 디자인 캔버스 업데이트
이 모든 것이 로컬 Claude Code CLI와의 통신을 통해 이루어집니다.

핵심 기술: MCP (Model Context Protocol)

MCP란?
MCP(Model Context Protocol)는 Anthropic이 만든 AI 모델과 외부 도구 간의 통신 프로토콜입니다. Claude Code는 MCP 클라이언트로서 동작하며, Pencil 같은 앱은 MCP 서버를 제공합니다.
MCP의 핵심 개념
- 서버 (Server): 도구(tools)와 리소스(resources)를 제공
- 클라이언트 (Client): 서버가 제공하는 도구를 호출 (Claude Code)
- 전송 (Transport): stdio, SSE, WebSocket 등
Claude Code에서 MCP 서버 등록
프로젝트 루트에 .mcp.json 파일을 생성하면 Claude Code가 자동으로 인식합니다:
{
"mcpServers": {
"my-app": {
"command": "node",
"args": ["mcp-server/dist/index.js"],
"cwd": "/path/to/project"
}
}
}
Pencil의 아키텍처 분석
Pencil 앱의 MCP 도구들을 확인해보면:
// Pencil이 제공하는 MCP 도구 예시
const tools = [
'batch_design', // 디자인 요소 삽입/수정/삭제
'batch_get', // 노드 검색 및 조회
'get_editor_state', // 현재 에디터 상태 확인
'get_screenshot', // 디자인 스크린샷 생성
'get_guidelines', // 디자인 가이드라인 조회
// ... 더 많은 도구들
];
Pencil의 통신 흐름
┌─────────────────────────────────────────────────────────────────┐
│ Pencil 데스크톱 앱 │
│ │
│ ┌───────────────────┐ ┌───────────────────────────────┐ │
│ │ WebSocket Server │◄─────│ MCP Server │ │
│ │ (port 52323) │ │ - 도구 정의 (tools) │ │
│ │ │ │ - 요청 핸들러 │ │
│ └─────────┬─────────┘ └───────────────┬───────────────┘ │
│ │ │ │
│ ▼ │ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ React UI │ │
│ │ - TaskList (실시간 작업 상태) │ │
│ │ - 디자인 캔버스 │ │
│ │ - 채팅 인터페이스 │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
▲
│ MCP Protocol (stdio)
▼
┌─────────────────────┐
│ Claude Code │
│ (로컬 CLI) │
│ │
│ ~/.mcp.json 또는 │
│ 프로젝트/.mcp.json │
└─────────────────────┘
핵심 포인트
- MCP 서버: Claude Code가 호출할 수 있는 도구 제공
- WebSocket 서버: UI에 실시간 상태 업데이트 전달
- 상태 동기화: MCP 도구 호출 → 상태 변경 → WebSocket 브로드캐스트 → UI 업데이트
직접 구현하기
이제 Pencil과 동일한 방식으로 Claude Code와 통신하는 웹 앱을 구현해봅시다.
1. 프로젝트 구조
my-claude-app/
├── mcp-server/
│ ├── index.ts # MCP 서버 + WebSocket 서버
│ ├── tsconfig.json
│ └── dist/ # 빌드 결과물
├── src/
│ ├── app/
│ │ └── page.tsx # 메인 페이지
│ ├── components/
│ │ └── chat/
│ │ ├── ChatPanel.tsx
│ │ └── TaskList.tsx
│ └── hooks/
│ └── useTaskState.ts # WebSocket 클라이언트
├── .mcp.json # Claude Code MCP 설정
└── package.json
2. MCP 서버 구현
// mcp-server/index.ts
#!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { WebSocketServer, WebSocket } from 'ws';
// ============================================
// WebSocket 서버 (UI 실시간 업데이트용)
// ============================================
const WS_PORT = 4451;
let wss: WebSocketServer | null = null;
const connectedClients: Set<WebSocket> = new Set();
// 현재 작업 상태
interface TaskState {
tasks: Array<{
id: string;
description: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
}>;
currentMessage: string;
isThinking: boolean;
}
let taskState: TaskState = {
tasks: [],
currentMessage: '',
isThinking: false,
};
// 모든 연결된 클라이언트에 상태 브로드캐스트
function broadcastState() {
const message = JSON.stringify({ type: 'state_update', data: taskState });
connectedClients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
// WebSocket 서버 시작
function startWebSocketServer() {
if (wss) return;
wss = new WebSocketServer({ port: WS_PORT });
wss.on('connection', (ws: WebSocket) => {
connectedClients.add(ws);
// 연결 시 현재 상태 전송
ws.send(JSON.stringify({ type: 'state_update', data: taskState }));
ws.on('close', () => {
connectedClients.delete(ws);
});
});
console.error(`WebSocket server started on port ${WS_PORT}`);
}
// ============================================
// MCP 서버 (Claude Code 연결용)
// ============================================
const server = new McpServer({
name: 'web-preview-agent',
version: '1.0.0',
});
// 도구 1: 새 작업 시작
server.tool(
'start_task',
'Start a new task and display it in the UI',
{
description: z.string().describe('Description of the task'),
},
async ({ description }) => {
const taskId = `task_${Date.now()}`;
const task = {
id: taskId,
description,
status: 'pending' as const,
};
taskState.tasks.push(task);
broadcastState(); // UI에 실시간 반영
return {
content: [{ type: 'text' as const, text: `Task created: ${taskId}` }],
};
}
);
// 도구 2: 작업 상태 업데이트
server.tool(
'update_task',
'Update the status of a task',
{
taskId: z.string().describe('ID of the task'),
status: z.enum(['pending', 'in_progress', 'completed', 'failed']),
},
async ({ taskId, status }) => {
const task = taskState.tasks.find((t) => t.id === taskId);
if (task) {
task.status = status;
broadcastState(); // UI에 실시간 반영
return {
content: [{ type: 'text' as const, text: `Task updated: ${status}` }],
};
}
return {
content: [{ type: 'text' as const, text: `Task not found` }],
isError: true,
};
}
);
// 도구 3: Thinking 상태 설정
server.tool(
'set_thinking',
'Set the thinking status (shows "Thinking..." in UI)',
{
isThinking: z.boolean(),
message: z.string().optional(),
},
async ({ isThinking, message }) => {
taskState.isThinking = isThinking;
taskState.currentMessage = message || '';
broadcastState(); // UI에 실시간 반영
return {
content: [{ type: 'text' as const, text: `Thinking: ${isThinking}` }],
};
}
);
// 도구 4: 모든 작업 초기화
server.tool(
'clear_tasks',
'Clear all tasks from the UI',
{},
async () => {
taskState.tasks = [];
taskState.currentMessage = '';
taskState.isThinking = false;
broadcastState();
return {
content: [{ type: 'text' as const, text: 'All tasks cleared' }],
};
}
);
// 서버 시작
async function main() {
startWebSocketServer();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('MCP server started');
}
main().catch(console.error);
3. WebSocket 클라이언트 훅
// src/hooks/useTaskState.ts
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
const WS_URL = 'ws://localhost:4451';
export interface Task {
id: string;
description: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
}
export interface TaskState {
tasks: Task[];
currentMessage: string;
isThinking: boolean;
}
export function useTaskState() {
const [taskState, setTaskState] = useState<TaskState>({
tasks: [],
currentMessage: '',
isThinking: false,
});
const [isConnected, setIsConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
const ws = new WebSocket(WS_URL);
ws.onopen = () => {
setIsConnected(true);
console.log('WebSocket connected');
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'state_update') {
setTaskState(message.data);
}
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
ws.onclose = () => {
setIsConnected(false);
console.log('WebSocket disconnected');
// 재연결 시도
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, 3000);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
wsRef.current = ws;
}, []);
useEffect(() => {
connect();
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
}
};
}, [connect]);
return {
...taskState,
isConnected,
};
}
4. TaskList UI 컴포넌트
// src/components/chat/TaskList.tsx
'use client';
import { Task } from '@/hooks/useTaskState';
interface TaskListProps {
tasks: Task[];
isThinking: boolean;
currentMessage: string;
isConnected: boolean;
}
function getStatusIcon(status: Task['status']) {
switch (status) {
case 'pending':
return '○';
case 'in_progress':
return '◐';
case 'completed':
return '●';
case 'failed':
return '✕';
}
}
function getStatusColor(status: Task['status']) {
switch (status) {
case 'pending':
return 'text-gray-400';
case 'in_progress':
return 'text-blue-400 animate-pulse';
case 'completed':
return 'text-green-400';
case 'failed':
return 'text-red-400';
}
}
export function TaskList({ tasks, isThinking, currentMessage, isConnected }: TaskListProps) {
if (!isConnected) {
return (
<div className="p-3 bg-gray-800/50 rounded-lg border border-gray-700">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span className="w-2 h-2 bg-red-500 rounded-full"></span>
MCP 서버 연결 대기 중...
</div>
</div>
);
}
if (tasks.length === 0 && !isThinking) {
return null;
}
return (
<div className="p-3 bg-gray-800/50 rounded-lg border border-gray-700 space-y-2">
{/* 연결 상태 */}
<div className="flex items-center gap-2 text-xs text-gray-500 mb-2">
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
Claude Code 연결됨
</div>
{/* Thinking 상태 */}
{isThinking && (
<div className="flex items-center gap-2 text-sm text-blue-300">
<span className="animate-spin">◐</span>
<span>{currentMessage || 'Thinking...'}</span>
</div>
)}
{/* 태스크 목록 */}
{tasks.length > 0 && (
<ul className="space-y-1">
{tasks.map((task) => (
<li key={task.id} className="flex items-center gap-2 text-sm">
<span className={getStatusColor(task.status)}>
{getStatusIcon(task.status)}
</span>
<span className={
task.status === 'completed'
? 'text-gray-400 line-through'
: 'text-gray-200'
}>
{task.description}
</span>
</li>
))}
</ul>
)}
</div>
);
}
5. MCP 설정 파일
// .mcp.json
{
"mcpServers": {
"web-preview-agent": {
"command": "node",
"args": ["mcp-server/dist/index.js"],
"cwd": "/path/to/your/project"
}
}
}
6. package.json 스크립트
{
"scripts": {
"dev": "next dev --port 4450",
"mcp:build": "tsc -p mcp-server/tsconfig.json",
"mcp:start": "node mcp-server/dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.3",
"ws": "^8.19.0",
"zod": "^4.3.6"
}
}
실행 및 테스트
1. MCP 서버 빌드 및 실행
# MCP 서버 빌드
pnpm mcp:build
# MCP 서버 시작 (별도 터미널)
pnpm mcp:start
2. Next.js 앱 실행
pnpm dev
3. Claude Code에서 테스트
프로젝트 폴더에서 Claude Code를 실행하면 .mcp.json을 자동으로 인식합니다.
cd /path/to/your/project
claude
Claude Code에서 MCP 도구를 사용하면 웹 UI에 실시간으로 반영됩니다:
Claude: 파일 구조를 분석하겠습니다.
[Claude가 내부적으로 start_task 도구 호출]
→ UI에 "파일 구조 분석" 작업 표시
[Claude가 update_task 도구 호출 (completed)]
→ UI에 체크 표시
결론
Pencil의 핵심 아키텍처
- MCP 서버: Claude Code가 호출할 수 있는 도구 정의
- WebSocket: UI 실시간 업데이트를 위한 양방향 통신
- 상태 동기화: MCP 호출 → 상태 변경 → WebSocket 브로드캐스트
이 방식의 장점
장점설명
| 안정성 | CLI spawn 방식보다 훨씬 안정적 |
| 실시간성 | WebSocket으로 즉각적인 UI 업데이트 |
| 확장성 | 새로운 도구를 쉽게 추가 가능 |
| 분리 | MCP 서버와 UI가 독립적으로 동작 |
주의사항
- MCP 서버는 stdio 전송을 사용하므로, Claude Code가 프로세스를 직접 실행합니다
- WebSocket 서버는 별도로 실행되어야 하며, MCP 서버 내에서 함께 시작됩니다
- Claude Code의 .mcp.json 설정이 올바르게 되어 있어야 합니다
참고 자료
'AI > Claude code' 카테고리의 다른 글
| Claude Code 오케스트라 패턴 실습 #3 (0) | 2026.01.16 |
|---|---|
| Claude Code 서브 에이전트 실습 튜토리얼 #2 (0) | 2026.01.16 |
| Claude Code 서브 에이전트를 활용하는 방법 #1 (0) | 2026.01.16 |
| Ralph Wiggum Loop: AI 코딩의 새로운 패러다임 (0) | 2026.01.13 |
| 🤖 Ralph for Claude Code: AI가 알아서 개발해주는 자율 개발 루프 도구 (0) | 2026.01.09 |
