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

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

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

방법 3: Agent SDK 방식

벤더가 제공하는 공식 Agent SDK를 import해서 오케스트레이션을 위임하고, 승인 · 히스토리 · MCP 연결은 SDK가 제공하는 공식 콜백과 옵션으로 제어하는 아키텍처다.

  • 오케스트레이션(LLM 호출 루프 → tool 판단 → 재호출)은 SDK가 자동 처리
  • 승인은 canUseTool 같은 공식 콜백으로 FrontEnd UI와 연결
  • MCP 서버(우리 서비스 + 사용자 기존)는 mcpServers 옵션에 전달하면 SDK가 자동 연결
  • 세션 유지는 resume 옵션으로 이전 대화를 이어감

한마디로 방법 1의 장점(오케스트레이션 위임)과 방법 2의 장점(프로그래밍 제어)을 모두 얻는 방식이다.

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

2편에서 사용한 API Client(@anthropic-ai/sdk, openai)와는 완전히 다른 패키지다.

AnthropicOpenAI
패키지명@anthropic-ai/claude-agent-sdk@openai/codex-sdk
역할오케스트레이션 포함 고수준 SDK오케스트레이션 포함 고수준 SDK
승인 콜백canUseTool⚠️ 공식 문서 미확인
MCP 연결mcpServers 옵션⚠️ 공식 문서 미확인
세션 유지resume 옵션resumeThread(threadId)
인증API key 필수 (종량제)ChatGPT 구독 또는 API key
CLI 의존성✅ 없음 — npm install만으로 동작⚠️ Codex agent 제어 SDK (상세 미확인)
내부 동작SDK → LLM API 직접 호출SDK → Codex agent 제어

Codex SDK는 “Codex agents의 programmatic control”을 위한 라이브러리이며, 우리 코드에서는 SDK 인터페이스(codex.startThread(), thread.run())만 사용한다. 단, 승인 콜백과 MCP 연결 옵션은 공식 문서에서 확인되지 않으므로, Claude Agent SDK처럼 동일한 수준의 공식 콜백 제어가 가능한지는 검증이 필요하다.

해당 방법이 필요한 이유

CLI와 Agent SDK는 같은 agent loop이다

공식 문서에 따르면 Agent SDK는 **“the same tools, agent loop, and context management that power Claude Code”**를 제공한다. CLI와 Agent SDK의 관계는 **“Same capabilities, different interface”**다.

즉, 오케스트레이션(agent loop) 자체는 1편의 CLI와 동일하다. 핵심 차이는 인터페이스의 성격이다:

  • CLI는 사람이 터미널에서 쓰라고 만든 것이고, Agent SDK는 프로그램이 코드에서 쓰라고 만든 것이다.
CLI (1편)Agent SDK (3편)
agent loop내장내장 (동일)
승인 제어stdout 파싱 (비공식)canUseTool 콜백 (공식)
히스토리CLI 내부, 접근 어려움스트리밍 메시지로 수신
과금 추적접근 불가SDK usage 추적
세션 관리--resume 플래그resume: sessionId 옵션

1편의 한계는 agent loop이 부족해서가 아니라, 우리 앱에서 프로그래밍적으로 끼어들 수 있는 공식 제어 포인트가 없었기 때문이다.

그렇다면 왜 2편을 거쳐야 했나

2편은 이 제어 포인트를 얻기 위해 agent loop까지 전부 직접 구현한 방법이다. 제어는 완전하지만 비용이 너무 높았다:

  • 구현 비용이 매우 높음 → 오케스트레이션 루프, Provider Adapter, MCP Manager, History Manager 전부 직접 구현
  • 벤더 업데이트 추적 부담 → 새로운 기능(parallel tool calls 등) 추가 시 Adapter를 계속 업데이트
  • 오케스트레이션 품질 → 벤더가 수년간 최적화한 로직(컨텍스트 압축, 에러 복구 등)을 따라가기 어려움

Agent SDK = 1편의 agent loop + 2편의 제어

Agent SDK는 1편과 같은 agent loop을 그대로 쓰면서, 2편에서 원했던 제어(승인, 히스토리, 과금)를 공식 콜백과 옵션으로 제공한다.

AS-IS (방법 2의 한계)

sequenceDiagram
  autonumber
  participant O as Orchestrator (🔴 직접 구현)
  participant P as Provider Adapter (🔴)
  participant MM as MCP Manager (🔴)
  participant L as LLM API

  O->>P: messages + tools 전달 (🔴)
  P->>L: 벤더사 API 호출 (🔴 스키마 변환)
  L-->>P: tool_use 응답
  P-->>O: 공통 포맷 변환 (🔴)
  O->>MM: tool 라우팅 (🔴)
  O->>P: 재호출 구성 (🔴)
  Note right of O: 이 루프 전체가 직접 구현

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

sequenceDiagram
  autonumber
  participant W as Agent Wrapper (🔴 Wrapper만)
  participant SDK as Agent SDK (🟢)
  participant L as LLM API

  W->>SDK: query(prompt, options)
  SDK->>L: LLM 호출 (🟢 SDK 자동)
  L-->>SDK: tool 필요
  SDK->>W: canUseTool 콜백 (🟢 공식)
  W-->>SDK: allow / deny
  SDK->>SDK: tool 실행 + 재호출 (🟢 자동)
  SDK-->>W: 스트리밍 메시지
  Note right of W: Wrapper + 콜백만 구현

전체 아키텍처

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 Wrapper 🔴 Wrapper만
    participant W as Agent Wrapper
    participant SDK as Agent SDK 🟢
  end
  box rgb(255,230,230) External
    participant L as LLM API
  end
  Note over U,L: Phase 1: 초기 설정
  U->>E: 앱 실행 + Provider 선택
  E->>W: Provider 설정 요청
  W->>W: MCP 설정 파일 파싱
  W->>SDK: SDK 초기화 (mcpServers + canUseTool)
  SDK->>M: MCP 연결 (SDK 자동)
  M-->>SDK: 연결 + tool 목록
  SDK-->>W: 초기화 완료
  W-->>E: 초기화 완료
  E-->>U: 설정 완료
  Note over U,L: Phase 2: 사용자 질의
  U->>E: 오늘 남은 todo 효율적 처리 방법
  E->>W: IPC 메시지 전달
  Note over U,L: Phase 3: LLM + Tool 판단 (SDK 자동)
  W->>SDK: query(prompt, options)
  SDK->>L: LLM API 호출 (SDK 자동)
  L-->>SDK: todo_getList 호출 필요
  Note over U,L: Phase 4: 승인 ✅ 공식 콜백
  SDK->>W: canUseTool 콜백 호출
  W->>E: IPC 승인 요청
  E->>U: 승인 UI
  U-->>E: 승인
  E-->>W: IPC 승인 결과
  W-->>SDK: return allow
  Note over U,L: Phase 5: Tool 실행 (SDK 자동)
  SDK->>M: tools/call (SDK 라우팅)
  M->>D: DB 조회
  D-->>M: 결과
  M-->>SDK: tool 결과
  Note over U,L: Phase 6: 최종 응답
  SDK->>L: 재호출 (SDK 자동)
  L-->>SDK: 최종 응답
  SDK-->>W: 스트리밍 메시지
  W->>W: 히스토리 저장
  W-->>E: 스트리밍 응답
  E-->>U: 화면 표시

개발자 구현 영역

방법 2에서 7개 모듈이었던 구현 범위가 5개로 줄어든다. 핵심 차이는 Orchestrator · LLM 호출 · MCP 라우팅 · Tool 스키마 변환이 모두 SDK로 넘어간다는 점이다.

영역구현 필요설명
FrontEnd (UI)🔴 직접 구현사용자 인터페이스, 승인 UI
Agent Wrapper🔴 직접 구현SDK 감싸기 + 벤더별 공통 인터페이스
canUseTool 콜백🔴 직접 구현승인 요청을 FrontEnd에 IPC로 전달
MCP 설정 파싱🔴 직접 구현사용자 기존 MCP 설정 읽어서 SDK에 전달
히스토리 저장 로직🔴 직접 구현SDK 스트리밍 메시지를 DB에 저장
Orchestrator🟢 SDK 처리LLM 호출 루프, tool 판단, 재호출
LLM API 호출🟢 SDK 처리SDK가 직접 호출
MCP 라우팅🟢 SDK 처리tool → MCP 서버 매핑
Tool 스키마 변환🟢 SDK 처리MCP tool → LLM API 포맷 변환
재호출 루프🟢 SDK 처리tool 결과 포함 LLM 재호출

핵심 구현 코드

1. 공통 인터페이스

벤더별 Agent SDK를 공통 인터페이스로 추상화한다. 방법 2의 Provider Adapter와 유사하지만, 오케스트레이션 루프가 빠져서 훨씬 단순하다.

// agent-wrapper/interface.ts
interface AgentProvider {
  query(
    message: string,
    mcpServers: Record<string, McpConfig>,
    canUseTool: ApprovalCallback,
    sessionId?: string,
  ): AsyncIterable<AgentMessage>;
}
 
type ApprovalCallback = (
  toolName: string,
  input: Record<string, unknown>,
) => Promise<
  | { behavior: "allow"; updatedInput: Record<string, unknown> }
  | { behavior: "deny"; message?: string }
>;

2. Claude Agent SDK 구현

// agent-wrapper/providers/claude.ts
class ClaudeAgentProvider implements AgentProvider {
  async *query(message, mcpServers, canUseTool, sessionId) {
    const { query: claudeQuery } = await import("@anthropic-ai/claude-agent-sdk");
 
    for await (const msg of claudeQuery({
      prompt: message,
      options: {
        mcpServers,         // 기존 MCP + 우리 MCP 일괄 전달
        canUseTool,         // ✅ 공식 승인 콜백
        resume: sessionId,  // 세션 이어가기
      },
    })) {
      yield msg;            // 🟢 LLM 호출·tool·재호출 → SDK 자동
    }
  }
}

3. Codex SDK 구현

// agent-wrapper/providers/codex.ts
class CodexAgentProvider implements AgentProvider {
  async *query(message, mcpServers, canUseTool, sessionId) {
    const { Codex } = await import("@openai/codex-sdk");
 
    const codex = new Codex();
    const thread = sessionId
      ? codex.resumeThread(sessionId)  // 세션 이어가기
      : codex.startThread();
 
    const result = await thread.run(message);
    yield result;                      // 🟢 SDK 자동 처리
    // ⚠️ 단순화: Codex SDK의 승인 콜백·MCP 옵션은 공식 문서에 명시되지 않음
    // 상세 옵션은 GitHub 참고: github.com/openai/codex/tree/main/sdk/typescript
  }
}

4. Agent Wrapper — 공통 사용부

// agent-wrapper/index.ts
const provider = usesClaude
  ? new ClaudeAgentProvider()
  : new CodexAgentProvider();
 
// 사용자 기존 MCP + 우리 서비스 MCP 병합
const mcpServers = {
  ...parsedUserMcpConfig,    // 사용자 기존 MCP (설정 파일에서 파싱)
  "our-service": {           // 우리 서비스 MCP
    command: "node",
    args: ["./mcp-server/dist/index.js"],
  },
};
 
for await (const msg of provider.query(
  userMessage,
  mcpServers,
  async (toolName, input) => {
    // 승인 콜백 — FrontEnd에 IPC로 전달
    const ok = await frontendIPC.requestApproval({ toolName, input });
    return ok
      ? { behavior: "allow", updatedInput: input }   // updatedInput으로 입력 수정도 가능
      : { behavior: "deny", message: "User denied" };
  },
  sessionId,
)) {
  frontendIPC.streamText(msg.content);     // FrontEnd에 스트리밍
  historyDB.append(sessionId, msg);        // 히스토리 DB 저장
}

방법 2와 비교하면:

  • Orchestrator의 while (res.stopReason === "tool_use") 루프가 사라졌다
  • Provider Adapter의 formatTools(), extractToolCall() 변환 로직이 사라졌다
  • MCP Manager의 spawn(), callTool() 라우팅 로직이 사라졌다
  • 남은 것은 Wrapper + 콜백 + 히스토리 저장뿐이다

인터페이스 안정성 ✅

구간프로토콜안정성
Agent Wrapper ↔ SDK벤더 공식 SDK API✅ 안정
SDK ↔ MCP ServerJSON-RPC 2.0 (SDK 자동)✅ 안정
SDK ↔ LLM APISDK 내부 (우리가 관리하지 않음)✅ 안정
FrontEnd ↔ Agent WrapperIPC (우리가 정의)✅ 완전 제어

방법 2와 동일하게 공식 문서화된 인터페이스에 의존하며, deprecated 시 사전 공지 + 마이그레이션 가이드가 제공된다.

단, 벤더마다 SDK API가 다르다 (Claude는 canUseTool 콜백 + resume 옵션, Codex는 startThread()/resumeThread() 메서드 + 승인 콜백 미확인). Agent Wrapper에서 이 차이를 공통 인터페이스로 흡수하므로, 나머지 코드는 벤더를 의식하지 않는다.

주의사항

SDK 버전 호환성

Agent SDK도 결국 외부 패키지이므로, major 버전 업그레이드 시 breaking change가 있을 수 있다. package.json에서 버전을 고정(^ 대신 정확한 버전)하고, 업그레이드는 changelog 확인 후 진행하는 것이 안전하다.

Codex SDK의 런타임 의존성

Codex SDK(@openai/codex-sdk)는 “Codex agents의 programmatic control”을 위한 라이브러리다. 공식 문서에서 내부 구현(CLI spawn 여부 등)은 명시되지 않으나, Codex agent를 제어하는 특성상 런타임 의존성이 있을 수 있다. 배포 시 GitHub 저장소에서 요구사항을 확인하는 것이 안전하다.

Claude Agent SDK는 npm install만으로 동작하므로 이 문제가 없다.

승인 콜백의 벤더별 차이

콜백 시그니처와 반환 타입이 벤더마다 다를 수 있다. 공통 인터페이스에서 이 차이를 흡수해야 한다.

// Claude: canUseTool(toolName, input) → { behavior: "allow", updatedInput } | { behavior: "deny", message }
// Codex:  승인 콜백 공식 미확인 — SDK 업데이트 시 확인 필요
 
// → 공통 인터페이스에서 변환
canUseTool: async (toolName, input) => {
  const approved = await frontendIPC.requestApproval({ toolName, input });
  // 벤더별 반환 타입에 맞게 변환
  return vendorSpecificFormat(approved);
}

히스토리 저장 시 메시지 구조

SDK가 스트리밍하는 메시지 객체의 구조가 벤더마다 다르다. 히스토리 DB에 저장할 때 공통 포맷으로 정규화하는 것이 좋다.

한계점

방법 1, 2의 주요 한계를 해결하지만, 이 방법에도 한계는 있다.

1. 벤더 SDK에 대한 의존

오케스트레이션을 SDK에 위임한다는 것은, SDK의 동작 방식에 의존한다는 뜻이다. SDK가 내부적으로 어떻게 컨텍스트를 관리하고, 어떤 전략으로 재호출하는지 제어할 수 없다. 방법 2에서는 이 모든 것을 직접 구현했으므로 완전한 제어가 가능했다.

2. SDK가 지원하지 않는 벤더

Claude Agent SDK와 Codex SDK가 존재하지만, 모든 LLM 벤더가 Agent SDK를 제공하는 것은 아니다. Agent SDK가 없는 벤더(예: Google Gemini)를 추가하려면 해당 벤더만 방법 2(직접 구현)로 대응해야 하는 혼합 아키텍처가 될 수 있다.

3. 세밀한 오케스트레이션 커스터마이징의 제한

SDK가 제공하는 콜백과 옵션 범위 내에서만 커스터마이징이 가능하다. 예를 들어 “특정 tool 결과를 받은 후 다른 LLM에 추가 질의”하는 복잡한 분기 로직은 SDK의 설계 범위를 벗어날 수 있다.


시리즈 정리: 3가지 방법 비교

방법 1: CLI방법 2: 직접 구현방법 3: Agent SDK
오케스트레이션CLI가 처리🔴 전부 직접 구현SDK가 처리
승인 UI⚠️ stdout 파싱 (비공식)✅ 완전 제어✅ 공식 콜백
히스토리 저장❌ CLI 내부✅ 직접 관리✅ 스트리밍 메시지
과금 추적❌ 접근 불가✅ API 응답✅ SDK usage
인터페이스 안정성⚠️ 비공식✅ 공식 API 스펙✅ 공식 SDK
멀티 벤더CLI별 파싱Provider AdapterAgent Wrapper
구현 모듈 수4개7개5개
구현 비용낮음높음중간

한 방법의 한계가 다음 방법의 등장 이유다:

  • 방법 1 한계 (승인 비공식, 내부 접근 불가) → 방법 2에서 해결 (전부 직접 구현)
  • 방법 2 한계 (구현 비용 높음, 오케스트레이션 품질) → 방법 3에서 해결 (SDK 위임 + 공식 콜백)

참고 문서