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

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

예를 들어 우리 서비스에 사용자의 할 일(Todo) 데이터가 있다고 하자. 사용자가 “오늘 남은 할 일을 효율적으로 처리하는 방법”을 물으면, LLM이 우리 DB에서 해당 사용자의 Todo 목록을 가져와 우선순위를 분석하고 맞춤형 답변을 제공하는 것이 목표다.

이를 구현하는 아키텍처는 크게 3가지가 있으며, 4편에서 3편을 심화한다:

방법핵심
1편 (본문)CLI Child ProcessCLI(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 ServerJSON-RPC 2.0 (MCP 공식 스펙)✅ 안정
CLI ↔ LLM API벤더 공식 API✅ 안정
FrontEnd ↔ CLIstdout 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: 직접 구현 방식을 다룬다. 모든 것을 제어할 수 있지만, 그만큼의 구현 비용이 따른다.

참고 문서