시리즈 소개: 우리 서비스 데이터 + LLM = 앱 고유 AI 서비스
이 시리즈는 우리 서비스의 DB 데이터와 LLM을 연동하여, 우리 앱에서만 제공할 수 있는 AI 서비스를 만드는 방법을 탐색하는 과정이다.
예를 들어 우리 서비스에 사용자의 할 일(Todo) 데이터가 있다고 하자. 사용자가 “오늘 남은 할 일을 효율적으로 처리하는 방법”을 물으면, LLM이 우리 DB에서 해당 사용자의 Todo 목록을 가져와 우선순위를 분석하고 맞춤형 답변을 제공하는 것이 목표다.
이를 구현하는 아키텍처는 크게 3가지가 있으며, 4편에서 3편을 심화한다:
| 편 | 방법 | 핵심 |
|---|---|---|
| 1편 (본문) | CLI Child Process | CLI(Claude Code, Codex 등)를 자식 프로세스로 실행, 오케스트레이션 위임 |
| 2편 | 직접 구현 | LLM API 직접 호출 + 오케스트레이터 자체 구현 |
| 3편 | Agent SDK | 벤더 공식 SDK로 오케스트레이션 위임 + 공식 콜백으로 제어 |
| 4편 | Agent SDK 내부 선택지 | Tool 제공 방식(MCP/로컬)과 행동 지침(Skill) 선택 기준 |
각 방법의 장단점과 한계를 비교하고, 한 방법의 한계가 다음 방법의 등장 이유가 되는 진화 흐름을 따라간다.
방법 1: CLI Child Process 방식
FrontEnd 앱이 CLI(Claude Code, Codex 등)를 child process로 spawn하고, stdin/stdout으로 통신하는 아키텍처다. LLM 호출, tool 판단, MCP 서버 관리 등 오케스트레이션 전체를 CLI에 위임한다.
- CLI가 LLM API 호출 → tool 필요 여부 판단 → MCP 서버 호출 → 재호출 루프를 자동 처리
- 우리 서비스의 MCP 서버(Todo 등)를 CLI 설정에 등록하면, CLI가 자동으로 spawn하고 tool 목록을 LLM에 전달
- 사용자가 기존에 사용하던 MCP 서버(파일 시스템, GitHub 등)도 CLI가 설정 파일에서 읽어 함께 로드
MCP(Model Context Protocol): Anthropic이 설계한 오픈 프로토콜. LLM 앱과 외부 데이터/도구를 연결하는 표준이다. JSON-RPC 2.0 over stdio를 공식 통신 방식으로 사용하며, MCP 클라이언트(이 아키텍처에서는 CLI)가 MCP 서버를 child process로 spawn해 stdin/stdout 파이프로 양방향 통신한다.
해당 방법이 필요한 이유
- LLM은 우리 서비스 데이터를 알 수 없다. 일반적인 답변만 가능
- 우리 DB 데이터를 LLM에 연결하려면 **중간 레이어(오케스트레이터)**가 필요
- 오케스트레이션(LLM 호출 루프, tool 라우팅, 컨텍스트 관리)을 직접 구현하면 비용이 높다
- 이미 완성된 오케스트레이터인 CLI를 그대로 활용하면 구현 비용을 크게 줄일 수 있다
AS-IS
sequenceDiagram autonumber actor U as 사용자 participant F as FrontEnd participant L as LLM API U->>F: "오늘 남은 할 일을 효율적으로 처리하려면?" F->>L: 사용자 질문 전달 L-->>F: 일반적인 시간 관리 조언 (우리 데이터 모름) F-->>U: "포모도로 기법을 추천합니다..." (개인화 없음)
TO-BE
sequenceDiagram autonumber actor U as 사용자 participant F as FrontEnd participant M as MCP Server (우리 서비스) participant C as CLI participant L as LLM API U->>F: "오늘 남은 할 일을 효율적으로 처리하려면?" F->>C: stdin 메시지 전달 C->>L: LLM API 호출 L-->>C: todo_getList 호출 필요 C->>M: tools/call (JSON-RPC via stdio) M-->>C: [회의 준비, 코드 리뷰, 배포] C->>L: Tool 결과 포함 재호출 L-->>C: 맞춤형 우선순위 분석 C-->>F: stdout 결과 F-->>U: "회의 준비를 먼저, 코드 리뷰는 오후에..."
전체 아키텍처
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 CLI Runtime 🟢 구현불필요 participant C as CLI (Claude/Codex) participant O as Orchestrator (CLI 내장) end box rgb(255,230,230) External participant L as LLM API end Note over U,L: Phase 1: 초기 설정 U->>E: 앱 실행 + Provider 선택 E->>E: MCP 설정 파일 탐색 E->>E: Todo MCP 설정 추가 E->>C: CLI spawn (child process) C->>O: 오케스트레이터 초기화 O->>M: MCP Server spawn (stdio) M-->>O: 연결 완료 + tool 목록 O-->>C: 초기화 완료 C-->>E: stdout ready E-->>U: 설정 완료 Note over U,L: Phase 2: 사용자 질의 U->>E: 오늘 남은 todo 효율적 처리 방법 E->>C: stdin 메시지 전달 Note over U,L: Phase 3: LLM + Tool 판단 C->>O: 메시지 전달 O->>L: LLM API 호출 L-->>O: todo_getList 호출 필요 Note over U,L: Phase 4: 승인 ⚠️ stdout 파싱 O->>C: 승인 요청 이벤트 C-->>E: stdout JSON (⚠️ 비공식) E->>U: 승인 UI U-->>E: 승인 E->>C: stdin 승인응답 (⚠️ 비공식) Note over U,L: Phase 5: Tool 실행 C->>O: 승인 확인 O->>M: tools/call (JSON-RPC) M->>D: DB 조회 D-->>M: 결과 M-->>O: tool 결과 Note over U,L: Phase 6: 최종 응답 O->>L: Tool 결과 포함 재호출 L-->>O: 최종 응답 O-->>C: 결과 전달 C-->>E: stdout result E-->>U: 화면 표시
개발자 구현 영역
이 방법의 최대 장점은 CLI Runtime 전체를 구현하지 않아도 된다는 점이다.
| 영역 | 구현 필요 | 설명 |
|---|---|---|
| FrontEnd (UI) | 🔴 직접 구현 | 사용자 인터페이스, 승인 UI |
| MCP 설정 파일 편집 | 🔴 직접 구현 | CLI 설정에 우리 MCP 서버 정보 추가 |
| MCP Server (Todo) | 🔴 직접 구현 | 우리 서비스 데이터를 tool로 노출 |
| stdout 파싱 로직 | 🔴 직접 구현 | CLI 출력을 파싱해 UI에 표시 |
| CLI Runtime | 🟢 구현 불필요 | CLI가 처리 |
| Orchestrator | 🟢 구현 불필요 | CLI 내장 오케스트레이터가 처리 |
| LLM API 호출 | 🟢 구현 불필요 | CLI가 처리 |
| MCP 라우팅 | 🟢 구현 불필요 | CLI가 MCP 서버 간 tool 호출 라우팅 |
사용자가 기존에 등록한 MCP 서버(파일 시스템, GitHub 등)가 있다면, CLI가 설정 파일(~/.claude.json 등)에서 읽어 자동으로 함께 로드한다. 우리는 우리 서비스의 MCP 서버만 해당 설정에 추가하면 된다.
핵심 구현 코드
1. MCP Server (우리 서비스 데이터 제공)
// mcp-server/index.ts — MCP 프로토콜로 우리 DB 데이터를 tool로 노출
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "our-service", version: "1.0.0" });
server.tool(
"todo_getList",
"사용자의 할 일 목록 조회",
{ date: z.string().describe("조회 날짜 (YYYY-MM-DD)") },
async ({ date }) => {
const todos = await db.query(
"SELECT * FROM todos WHERE date = ? AND user_id = ?",
[date, currentUserId]
);
return { content: [{ type: "text", text: JSON.stringify(todos) }] };
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
// stdin으로 JSON-RPC 메시지를 받고, stdout으로 MCP 메시지를 보냄
// ⚠️ stdout은 오직 valid MCP 메시지 전용 (request/response/notification) — 디버그 로그는 반드시 stderr로2. FrontEnd에서 CLI spawn + 통신
// frontend/claude-runner.ts — CLI를 자식 프로세스로 실행
import { spawn } from "child_process";
const cli = spawn("claude", [
"-p", // non-interactive (print) 모드
"--output-format", "stream-json", // JSON 스트리밍 출력
"--mcp-config", "./our-mcp.json", // 우리 MCP 서버 설정 경로
]);
// 사용자 질문을 stdin으로 전달
cli.stdin.write(userMessage + "\n");
// CLI stdout을 파싱해 UI에 표시
cli.stdout.on("data", (data) => {
const lines = data.toString().split("\n").filter(Boolean);
for (const line of lines) {
const event = JSON.parse(line);
if (event.type === "tool_use_request") { // ⚠️ 비공식 이벤트
const ok = await showApprovalUI(event); // FrontEnd 승인 UI 표시
cli.stdin.write(JSON.stringify({ approved: ok }) + "\n");
// ⚠️ 이 응답 포맷도 CLI 버전에 따라 달라질 수 있음
}
if (event.type === "result") {
displayResult(event.content);
}
}
});3. MCP 설정 파일
{
"mcpServers": {
"our-service": {
"command": "node",
"args": ["./mcp-server/dist/index.js"],
"env": { "DB_PATH": "./data/todos.db" }
}
}
}CLI가 이 설정을 읽으면 our-service를 child process로 spawn하고, 사용자가 기존에 ~/.claude.json에 등록한 다른 MCP 서버들도 함께 로드한다. LLM은 모든 MCP 서버의 tool 목록을 한꺼번에 전달받아 어떤 tool을 호출할지 판단한다.
인터페이스 안정성 ⚠️
이 방법의 가장 큰 리스크는 FrontEnd와 CLI 사이의 통신 인터페이스가 비공식이라는 점이다.
| 구간 | 프로토콜 | 안정성 |
|---|---|---|
| CLI ↔ MCP Server | JSON-RPC 2.0 (MCP 공식 스펙) | ✅ 안정 |
| CLI ↔ LLM API | 벤더 공식 API | ✅ 안정 |
| FrontEnd ↔ CLI | stdout JSON 파싱 | ⚠️ 비공식 |
CLI의 --output-format stream-json stdout은 JSON 형식이지만 공식 API 계약이 아니다. key 이름이나 구조가 문서화되지 않은 내부 구현이므로:
- 오늘
{"type": "tool_use_request", "tool": "getList"}이던 것이 - 다음 버전에서
{"type": "approval_needed", "toolName": "getList"}로 바뀌어도 - 벤더 입장에서는 breaking change가 아니다 — 공식 계약이 아니니까
벤더마다 이 stdout 포맷이 다를 수도 있고 (Claude CLI vs Codex CLI), CLI 업데이트만으로 우리 파싱 코드가 깨질 수 있다.
주의사항
stdout은 오직 valid MCP 메시지 전용 (MCP 서버 구현 시)
MCP 서버의 stdout에 디버그 로그를 출력하면 MCP 통신이 깨진다. MCP 스펙: “The server MUST NOT write anything to its stdout that is not a valid MCP message.” 로그는 반드시 stderr로 보내야 한다.
// ❌ 잘못된 사용 — stdout에 로그 출력
console.log("debug: fetching todos...");
// ✅ 올바른 사용 — stderr로 로그 출력
console.error("debug: fetching todos...");Python MCP 서버는 버퍼링 주의
Python으로 MCP 서버를 구현할 때 PYTHONUNBUFFERED=1을 설정하지 않으면 stdout 버퍼링으로 인해 응답이 지연되거나 통신이 끊길 수 있다.
{
"our-python-service": {
"command": "python3",
"args": ["./mcp-server/server.py"],
"env": { "PYTHONUNBUFFERED": "1" }
}
}CLI 프로세스 생명주기 관리
CLI를 child process로 spawn하면 FrontEnd와 운명 공동체가 된다. FrontEnd 종료 시 CLI 프로세스도 정리해야 하며, CLI가 예기치 않게 종료되었을 때의 에러 핸들링도 필요하다.
// FrontEnd 종료 시 CLI 프로세스 정리
process.on("exit", () => cli.kill());
cli.on("exit", (code) => {
if (code !== 0) handleCLICrash(code);
});세션 유지는 CLI 기능에 의존
컨텍스트 유지(이전 대화 이어가기)는 CLI가 제공하는 --resume 플래그에 의존한다.
# 세션 ID를 캡처해서
session_id=$(claude -p "할 일 목록 보여줘" --output-format json | jq -r '.session_id')
# 다음 질문에서 이어가기
claude -p "그중 급한 것부터 정리해줘" --resume "$session_id"세션 관리 방식이 CLI 벤더마다 다르고, 히스토리를 우리 DB에 저장하거나 가공하는 것은 불가능하다.
한계점
이 방법의 한계가 곧 2편(직접 구현)과 3편(Agent SDK)의 등장 이유다.
1. 승인 플로우가 비공식 프로토콜에 의존
Phase 4(승인)에서 CLI stdout의 비공식 JSON 이벤트를 파싱해야 한다. 이 포맷은 문서화되지 않아 CLI 업데이트 시 공지 없이 깨질 수 있다. 벤더마다 포맷이 다르므로 멀티 벤더 지원 시 벤더별 파싱 로직을 각각 구현해야 한다.
2. 히스토리 · 과금 추적 불가
대화 히스토리는 CLI 내부에서 관리되므로, 우리 DB에 저장하거나 토큰 사용량을 추적하는 것이 불가능하다. CLI가 제공하는 명령어(sessions list 등)로 조회는 가능하지만, 프로그래밍 방식으로 실시간 접근할 수 없다.
3. LLM 응답 가공 불가
CLI가 LLM 응답을 직접 처리하므로, 응답을 중간에 가로채서 가공(키워드 필터링, 포맷 변환 등)하는 것이 어렵다.
4. 멀티 벤더 지원 시 복잡도 증가
CLI 자체가 벤더별로 다르므로 (Claude CLI, Codex CLI), spawn 명령어 · stdout 포맷 · 세션 관리 방식 모두 벤더별로 대응해야 한다.
다음 편 예고: 2편에서는 이 한계들을 해결하기 위해 오케스트레이터를 직접 구현하는 방법 2: 직접 구현 방식을 다룬다. 모든 것을 제어할 수 있지만, 그만큼의 구현 비용이 따른다.