MCP 챗봇 TDD
Background
목표가 알림·종목 리스트·주문·보유 종목 기능을 구축했다. 각 기능은 독립적인 REST API로 존재하며, 사용자는 화면을 이동하며 개별 기능을 사용해야 한다. 이 모든 기능을 Spring AI MCP Tool로 통합하고, 로컬 claude -p CLI가 오케스트레이션하는 자연어 챗봇을 제공한다. Anthropic API 키 없이 로컬 Claude Code CLI만으로 동작한다.
Overview
Spring Boot 백엔드에 Spring AI MCP Server를 추가한다 (Anthropic ChatClient 불필요). 기존 UseCase들을 래핑한 @Tool 메서드를 노출하고, ChatApiController(애그리게이터)가 claude -p --mcp-config stock-mcp.json을 subprocess로 실행해 응답을 스트리밍한다. 웹 서치는 stock-mcp.json에 외부 MCP 서버(Brave/Tavily) 항목을 추가해 Claude가 직접 호출하게 한다.
Terminology
| 용어 | 정의 |
|---|---|
| MCP (Model Context Protocol) | LLM이 외부 Tool을 호출하기 위한 표준 프로토콜 |
| Spring AI MCP Server | Spring Boot 위에서 MCP 프로토콜을 구현하는 서버 모듈 |
claude -p | Claude Code CLI의 비대화형 출력 모드 (애그리게이터가 subprocess로 실행) |
--mcp-config | claude -p에 MCP 서버 JSON 설정 파일을 지정하는 플래그 |
--output-format stream-json | claude -p의 스트리밍 JSON 출력 모드 |
| 애그리게이터 | ChatApiController — 채팅 요청을 받아 claude -p를 실행하고 응답을 스트리밍 |
| SSE | Server-Sent Events — MCP 웹 트랜스포트 |
Define Problem
AS-IS
- 종목 조회·주문·알림 기능이 REST API로 분리돼 있어 단일 흐름 지원 불가.
- AI 통합 없이 수동 탐색만 가능.
- 시장 뉴스를 앱 내에서 조회할 수 없어 별도 검색이 필요.
TO-BE
- 자연어 입력 한 번으로 Tool 자동 선택·실행·결과 요약.
- Anthropic API 키 없이 로컬 Claude Code CLI로 동작 — 비용 0.
- MCP Server가 Claude Desktop 등 외부 MCP 클라이언트와도 연동.
stock-mcp.json에 웹 서치 MCP 항목 추가로 최신 뉴스·공시 통합.
Possible Solutions
벤치마킹 참조 제품
| 제품명 | 카테고리 | 참조 패턴 |
|---|---|---|
| Claude Desktop + MCP | AI 클라이언트 | MCP Tool 호출 패턴 |
Claude Code -p 모드 | CLI 애그리게이터 | --mcp-config로 로컬 MCP 연결 |
| Perplexity | AI 검색 | 웹 서치 + LLM 합성 응답 |
방안 비교
| 방안 | 설명 | 왜 채택 | 미채택 대안 |
|---|---|---|---|
claude -p + Spring AI MCP Server (선택) | Spring Boot가 MCP 서버 역할, 애그리게이터가 claude -p subprocess 실행 | Anthropic API 키 불필요. 로컬 Claude Code 재사용. 빌드 의존성 최소 | Spring AI ChatClient (Anthropic API 키 필요, 유료) |
| LangChain4j | Java LLM 프레임워크 | — | API 키 필요, 커뮤니티 작음 |
| Python FastAPI 애그리게이터 | 별도 Python 서버에서 claude -p 실행 | — | 스택 분리 비용 높음, Spring Boot 단일 프로세스로 충분 |
Detail Design
전체 흐름
[FE Chat UI]
↓ POST /api/chat/message {sessionId, message}
[ChatApiController (Spring Boot :8080) — 애그리게이터]
↓ ProcessBuilder: claude -p "{history + message}"
--mcp-config /etc/stock-mcp.json
--output-format stream-json
[claude -p (로컬 Claude Code CLI)]
↓ MCP Tool call (SSE) ↓ 웹 서치 MCP call
[/mcp/sse (Spring Boot :8080)] [Brave/Tavily MCP 서버]
StockMcpTools
WatchlistMcpTools
AlertMcpTools
OrderMcpTools
↓
[기존 UseCases → MySQL / Toss API]
MCP 설정 파일 (stock-mcp.json)
{
"mcpServers": {
"stock": {
"type": "sse",
"url": "http://localhost:8080/mcp/sse"
},
"brave-search": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
"env": { "BRAVE_API_KEY": "<key>" }
}
}
}Brave 대신 Tavily MCP(
@modelcontextprotocol/server-tavily)도 동일 방식으로 교체 가능.
레이어 구조
presentation/
ai/
ChatApiController.kt — POST /api/chat/message (애그리게이터)
StockMcpTools.kt — 종목 도메인 @Tool
WatchlistMcpTools.kt — 관심종목 도메인 @Tool
AlertMcpTools.kt — 알림 도메인 @Tool
OrderMcpTools.kt — 주문 도메인 @Tool
common/
config/
McpServerConfig.kt — MCP Server 빈 설정 (Anthropic 없음)
resources/
stock-mcp.json — claude -p 에 전달할 MCP 서버 설정
ChatApiController 구현 방향
@RestController
class ChatApiController(
@Value("\${chat.mcp-config-path}") private val mcpConfigPath: String,
@Value("\${chat.claude-path:claude}") private val claudePath: String,
) {
private val sessionHistory = ConcurrentHashMap<String, MutableList<String>>()
@PostMapping("/api/chat/message", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun chat(@RequestBody @Valid request: ChatRequest): SseEmitter {
val emitter = SseEmitter(120_000L)
val history = sessionHistory.getOrPut(request.sessionId) { mutableListOf() }
val fullPrompt = buildPrompt(history, request.message)
Thread {
try {
val process = ProcessBuilder(
claudePath, "-p", fullPrompt,
"--mcp-config", mcpConfigPath,
"--output-format", "stream-json",
"--allowedTools", "mcp__stock__*,mcp__brave-search__*"
).redirectErrorStream(true).start()
process.inputStream.bufferedReader().lines().forEach { line ->
parseStreamJson(line)?.let { emitter.send(it) }
}
process.waitFor()
emitter.complete()
history.add("User: ${request.message}")
} catch (e: Exception) {
emitter.completeWithError(e)
}
}.start()
return emitter
}
}Component Diagram
flowchart LR subgraph FE["Frontend"] ChatUI[챗봇 UI] end subgraph BE["Backend (Spring Boot :8080)"] subgraph Aggregator["Aggregator (presentation)"] ChatCtrl[ChatApiController] end subgraph McpServer["MCP Server (presentation)"] Stock[StockMcpTools] Watch[WatchlistMcpTools] Alert[AlertMcpTools] Order[OrderMcpTools] end subgraph Application UC[기존 UseCases] end end subgraph Local["로컬 CLI"] Claude[claude -p] end subgraph External Brave[Brave MCP Server] TossAPI[Toss Open API] end ChatUI -->|POST /api/chat/message| ChatCtrl ChatCtrl -->|subprocess| Claude Claude -->|MCP SSE tool call| Stock Claude -->|MCP SSE tool call| Watch Claude -->|MCP SSE tool call| Alert Claude -->|MCP SSE tool call| Order Claude -->|MCP stdio| Brave Stock --> UC Watch --> UC Alert --> UC Order --> UC UC --> TossAPI
Sequence Diagram
sequenceDiagram participant U as User participant FE as Chat UI participant API as ChatApiController participant CLI as claude -p participant MCP as Spring MCP Server participant Brave as Brave MCP U->>FE: "삼성전자 최신 뉴스와 현재가 알려줘" FE->>API: POST /api/chat/message API->>CLI: subprocess: claude -p "{prompt}" --mcp-config stock-mcp.json CLI->>Brave: brave_web_search("삼성전자 뉴스") Brave-->>CLI: 뉴스 기사 목록 CLI->>MCP: searchStocks("005930") via SSE MCP-->>CLI: StockResponse CLI-->>API: stream-json 청크 (스트리밍) API-->>FE: SSE 스트리밍 FE-->>U: 실시간 응답 표시
Tool 정의 상세
StockMcpTools
@Component
class StockMcpTools(
private val listStocksUseCase: ListStocksUseCase,
private val searchStocksUseCase: SearchStocksUseCase,
private val getStockPricesUseCase: GetStockPricesUseCase,
) {
@Tool(description = "주식 종목 목록 조회. 종목명·코드·현재가·등락률 포함.")
fun listStocks(page: Int, size: Int): List<StockResponse>
@Tool(description = "종목명 또는 종목 코드로 종목 검색.")
fun searchStocks(query: String): List<StockResponse>
@Tool(description = "종목 코드 목록의 현재 시세를 일괄 조회.")
fun getStockPrices(stockCodes: List<String>): List<StockPriceResponse>
}빌드 의존성 추가 (build.gradle.kts)
// BOM
implementation(platform("org.springframework.ai:spring-ai-bom:1.0.0"))
// MCP Server (SSE 트랜스포트) — Anthropic/Tavily 스타터 불필요
implementation("org.springframework.ai:spring-ai-starter-mcp-server-webmvc")설정 (application.yml 추가)
spring:
ai:
mcp:
server:
name: stock-mcp-server
version: 1.0.0
type: SYNC
chat:
mcp-config-path: ${MCP_CONFIG_PATH:/etc/stock-mcp.json}
claude-path: ${CLAUDE_PATH:claude}
MCP_CONFIG_PATH환경 변수로stock-mcp.json위치를 외부 주입한다.
ERD
변경 없음. 대화 히스토리는 인메모리(ConcurrentHashMap)로 관리하며 DB 영속화 불필요.
Testing Plan
StockMcpTools.searchStocks("삼성전자")호출 시SearchStocksUseCase에 위임한다.ChatApiController에 메시지 전송 시claude -p를 올바른 인자로 subprocess 실행한다.claude -p종료 코드가 0이 아닐 때 SSE에 error 이벤트를 전송한다.- 동일
sessionId로 2회 요청 시 이전 대화가 프롬프트에 포함된다. /mcp/sse엔드포인트가 200 응답을 반환한다.
Release Scenario
stock-mcp.json파일 작성 후 서버에 배치.MCP_CONFIG_PATH환경 변수 설정.build.gradle.kts의존성 추가 후 빌드.- 백엔드 재시작 —
/mcp/sse자동 활성화. - 로컬 머신에
claudeCLI 설치 확인 (claude --version). - FE 챗봇 컴포넌트 배포.
- 주문 구현 완료 후
OrderMcpTools@ConditionalOnProperty제거.
롤백: spring.ai.mcp.server.enabled=false로 MCP 전체 비활성화. 기존 REST API에 영향 없음.
Observability
| 지표 | 측정 방법 |
|---|---|
| Tool 호출 횟수·지연 | Spring AI Actuator /actuator/ai |
claude -p 응답 시간 | ChatApiController 내 System.nanoTime() 로그 |
| subprocess 오류율 | process.exitValue() != 0 카운터 로그 |
| MCP SSE 연결 수 | Spring MVC 액추에이터 지표 |
Project Information
- 담당자: biuea
- 전제: 로컬 머신에
claudeCLI 설치 완료 - 예상 소요: 3~4일
Document History
| 날짜 | 변경 내용 | 작성자 |
|---|---|---|
| 2026-06-19 | 최초 작성 | biuea |
| 2026-06-19 | claude -p 애그리게이터 패턴으로 변경 (Anthropic API 제거) | biuea |
| 2026-06-21 | 스킬 포맷 적용 | biuea |