1. 전체 그림: 서버를 통한 도구 호출 경로
코드를 작성하기 전에 아키텍처를 정리해 둡니다. 이렇게 하면 디테일에 빠지지 않을 수 있습니다.
Apps SDK + MCP 용어로 보면 다음과 같습니다: 우리에겐 MCP 서버(이 강의에서는 Next.js의 Route Handler app/mcp/route.ts)가 있고, 이 서버가 도구와 리소스를 등록하고 각 도구의 핸들러를 구현합니다.
상위 수준 개요:
sequenceDiagram
participant User as 사용자
participant Chat as ChatGPT (모델)
participant App as ChatGPT App
participant MCP as MCP 서버 / 백엔드
participant DB as 카탈로그/외부 API
User->>Chat: "선물을 추천해줘..."
Chat->>App: tool `suggest_gifts`를 호출하기로 결정
App->>MCP: JSON-RPC call_tool (이름 + 인자)
MCP->>MCP: 검증, 인가
MCP->>DB: 카탈로그 요청/필터링
DB-->>MCP: 후보 목록
MCP-->>App: structuredContent + content + _meta
App-->>Chat: 모델과 위젯에 결과 전달
Chat-->>User: 선택 이유 설명, 위젯 표시
핵심: 서버는 모델의 “마법”을 모릅니다. 서버는 도구 이름 + 인자를 갖춘 일반 요청만 보고, 구조화된 응답을 반환해야 합니다. 반면 모델은 여러분의 코드를 전혀 보지 못하고 다음만 봅니다:
- 어떤 도구가 있고 그 스키마가 무엇인지;
- 모델이 스스로 구성한 인자;
- 여러분이 반환한 JSON 응답.
따라서 이 강의의 목표는 중간 층을 깔끔하게 구현하는 것입니다 — MCP 서버와 tool 핸들러들.
Insight: mcp-tools limit
MCP 서버에서 도구의 개수는 메모리나 컨텍스트 토큰처럼 제한되는 지표입니다. 형식적으로 수십, 수백 개의 tools를 등록할 수 있지만, 플랫폼과 모델은 이를 선형적으로 다루지 않습니다. 새 도구가 늘어날수록 라우팅의 “노이즈”가 증가합니다.
실무 기준은 대략 이렇습니다:
- 하드 상한 (ChatGPT) ≈ 서버당 최대 128 MCP-tools;
- 실무 권장 범위 — 최대 50개 도구. 그 이상부터는 품질이 눈에 띄게 떨어집니다. 설명이 비슷한 tools를 혼동하거나, 드문 도구는 덜 떠올리고, 잘못된 도구를 선택하는 빈도가 늘어납니다.
Anthropic도 비슷합니다: 최대 약 100개 도구까지 가능하나, 그들 역시 약 50개 이하를 권장합니다.
2. Next.js + Apps SDK 템플릿에서 서버 로직 위치
모듈 2에서 이미 ChatGPT App 공식 Next.js 템플릿을 배포하고 구조를 훑어봤습니다. 이제 그 안에서 MCP 서버가 어디 있으며, 위젯과 어떻게 연결되는지 살펴보겠습니다.
이 템플릿을 사용한다면, MCP 서버는 보통 app/mcp/route.ts 파일(App Router)에 구현됩니다. 여기에 ChatGPT의 JSON-RPC 호출 tools/call, resources/list, handshake 등이 도착합니다.
전형적인 프로젝트 구조:
my-chatgpt-app/
├─ app/
│ ├─ mcp/
│ │ └─ route.ts # MCP 서버 + 도구 등록
│ ├─ page.tsx # React 위젯 (UI)
│ ├─ layout.tsx # Root layout, Bootstrap SDK
│ └─ globals.css # 전역 스타일
│
├─ proxy.ts # CORS 등
├─ next.config.ts
├─ package.json
├─ tsconfig.json
└─ .env
route.ts에서 우리는 다음을 수행합니다:
- @modelcontextprotocol/sdk를 통해 MCP 서버 인스턴스를 생성;
- 도구 등록(server.registerTool(...));
- ChatGPT에서 요청을 받아 MCP 서버로 전달하는 HTTP 핸들러를 정의.
이 구조를 바탕으로 TypeScript로 코드를 작성해 봅니다.
3. 최소한의 MCP 서버와 도구 핸들러
가장 간단한 것부터 시작합시다: 서버를 만들고 학습용 도구 suggest_gifts를 추가해 더미를 반환하게 합니다.
MCP SDK가 이미 설치되어 있다고 가정합니다:
pnpm add @modelcontextprotocol/sdk
간단한 app/mcp/route.ts를 작성합니다:
// app/mcp/route.ts
import { NextRequest } from "next/server";
import { McpServer } from "@modelcontextprotocol/sdk/server";
const server = new McpServer({ name: "giftgenius-mcp" });
// 최소 스키마로 도구 등록
server.registerTool(
"suggest_gifts",
{
title: "선물 추천",
description: "관심사와 예산에 따라 선물을 추천합니다.",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "수신자에 대한 간단한 설명." },
},
required: ["query"],
},
},
async ({ input }) => {
// 여기에서 비즈니스 로직이 동작합니다
return {
content: [
{
type: "text",
text: `플레이스홀더: "${input.query}"를 위한 선물.`,
},
],
structuredContent: {},
};
}
);
// Next.js HTTP 핸들러
export async function POST(req: NextRequest) {
const body = await req.text(); // JSON-RPC 문자열
const response = await server.handle(body);
return new Response(response, {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
이것만으로도 동작합니다: ChatGPT가 suggest_gifts를 호출할 수 있고, 서버는 텍스트 더미를 반환합니다.
중요한 점은 server.registerTool이 다음을 받는다는 것입니다:
- 도구 이름;
- 메타데이터와 입력 JSON Schema;
- 핸들러 — input 인자가 전달되는 비동기 함수.
하지만 아직 검증도, 제대로 된 구조화 출력도, 인가도 없습니다. 이제부터 이를 보강합니다.
4. 입력 데이터 검증과 계층 분리
JSON 스키마만으론 부족한 이유
네, 플랫폼이 스키마에 따라 기본적인 것(필드 타입, 필수 속성 등)은 검증합니다. 하지만:
- 모델이 논리적으로 잘못된 데이터를 전달할 수 있습니다(예: 예산 −100, 관심사 목록 1000개 등);
- 비즈니스 제약(최대 예산, 지원 통화 등)이 있습니다;
- 가끔 ChatGPT나 다른 클라이언트가 아주 예상 밖의 것을 보내기도 합니다.
따라서 핸들러 내부에서 추가 논리 검증이 필요합니다.
코드 분리: handler ↔ 비즈니스 로직
서버 코드를 난장판으로 만들지 않으려면 비즈니스 로직을 분리해 두는 것이 좋습니다. 예를 들어 app/mcp/gifts.ts를 만듭니다:
// app/mcp/gifts.ts
export type SuggestGiftsInput = {
age?: number | null;
relationship: "friend" | "partner" | "colleague";
maxBudget: number;
interests: string[];
};
export type GiftItem = {
id: string;
title: string;
price: number;
currency: "USD";
score: number;
tags: string[];
shortDescription: string;
};
// 간단한 '선물' 카탈로그
const CATALOG: GiftItem[] = [
{
id: "board-game-1",
title: "보드게임 '우주 전략'",
price: 39,
currency: "USD",
score: 0.93,
tags: ["board_games", "strategy", "2-4_players"],
shortDescription: "보드게임을 좋아하는 사람에게 훌륭한 선물입니다.",
},
// ...
];
export function suggestGifts(input: SuggestGiftsInput): GiftItem[] {
if (input.maxBudget <= 0) {
throw new Error("예산은 양수여야 합니다.");
}
const filtered = CATALOG.filter(
(item) => item.price <= input.maxBudget
);
// 단순화: score로 정렬해 상위 3개만 선택
return filtered.sort((a, b) => b.score - a.score).slice(0, 3);
}
이제 MCP 도구 핸들러에서는 다음을 수행합니다:
- input 파싱;
- SuggestGiftsInput 타입으로 매핑;
- suggestGifts를 안전하게 호출;
- 결과를 ChatGPT와 우리 UI가 이해하는 형식으로 포장.
5. 핸들러 구현: input에서 structuredContent까지
route.ts의 registerTool을 비즈니스 로직을 사용하도록 고쳐봅니다:
// app/mcp/route.ts (발췌)
import { suggestGifts, SuggestGiftsInput } from "./gifts";
server.registerTool(
"suggest_gifts",
{
title: "선물 추천",
description:
"관심사, 예산, 관계 유형에 따라 선물을 골라야 할 때 사용하세요.",
inputSchema: {
type: "object",
properties: {
age: {
type: "integer",
minimum: 0,
maximum: 120,
description: "알고 있다면 수신자의 나이.",
},
relationship: {
type: "string",
enum: ["friend", "partner", "colleague"],
description: "수신자와의 관계 유형.",
},
maxBudget: {
type: "number",
minimum: 1,
description: "최대 예산(USD).",
},
interests: {
type: "array",
items: { type: "string" },
description: "수신자의 관심사(예: board games, hiking).",
},
},
required: ["relationship", "maxBudget", "interests"],
},
},
async ({ input }) => {
// 기본 논리 검증
if (!Array.isArray(input.interests) || input.interests.length === 0) {
return {
isError: true,
content: [
{
type: "text",
text: "관심사를 최소 하나 이상 지정해야 합니다.",
},
],
structuredContent: { errorCode: "NO_INTERESTS" },
};
}
const payload: SuggestGiftsInput = {
age: input.age ?? null,
relationship: input.relationship,
maxBudget: input.maxBudget,
interests: input.interests,
};
const items = suggestGifts(payload);
if (items.length === 0) {
return {
content: [
{
type: "text",
text:
"지정한 예산에서 적합한 선물을 찾지 못했습니다. 예산을 늘리거나 관심사를 변경해 보세요.",
},
],
structuredContent: {
items: [],
emptyReason: "NO_MATCHES",
},
};
}
return {
content: [
{
type: "text",
text: `적합한 선물 ${items.length}개를 찾았습니다.`,
},
],
structuredContent: {
items: items.map((item) => ({
id: item.id,
title: item.title,
price: item.price,
currency: item.currency,
shortDescription: item.shortDescription,
tags: item.tags,
})),
},
};
}
);
여기엔 중요한 포인트가 몇 가지 있습니다.
첫째, interests가 빈 배열이 아님을 명시적으로 확인합니다. JSON Schema가 형식상 빈 배열을 허용하더라도 우리에게는 의미 없는 요청일 수 있습니다. 무작위 목록을 만들기보다 명확한 오류를 반환하는 편이 낫습니다.
둘째, 두 가지 데이터 집합을 반환합니다:
- content — 모델용. “N개를 찾았다” 수준의 짧은 요약입니다. 모델은 이를 사용해 사용자에게 답합니다.
- structuredContent — 모델과 UI 모두를 위한 구조화된 JSON입니다. 우리 위젯이 카드 형태로 렌더링할 수 있습니다.
자주 보이는 실수는 content에 큰 JSON을 통째로 집어넣는 것입니다. 그러면 토큰을 낭비하고 모델이 혼동할 수 있습니다. content는 짧게 유지하고, 상세 정보는 structuredContent에 넣는 것이 좋습니다.
6. UI 템플릿과 _meta/openai/outputTemplate 추가
Apps SDK 수준에서 서버는 도구 결과를 시각화할 UI 템플릿을 ChatGPT에 알려줄 수 있습니다. 이는 리소스와 _meta["openai/outputTemplate"]를 통해 이뤄집니다. 서버는 mimeType이 "text/html+skybridge"인 HTML 리소스를 등록하고, 도구가 응답에서 이를 참조합니다.
Next.js 템플릿에서는 보통 이를 감싼 편의 레이어가 있지만, 단순화하면 다음과 같습니다:
// MCP 서버 초기화 시 어딘가에서
server.registerResource("ui://widget/gifts.html", {
name: "Gift suggestions widget",
mimeType: "text/html+skybridge",
// 이후: HTML을 제공하는 방법(인라인 템플릿 또는 파일)
});
도구 응답에서는 다음과 같이 사용합니다:
return {
content: [{ type: "text", text: `선물 ${items.length}개를 찾았습니다.` }],
structuredContent: { items: /* ... */ },
_meta: {
"openai/outputTemplate": "ui://widget/gifts.html",
},
};
그러면 ChatGPT는 결과 구조를 이해할 뿐 아니라, 위젯용 HTML/JS를 로드하고, iframe 내부의 우리 React 컴포넌트가 window.openai.toolOutput를 읽어 선물 목록을 렌더링하게 됩니다.
UI 부분은 이 모듈의 다른 강의에서 더 자세히 다룹니다. 여기서는 연결만 짚고 넘어갑니다: 도구 핸들러는 비즈니스 데이터뿐 아니라 결과를 어떤 UI 템플릿에 연결할지도 책임집니다. 우리는 MCP 서버 관점에서 어떤 템플릿을 지정하고 무엇을 structuredContent에 담을지를 본 것입니다.
Insight
ChatGPT 제작자들은 위젯을 JSON을 표시하는 템플릿으로 설계했습니다. 그래서 이름도 outputTemplate입니다. 초기 아이디어는 이렇습니다: ChatGPT가 mcp-tool을 호출하고, mcp-tool은 JSON을 반환하며 때로는 위젯도 함께 반환한다. 위젯이 없으면 ChatGPT가 JSON을 어떻게 보여줄지 자체적으로 결정합니다.
위젯이 지정되면 ChatGPT는 위젯을 표시하고, JSON을 toolOutput으로 위젯에 전달하며, 위젯은 해당 JSON을 렌더링해야 합니다. 위젯은 JSON 표시 템플릿입니다. 그래서 스토어에 앱을 등록하는 단계에서 미리 캐시됩니다.
위젯은 필요에 따라 자유롭게 사용할 수 있습니다. 예를 들어 fetch()를 호출할 수도 있습니다. 다만 ChatGPT 개발자의 초기 의도를 이해하면, 몇 가지 제약과 향후 변경을 더 쉽게 받아들일 수 있습니다.
7. 핸들러에서의 인가와 접근
지금까지는 모든 것이 공개 데이터라고 가정했습니다. 실제로는 일부 도구에 인가가 필요합니다. 예: 사용자 계정, 주문, 결제, 문서 등에 접근.
Apps SDK / MCP 용어로 도구에 securitySchemes를 지정할 수 있고, 핸들러에서 토큰과 컨텍스트를 점검합니다.
가장 단순한 예:
server.registerTool(
"list_user_orders",
{
title: "사용자 주문 목록",
description: "인가된 사용자의 최근 주문을 반환합니다.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
_meta: {
securitySchemes: [{ type: "oauth2", scopes: ["orders.read"] }],
}
},
async ({ auth }) => {
if (!auth?.accessToken) {
return {
isError: true,
content: [
{
type: "text",
text: "주문을 보려면 로그인해야 합니다.",
},
],
_meta: {
// ChatGPT에 OAuth UI 표시 요청
"mcp/www_authenticate": [
'Bearer resource_metadata="https://your-mcp.example.com/.well-known/oauth-protected-resource", error="insufficient_scope", error_description="계속하려면 인증하세요."',
],
},
};
}
// 여기에서 토큰, issuer, audience, scope 등을 점검
const orders = await fetchUserOrders(auth.accessToken);
return {
content: [
{
type: "text",
text: `최근 주문 ${orders.length}건을 찾았습니다.`,
},
],
structuredContent: { orders },
};
}
);
여기서 중요한 점:
- ChatGPT가 여러분의 검사를 “스스로 추측”하지 않습니다. ChatGPT는 토큰과 컨텍스트만 전달할 뿐이며, 여러분이 반드시 정상적인 인가를 구현해야 합니다.
- 특수 필드 _meta["mcp/www_authenticate"]는 플랫폼에 “로그인/토큰 갱신 UI를 보여달라”고 신호를 보냅니다. 이것이 없으면 ChatGPT는 단순 오류로만 인식합니다.
인가의 복잡성은 모듈 10에서 별도로 다룹니다. 지금은 기본 개념만 고정합시다: 핸들러에서 토큰을 검사하고, 모델을 맹신하지 않습니다.
8. 외부 API 및 DB와의 상호작용: 계층과 실무 팁
“핸들러에서 모두 처리하자”는 유혹이 큽니다. 인자 파싱, DB 쿼리, 필터링, structuredContent 매핑, 로깅, 약간의 철학까지 — 150줄짜리 단일 함수로 말이죠. 이는 pages/index.tsx 하나에 모든 앱을 쓰는 것과 비슷합니다. 가능은 하지만 고통스럽습니다.
차라리 계층을 나눕니다:
// gifts-repository.ts
import type { GiftItem } from "./gifts";
export async function fetchGiftsFromApi(
maxBudget: number,
interests: string[]
): Promise<GiftItem[]> {
const resp = await fetch("https://example.com/api/gifts", {
method: "POST",
body: JSON.stringify({ maxBudget, interests }),
headers: { "Content-Type": "application/json" },
});
if (!resp.ok) {
throw new Error(`Gift API error: ${resp.status}`);
}
const data = (await resp.json()) as GiftItem[];
return data;
}
// gifts.ts (업데이트됨)
import { fetchGiftsFromApi } from "./gifts-repository";
export async function suggestGifts(input: SuggestGiftsInput): Promise<GiftItem[]> {
if (input.maxBudget <= 0) {
throw new Error("예산은 양수여야 합니다.");
}
const items = await fetchGiftsFromApi(input.maxBudget, input.interests);
return items.sort((a, b) => b.score - a.score).slice(0, 3);
}
// route.ts (핸들러 발췌)
async ({ input }) => {
try {
const payload: SuggestGiftsInput = {
age: input.age ?? null,
relationship: input.relationship,
maxBudget: input.maxBudget,
interests: input.interests,
};
const items = await suggestGifts(payload);
// ...
} catch (err) {
console.error("suggest_gifts failed", err);
return {
isError: true,
content: [
{
type: "text",
text: "선물 추천 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
},
],
structuredContent: {
errorCode: "INTERNAL_ERROR",
},
};
}
}
이 접근은 다음과 같은 장점이 있습니다.
- 테스트 용이성: MCP 서버를 올리지 않고도 suggestGifts와 fetchGiftsFromApi에 대한 단위 테스트를 작성할 수 있습니다.
- 가독성: 핸들러는 프로토콜(MCP)과 비즈니스 로직 사이의 얇은 어댑터로 남습니다.
- 재사용성: 이후 동일한 선물 추천이 다른 곳(예: 별도 REST API)에서도 필요하면 MCP에서 로직을 “뜯어내지” 않아도 됩니다.
9. 로깅과 기본 가시성
도구의 서버 구현은 최소한의 가시성을 마련하기에 아주 좋은 지점입니다. 프로덕션에서는 다음을 알고 싶어질 겁니다:
- 어떤 도구가 호출되는지;
- 어떤 인자와 함께 호출되는지(물론 PII는 제외);
- 처리에 걸리는 시간은 얼마나 되는지;
- 오류는 얼마나, 어떤 유형으로 발생하는지.
지금은 ChatGPT App의 구조를 파악하는 단계이므로 전문 로거 도입은 미뤄둡니다. 핸들러 주변에 둘 간단한 래퍼 로거는 다음과 같이 만들 수 있습니다:
// simple-logger.ts
export function logToolInvocationStart(tool: string, args: unknown) {
console.log(
JSON.stringify({
level: "info",
event: "tool_invocation_started",
tool,
timestamp: new Date().toISOString(),
// 프로덕션에서는 절대 PII를 로그에 남기지 마세요!
args,
})
);
}
export function logToolInvocationEnd(tool: string, ms: number, success: boolean) {
console.log(
JSON.stringify({
level: "info",
event: "tool_invocation_finished",
tool,
durationMs: ms,
success,
timestamp: new Date().toISOString(),
})
);
}
// route.ts (핸들러 래핑)
import { logToolInvocationStart, logToolInvocationEnd } from "./simple-logger";
server.registerTool(
"suggest_gifts",
{ /* ...meta... */ },
async ({ input }) => {
const startedAt = Date.now();
logToolInvocationStart("suggest_gifts", {
relationship: input.relationship,
maxBudget: input.maxBudget,
interestsCount: Array.isArray(input.interests)
? input.interests.length
: 0,
});
try {
// ... 주요 로직 ...
const duration = Date.now() - startedAt;
logToolInvocationEnd("suggest_gifts", duration, true);
return result;
} catch (err) {
const duration = Date.now() - startedAt;
logToolInvocationEnd("suggest_gifts", duration, false);
throw err;
}
}
);
이후 모듈에서 메트릭, SLO, 모니터링을 다룰 때 이 로그를 기반으로 그래프와 알림을 만들 수 있습니다. 로깅 습관은 지금부터 들여두는 것이 좋습니다.
10. 서버 결과가 위젯으로 들어가고(그리고 되돌아오는) 방식
섹션 6에서 이미 _meta["openai/outputTemplate"]를 통해 도구 결과를 UI 템플릿에 연결했습니다. 이제 반대편에서 같은 경로를 봅니다 — 이 structuredContent가 React 위젯 내부로 어떻게 들어오고, UI에서 무엇을 하는지.
이 강의는 서버에 초점을 맞추고 있지만, 여러분은 “모델을 위한 API”뿐 아니라 “UI를 위한 API”도 함께 설계하고 있음을 이해하는 것이 중요합니다. 서버는 다음을 반환합니다:
- structuredContent — 모델과 위젯(through toolOutput)이 모두 보는 데이터;
- content — 결과에 대한 “요약” 설명(모델용);
- _meta — 위젯 전용 필드: openai/outputTemplate, openai/widgetCSP, openai/widgetDomain 등.
React 위젯 내부에서는 대략 다음과 같이 사용합니다:
// app/page.tsx (발췌)
type ToolOutput = {
items?: {
id: string;
title: string;
price: number;
currency: string;
shortDescription: string;
tags: string[];
}[];
emptyReason?: string;
};
declare global {
interface Window {
openai?: {
toolOutput?: ToolOutput;
};
}
}
export default function GiftWidget() {
const output = typeof window !== "undefined"
? window.openai?.toolOutput
: undefined;
if (!output) {
return <div>선물 추천 결과를 기다리는 중…</div>;
}
if (!output.items || output.items.length === 0) {
return <div>적합한 선물이 없습니다. 조건을 변경해 보세요.</div>;
}
return (
<ul>
{output.items.map((item) => (
<li key={item.id}>
<strong>{item.title}</strong> — {item.price} {item.currency}
</li>
))}
</ul>
);
}
그래서 structuredContent의 계약을 안정적으로 유지하고, UI에 친화적으로 설계하는 것이 매우 중요합니다. 개별 필드로 구성하고, 10단계 중첩 같은 구조는 피하세요.
이 경로는 모듈 4의 별도 강의에서 자세히 다룹니다. 여기서는 서버와 위젯이 같은 structuredContent 구조를 공유한다는 점만 고정합니다.
11. 서버에서의 오류 처리: 형식과 전략
섹션 8–9에서 핸들러 내부의 오류와 로깅을 살짝 다뤘습니다. 이제 이를 하나의 형식으로 모읍니다: 모델과 UI가 모두 활용할 수 있도록 도구 오류를 어떻게 반환할 것인가.
핸들러에서 오류는 피할 수 없습니다. 외부 API가 실패할 수도 있고, 입력이 잘못 들어올 수도 있으며, 여러분이 오타를 낼 수도 있습니다. 중요한 것은 모델과 사용자에게 “설명 없는 500 Internal Server Error”만 보여주지 않는 것입니다.
좋은 서버 구현은 다음을 지킵니다:
- 사용자/모델 입력 검증 오류와 내부 오류를 구분;
- isError와 이해하기 쉬운 errorCode를 structuredContent에 포함;
- content에는 사람 친화적인 메시지를 제공.
예시(도구의 title, description, inputSchema 등의 메타데이터는 중복을 피하기 위해 meta 변수에 뺐다고 가정):
function makeErrorResult(message: string, code: string) {
return {
isError: true,
content: [
{
type: "text",
text: message,
},
],
structuredContent: {
errorCode: code,
},
};
}
server.registerTool(
"suggest_gifts",
meta,
async ({ input }) => {
try {
if (input.maxBudget > 10000) {
return makeErrorResult(
"예산이 너무 큽니다. 요청을 구체화해 주세요(최대 10000 USD).",
"BUDGET_TOO_HIGH"
);
}
const items = await suggestGifts({
age: input.age ?? null,
relationship: input.relationship,
maxBudget: input.maxBudget,
interests: input.interests,
});
if (!items.length) {
return {
content: [
{
type: "text",
text:
"해당 예산에서 선물을 찾지 못했습니다. 관심사를 바꾸거나 예산을 늘려 보세요.",
},
],
structuredContent: {
items: [],
emptyReason: "NO_MATCHES",
},
};
}
return {/* 정상 결과 */};
} catch (err) {
console.error(err);
return makeErrorResult(
"선물 추천 중 서버 내부 오류가 발생했습니다.",
"INTERNAL_ERROR"
);
}
}
);
이 형식은 모델에도(UI에도) 도움이 됩니다. 모델은 인자를 바꿔 재시도해 볼 수 있고, UI는 각 errorCode에 특화된 메시지를 보여줄 수 있습니다.
탄탄함, 멱등성, 안전한 도구 설계는 몇 강의 뒤에서 자세히 다룹니다. 하지만 지금부터도 “이상한 일을 조용히 하지 말고, 명시적으로 오류를 반환하자”는 습관을 들이는 것이 유익합니다.
강의 말미에 서버 구현 시 자주 발생하는 실수를 체크리스트로 모아 요약하겠습니다.
12. 짧은 end‑to‑end 예시: 요청부터 응답까지
지금까지 만든 것을 GiftGenius 앱에서 하나의 흐름으로 묶어 봅니다.
- 사용자가 ChatGPT에 작성합니다:
“친구에게 줄 선물을 골라줘. 보드게임을 좋아하고 예산은 50달러까지야.” - 모델은 suggest_gifts 도구와 그 스키마를 알고 있으므로 이를 호출하기로 결정하고 tool_call을 구성합니다:
{ "tool": "suggest_gifts", "arguments": { "relationship": "friend", "maxBudget": 50, "interests": ["board games"], "age": null } } - 플랫폼은 이 JSON-RPC를 우리 MCP 서버(POST /app/mcp)로 보냅니다. Next.js는 본문을 server.handle(...)에 전달합니다.
- 우리의 suggest_gifts 핸들러는:
- interests가 비어 있지 않은지 검증하고;
- suggestGifts(payload)를 호출하여;
- GiftItem[] 배열(score 기준 상위 3개)을 받고;
- 이를 structuredContent.items에 포장하고 _meta["openai/outputTemplate"] = "ui://widget/gifts.html"을 추가합니다.
- ChatGPT는 응답을 수신하고 structuredContent를 컨텍스트에 넣은 뒤, 위젯 HTML 리소스 gifts.html을 로드하고 toolOutput을 전달합니다.
- 우리 React 위젯은 window.openai.toolOutput.items를 읽어 선물 목록을 렌더링합니다. 모델은 content와 structuredContent를 바탕으로 왜 이 선물들이 적합한지에 대한 설명을 작성해 사용자에게 보여줍니다.
- 사용자가 위젯에서 “더 보기”를 누르면 — 위젯이 SDK를 통해 callTool을 호출 → 다시 우리의 핸들러로 들어오되, 이번에는 다른 인자(예: 예산 증가)로 들어옵니다.
이 모든 흐름은 도구의 서버 구현이 다음을 지키기 때문에 성립합니다:
- 합의된 JSON Schema에 따른 구조화된 input을 받고;
- 데이터를 꼼꼼히 검증하며;
- 분리된 비즈니스 로직을 호출하고;
- 안정적인 구조화 출력을 반환하며;
- 필요 시 UI 템플릿과 메타데이터를 지정합니다.
13. 도구 서버 구현 시 흔한 실수
오류 №1: “모든 것을 한 곳에” — 거대한 handler.
모든 로직과 외부 API 연동이 server.registerTool(..., async () => { ... }) 내부에 들어가면, 코드는 빠르게 비대해지고 읽기 어려운 모놀리스가 됩니다. 작은 변경에도 전체가 한 번에 깨질 수 있습니다. 비즈니스 로직은 별도 함수/모듈로 분리하고, 핸들러는 얇은 어댑터로 두는 편이 좋습니다.
오류 №2: JSON 스키마를 맹신.
“스키마가 있으니 입력은 항상 유효하겠지”라고 생각하기 쉽습니다. 하지만 모델은 이상한 값을 보낼 수 있고, 외부 클라이언트는 더더욱 그렇습니다. 타입과 JSON Schema만 믿을 수는 없습니다 — 논리 검증(예산 범위, 배열 길이, 허용 값 등)이 필요합니다.
오류 №3: 모든 것을 content에 몰아넣고 structuredContent를 무시.
가끔 content에 만일을 대비한다며 거대한 JSON 문자열을 넣습니다. 그러면 모델 프롬프트가 시끄럽고 토큰 소모도 커집니다. UI도 문자열을 디코드해야 해 고통스럽습니다. content는 짧게, 상세는 structuredContent에 담는 것이 훨씬 낫습니다.
오류 №4: 구조화 출력 포맷의 불안정성.
오늘은 items가 id, title, price를 가진 객체 배열인데, 내일 갑자기 price를 amount로 바꾸면 위젯이 깨집니다. 또는 새 중첩 레벨을 추가할 수도 있습니다. 이런 변경은 가능하지만, 계약을 버저닝하거나 작은 단계로 스키마를 진화시켜야 합니다. 그렇지 않으면 UI와 테스트가 계속 깨집니다.
오류 №5: 의미 있는 오류 처리가 없음.
예외를 던지고 “플랫폼이 알아서 처리하겠지”라고 기대하는 것은 좋지 않습니다. 모델은 이해하기 어려운 JSON-RPC 오류만 보고, 사용자는 빨간 배너만 보고, 여러분은 문제의 맥락을 잃습니다. isError, errorCode, 사람에게 친숙한 메시지를 명시적으로 반환하고, 서버에서는 세부를 로깅하는 것이 훨씬 좋습니다.
오류 №6: 인가를 무시하고 모델을 신뢰.
“모델은 똑똑하니, 사용자가 미인가 상태라면 이 도구를 호출하지 않겠지”라고 생각하는 경우가 있습니다. 모델은 여러분의 ACL과 제한을 모릅니다. 도구 설명만 볼 뿐입니다. 권한 검사는 도구 설명과 무관하게 서버 핸들러에서 반드시 수행해야 합니다.
오류 №7: PII를 포함해 마구 로깅.
습관적으로 input 전체를 로깅하기 쉽습니다. ChatGPT App의 경우 이는 이름, 이메일, 주소 같은 PII를 포함할 수 있으며, OpenAI 정책과 상식에 어긋납니다. 관계 유형, 예산 범위, 관심사 개수 같은 집계/비식별 정보만 로깅하는 것이 좋습니다.
오류 №8: 외부 API 작업 시 타임아웃과 재시도 미설정.
핸들러 내부에서 외부 API로 fetch를 호출하면서 타임아웃과 재시도를 설정하지 않으면, 해당 API의 지연이 “ChatGPT가 멈춘 것”처럼 보입니다. 사용자는 앱이 고장 났다고 생각할 겁니다. 서버 측에서 시간 제한을 설정하고, 타임아웃을 처리하며, 의미 있는 오류를 반환해야 합니다.
GO TO FULL VERSION