시리즈 소개: 우리 서비스 데이터 + LLM = 앱 고유 AI 서비스
이 시리즈는 우리 서비스의 DB 데이터와 LLM을 연동하여, 우리 앱에서만 제공할 수 있는 AI 서비스를 만드는 방법을 탐색하는 과정이다.
| 편 | 방법 | 핵심 |
|---|---|---|
| 1편 | CLI Child Process | CLI를 자식 프로세스로 실행, 오케스트레이션 위임 |
| 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)와는 완전히 다른 패키지다.
| Anthropic | OpenAI | |
|---|---|---|
| 패키지명 | @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 Server | JSON-RPC 2.0 (SDK 자동) | ✅ 안정 |
| SDK ↔ LLM API | SDK 내부 (우리가 관리하지 않음) | ✅ 안정 |
| FrontEnd ↔ Agent Wrapper | IPC (우리가 정의) | ✅ 완전 제어 |
방법 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 Adapter | Agent Wrapper |
| 구현 모듈 수 | 4개 | 7개 | 5개 |
| 구현 비용 | 낮음 | 높음 | 중간 |
한 방법의 한계가 다음 방법의 등장 이유다:
- 방법 1 한계 (승인 비공식, 내부 접근 불가) → 방법 2에서 해결 (전부 직접 구현)
- 방법 2 한계 (구현 비용 높음, 오케스트레이션 품질) → 방법 3에서 해결 (SDK 위임 + 공식 콜백)