시리즈 소개: 우리 서비스 데이터 + LLM = 앱 고유 AI 서비스

이 시리즈는 우리 서비스의 DB 데이터와 LLM을 연동하여, 우리 앱에서만 제공할 수 있는 AI 서비스를 만드는 방법을 탐색하는 과정이다.

방법핵심
1편CLI Child ProcessCLI를 자식 프로세스로 실행, 오케스트레이션 위임
2편 (본문)직접 구현LLM API 직접 호출 + 오케스트레이터 자체 구현
3편Agent SDK벤더 공식 SDK로 오케스트레이션 위임 + 공식 콜백으로 제어
4편Agent SDK 내부 선택지Tool 제공 방식(MCP/로컬)과 행동 지침(Skill) 선택 기준

방법 2: 직접 구현 방식

LLM API를 SDK로 직접 호출하고, 오케스트레이션 전체(LLM 호출 루프 → tool 판단 → MCP 라우팅 → 재호출)를 자체 구현하는 아키텍처다. CLI를 사용하지 않는다.

  • LLM Provider Adapter 패턴으로 Anthropic · OpenAI 등 멀티 벤더 지원
  • 우리 서비스 데이터(Todo 등)는 MCP 없이 직접 함수 호출로 처리
  • 사용자가 기존에 사용하던 MCP 서버는 MCP Manager가 설정 파일을 파싱해 stdio로 연결
  • 승인 UI, 히스토리 DB 저장, 과금 추적 등 모든 것을 완전 제어

Provider Adapter 패턴: 벤더마다 다른 LLM API 스펙을 공통 인터페이스로 추상화하는 패턴. Anthropic의 tool_use block과 OpenAI의 tool_calls array처럼 구조가 달라도, Adapter에서 한 번 매핑하면 오케스트레이터는 벤더를 의식하지 않고 동작한다.

방법 2에서 사용하는 패키지

방법 2에서 “API 직접 호출”이란 벤더사의 API Client 라이브러리를 사용한다는 뜻이다. API Client는 HTTP 요청/응답만 처리하는 얇은 래퍼로, 오케스트레이션(LLM 호출 루프, tool 판단, 재호출 등)은 포함하지 않는다. 그래서 방법 2에서는 이 부분을 직접 구현한다.

AnthropicOpenAI
패키지명@anthropic-ai/sdkopenai
역할HTTP 호출 래퍼HTTP 호출 래퍼
호출 방식client.messages.create()client.chat.completions.create()
tool 응답 포맷tool_use block (content 배열)tool_calls array (message 내)

⚠️ @anthropic-ai/sdk@anthropic-ai/claude-agent-sdk완전히 다른 패키지다. 전자는 API Client(방법 2), 후자는 Agent SDK(3편)이다. OpenAI도 마찬가지로 openai(API Client)와 @openai/codex-sdk(Agent SDK)는 별개 패키지다. Agent SDK에 대해서는 3편에서 다룬다.

해당 방법이 필요한 이유

1편(CLI Child Process)의 한계가 이 방법의 등장 이유다:

  • 승인 UI가 비공식 프로토콜에 의존 → CLI stdout 파싱이 깨질 수 있음
  • 히스토리 · 과금 추적 불가 → CLI 내부에 갇혀 있어 프로그래밍 접근 불가
  • LLM 응답 가공 불가 → 중간에 가로채서 필터링/변환할 수 없음
  • 멀티 벤더 지원 시 복잡도 증가 → CLI별로 spawn 명령어, stdout 포맷, 세션 관리가 모두 다름

직접 구현하면 이 모든 것을 공식 API 스펙 기반으로 안정적으로 해결할 수 있다.

AS-IS (방법 1의 한계)

sequenceDiagram
  autonumber
  participant F as FrontEnd
  participant C as CLI
  participant L as LLM API

  C-->>F: stdout JSON (⚠️ 비공식 포맷)
  Note right of F: {"type": "tool_use_request", ...}
  Note right of F: ⚠️ 다음 CLI 버전에서 key가 바뀌면?
  Note right of F: ⚠️ Codex CLI는 포맷이 다르면?
  F->>C: stdin 승인 응답 (⚠️ 비공식)
  Note right of F: 히스토리 접근 불가
  Note right of F: 토큰 사용량 추적 불가

TO-BE (방법 2에서 해결)

sequenceDiagram
  autonumber
  participant F as FrontEnd
  participant O as Orchestrator
  participant P as Provider Adapter
  participant L as LLM API

  P->>L: 벤더사 API 직접 호출
  L-->>P: tool_use block (✅ 공식 API 스펙)
  P-->>O: 공통 포맷으로 변환
  O->>F: IPC 승인 요청 (✅ 완전 제어)
  O->>O: 히스토리 DB 저장 (✅ 직접 관리)
  O->>O: 토큰 usage 추출 (✅ API 응답에 포함)

전체 아키텍처

sequenceDiagram
  autonumber
  box rgb(245,245,245) User
    participant U as User
  end
  box lightcyan FrontEnd 🔴
    participant E as FrontEnd
  end
  box lightgreen Backend 🔴
    participant M as MCP Server (Todo)
    participant D as Local DB
  end
  box lightyellow Agent Server 🔴 전부 직접 구현
    participant O as Orchestrator
    participant P as LLM Provider Adapter
    participant MM as MCP Manager
  end
  box rgb(255,230,230) External
    participant L as LLM API
  end
  Note over U,L: Phase 1: 초기 설정
  U->>E: 앱 실행 + Provider 선택
  E->>O: Provider 설정 요청
  O->>P: 벤더사 API Client 초기화
  P-->>O: 초기화 완료
  O->>O: MCP 설정 파일 파싱
  O->>MM: MCP 서버 기동
  MM->>M: spawn + stdio 연결
  M-->>MM: 연결 + tool 목록
  MM-->>O: tool 목록 병합
  O->>P: tool 스키마 변환
  O-->>E: 초기화 완료
  E-->>U: 설정 완료
  Note over U,L: Phase 2: 사용자 질의
  U->>E: 오늘 남은 todo 효율적 처리 방법
  E->>O: IPC 메시지 전달
  Note over U,L: Phase 3: LLM + Tool 판단
  O->>O: 히스토리 로드 (직접 관리)
  O->>P: messages + tools 전달
  P->>L: 벤더사 API 직접 호출
  L-->>P: tool_use (todo_getList)
  P-->>O: tool call 추출
  Note over U,L: Phase 4: 승인 ✅ 완전 제어
  O->>E: IPC 승인 요청
  E->>U: 승인 UI
  U-->>E: 승인
  E-->>O: IPC 승인 결과
  Note over U,L: Phase 5: Tool 실행
  O->>MM: tool 호출 라우팅
  MM->>M: tools/call (JSON-RPC)
  M->>D: DB 조회
  D-->>M: 결과
  M-->>MM: tool 결과
  MM-->>O: 결과 전달
  Note over U,L: Phase 6: 최종 응답
  O->>P: Tool 결과 포함 구성
  P->>L: SDK 재호출
  L-->>P: 최종 응답
  P-->>O: 응답 전달
  O->>O: 히스토리 저장
  O-->>E: 스트리밍 응답
  E-->>U: 화면 표시

개발자 구현 영역

방법 1과 대비해 거의 전부를 직접 구현해야 한다. CLI가 해주던 모든 것이 구현 범위에 들어온다.

영역구현 필요설명
FrontEnd (UI)🔴 직접 구현사용자 인터페이스, 승인 UI
Orchestrator🔴 직접 구현LLM 호출 루프, tool 판단, 재호출 로직
LLM Provider Adapter🔴 직접 구현벤더별 API 호출 + tool 스키마 변환
MCP Manager🔴 직접 구현사용자 기존 MCP 서버 spawn + 라우팅
History Manager🔴 직접 구현대화 히스토리 DB 저장/로드
MCP Server (Todo)🔴 직접 구현우리 서비스 데이터를 tool로 노출
Tool 스키마 변환🔴 직접 구현MCP tool 스키마 → 벤더별 포맷 변환
LLM API🟢 외부 서비스Anthropic / OpenAI API

방법 1에서 4개 모듈이었던 구현 범위가 7개 모듈로 늘어난다.

참고로 우리 서비스 데이터(Todo 등)는 오케스트레이터와 같은 프로세스에 있으므로, MCP 프로토콜 없이 직접 함수로 호출해도 된다. MCP Manager는 사용자가 기존에 등록한 외부 MCP 서버를 관리하는 용도다.

핵심 구현 코드

1. Orchestrator — 핵심 루프

오케스트레이션의 핵심은 LLM 호출 → tool 판단 → 승인 → 실행 → 재호출 루프다. 방법 1에서는 CLI가 이 루프를 내부적으로 처리했지만, 여기서는 직접 구현해야 한다.

// agent-server/orchestrator.ts
class Orchestrator {
  provider: LLMProviderAdapter;   // 🔴 직접 구현
  mcpManager: MCPManager;         // 🔴 직접 구현
  history: HistoryManager;        // 🔴 직접 구현
  tools: LocalTools;              // 🔴 우리 서비스 tool (MCP 불필요)
 
  async handleMessage(sessionId: string, msg: string) {
    const hist = await this.history.load(sessionId);
 
    // MCP 서버의 tool + 우리 서비스 tool을 합침
    const allTools = [
      ...this.mcpManager.getAllTools(),    // 사용자 기존 MCP
      ...this.tools.getDefinitions(),      // 우리 서비스 (직접 함수)
    ];
    const formattedTools = this.provider.formatTools(allTools);
 
    let res = await this.provider.chat([...hist, msg], formattedTools);
 
    // 🔴 이 루프 전체가 직접 구현 영역
    while (res.stopReason === "tool_use") {
      const toolCall = this.provider.extractToolCall(res);
 
      // Phase 4: 승인 — IPC로 FrontEnd에 요청 (✅ 완전 제어)
      const approved = await this.requestApproval(toolCall);
      if (!approved) {
        res = await this.provider.chat(
          [...hist, msg, { role: "tool_result", content: "사용자가 거부함" }],
          formattedTools
        );
        continue;
      }
 
      // Phase 5: tool 실행 — 우리 tool인지 외부 MCP인지 라우팅
      const result = this.tools.has(toolCall.name)
        ? await this.tools.execute(toolCall)        // 직접 함수 호출
        : await this.mcpManager.callTool(toolCall); // MCP JSON-RPC
 
      // Phase 6: 결과 포함 재호출
      res = await this.provider.chat(
        [...hist, msg, toolCall, result],
        formattedTools
      );
    }
 
    await this.history.save(sessionId, [...hist, msg, res]);
    return res;
  }
}

2. LLM Provider Adapter — 벤더별 스키마 변환

Anthropic과 OpenAI의 tool 정의 스키마가 다르다. Adapter에서 이 차이를 흡수한다.

// agent-server/providers/anthropic.ts
class AnthropicAdapter implements LLMProviderAdapter {
  private client = new Anthropic();
 
  formatTools(tools: UnifiedTool[]): AnthropicTool[] {
    return tools.map(t => ({
      name: t.name,
      description: t.description,
      input_schema: t.inputSchema,   // Anthropic 포맷
    }));
  }
 
  extractToolCall(res: AnthropicResponse): ToolCall {
    // ⚠️ 단순화: 실제로는 병렬 tool call (content에 tool_use가 여러 개)을 처리해야 함
    const block = res.content.find(b => b.type === "tool_use");
    return { name: block.name, input: block.input, id: block.id };
  }
 
  async chat(messages, tools) {
    return this.client.messages.create({
      model: "claude-sonnet-4-20250514",
      max_tokens: 4096,
      messages,
      tools,
    });
  }
}
 
// agent-server/providers/openai.ts
class OpenAIAdapter implements LLMProviderAdapter {
  formatTools(tools: UnifiedTool[]): OpenAITool[] {
    return tools.map(t => ({
      type: "function",
      function: {                       // OpenAI 포맷 — 구조가 다름
        name: t.name,
        description: t.description,
        parameters: t.inputSchema,
      },
    }));
  }
 
  extractToolCall(res: OpenAIResponse): ToolCall {
    const tc = res.choices[0].message.tool_calls[0];
    return { name: tc.function.name, input: JSON.parse(tc.function.arguments), id: tc.id };
  }
  // ...
}

3. MCP Manager — 사용자 기존 MCP 서버 관리

사용자가 기존에 등록한 MCP 서버 설정을 파싱해서 각각 child process로 spawn하고, tool 호출을 라우팅한다.

// agent-server/mcp-manager.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
 
class MCPManager {
  private clients = new Map<string, Client>();
 
  async loadFromConfig(configPath: string) {
    // 사용자의 MCP 설정 파일을 읽어서 각 서버를 spawn
    const config = JSON.parse(await fs.readFile(configPath, "utf-8"));
 
    for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
      const transport = new StdioClientTransport({
        command: serverConfig.command,    // e.g. "node"
        args: serverConfig.args,          // e.g. ["./foo-server/index.js"]
        env: serverConfig.env,
      });
      const client = new Client({ name: `client-${name}`, version: "1.0.0" });
      await client.connect(transport);
      this.clients.set(name, client);
    }
  }
 
  async getAllTools(): Promise<UnifiedTool[]> {
    // 모든 MCP 서버의 tool 목록을 수집해서 병합
    const tools: UnifiedTool[] = [];
    for (const [serverName, client] of this.clients) {
      const { tools: serverTools } = await client.listTools();
      tools.push(...serverTools.map(t => ({ ...t, server: serverName })));
    }
    return tools;
  }
 
  async callTool(toolCall: ToolCall) {
    // tool 이름으로 어떤 MCP 서버의 tool인지 찾아서 라우팅
    const tool = this.findToolServer(toolCall.name);
    const client = this.clients.get(tool.server);
    return client.callTool({ name: toolCall.name, arguments: toolCall.input });
  }
}

4. 우리 서비스 Tool — MCP 없이 직접 함수 호출

// agent-server/tools/todo.ts
// 우리 서비스 데이터는 같은 프로세스에서 직접 DB 접근 — MCP 불필요
const todoTool: LocalTool = {
  name: "todo_getList",
  description: "사용자의 할 일 목록 조회",
  inputSchema: {
    type: "object",
    properties: { date: { type: "string" } },
    required: ["date"],
  },
  execute: async ({ date }) => {
    return db.query("SELECT * FROM todos WHERE date = ?", [date]);
  },
};

인터페이스 안정성 ✅

방법 1과의 핵심 차이: 공식 문서화된 API 스펙에 의존한다.

구간프로토콜안정성
Orchestrator ↔ MCP ServerJSON-RPC 2.0 (MCP 공식 스펙)✅ 안정
Provider Adapter ↔ LLM API벤더 공식 API 스펙✅ 안정
FrontEnd ↔ OrchestratorIPC (우리가 정의)✅ 완전 제어

Anthropic의 tool_use block 구조, OpenAI의 tool_calls array 구조는 각각 공식 문서화되어 있다. 벤더마다 구조가 다르지만, Provider Adapter에서 한 번 매핑하면 된다.

단, 방법 1과 달리 안 바뀌는 것이 아니라 바뀔 때 절차가 있다는 차이다:

  • deprecated 시 사전 공지가 있음
  • 버전 관리(v1 → v2)가 있음
  • 마이그레이션 가이드가 제공됨
  • 즉, 대응할 시간이 확보됨

주의사항

오케스트레이션 루프의 에지 케이스

LLM이 여러 tool을 연쇄 호출하거나, tool 결과를 보고 다시 다른 tool을 호출하는 경우가 있다. 루프에 최대 턴 수 제한무한 루프 방지 로직을 반드시 넣어야 한다.

const MAX_TURNS = 20;
let turns = 0;
 
while (res.stopReason === "tool_use" && turns < MAX_TURNS) {
  // ... tool 실행 ...
  turns++;
}
 
if (turns >= MAX_TURNS) {
  // 강제 종료 + 사용자에게 알림
}

벤더별 Tool 스키마 차이

MCP 서버가 제공하는 tool 스키마를 각 벤더 포맷으로 변환해야 한다. 특히 주의할 점:

// MCP tool 스키마 (표준)
{ name: "todo_getList", inputSchema: { type: "object", ... } }
 
// Anthropic 포맷
{ name: "todo_getList", input_schema: { type: "object", ... } }
//                      ^^^^^^^^^^^^  snake_case
 
// OpenAI 포맷
{ type: "function", function: { name: "todo_getList", parameters: { ... } } }
//                  ^^^^^^^^  한 단계 더 감싸야 함

key 하나 틀리면 API가 에러를 반환하므로, 변환 로직에 대한 테스트가 중요하다.

히스토리 관리의 복잡도

직접 관리하는 만큼 고려할 것이 많다:

  • 컨텍스트 윈도우 초과 시 오래된 메시지 truncation
  • tool_use / tool_result 쌍이 깨지면 API 에러 발생
  • 히스토리에 tool 결과까지 포함하면 DB 용량이 빠르게 증가

사용자 기존 MCP 설정 파일 경로

벤더마다 MCP 설정 파일 위치가 다르다. 사용자가 어떤 CLI를 사용했는지에 따라 파싱 경로가 달라진다.

// Claude Desktop
"~/Library/Application Support/Claude/claude_desktop_config.json"
 
// Claude Code CLI
"~/.claude.json"
 
// 우리 앱은 사용자가 선택한 벤더에 따라 올바른 경로를 찾아야 함

한계점

방법 1의 한계는 모두 해결하지만, 새로운 한계가 생긴다. 이것이 3편(Agent SDK)의 등장 이유다.

1. 구현 비용이 매우 높음

오케스트레이션 루프, Provider Adapter, MCP Manager, History Manager, 에러 핸들링, 재시도 로직, 스트리밍 처리 — 이 모든 것을 직접 구현하고 유지보수해야 한다. CLI가 수년간 다듬어온 로직을 처음부터 만드는 것이다.

2. LLM 벤더 업데이트 추적 부담

공식 API 스펙은 안정적이지만, 새로운 기능(tool streaming, parallel tool calls 등)이 추가될 때마다 Provider Adapter를 업데이트해야 한다. 벤더가 늘어날수록 이 부담은 선형으로 증가한다.

3. 오케스트레이션 품질

CLI 벤더(Anthropic, OpenAI)는 오케스트레이션을 지속적으로 최적화한다. 컨텍스트 압축, 효율적인 재호출 전략, 에러 복구 등 내부 최적화를 직접 구현에서 따라가기 어렵다.

4. 자유도는 높지만 유지보수 비용도 높음

모든 것을 제어할 수 있다는 장점이 곧 단점이기도 하다. 모든 변경에 대한 책임이 우리에게 있다.


다음 편 예고: 3편에서는 “오케스트레이션은 SDK에 맡기면서, 승인/히스토리/토큰은 공식 콜백으로 제어”하는 방법 3: Agent SDK 방식을 다룬다. 방법 1의 장점(위임)과 방법 2의 장점(제어)을 모두 얻는 접근이다.

참고 문서