이전에 Forwarder 에 대해 설명했습니다.
이번 시간에는 Forwarder가 MCP 서버에게 보낸 후 어떻게 데이터가 관리되고 LLM에게 전달되는지 알아보고 개발 중 트러블 슈팅 경험을 기술해보겠습니다.
Node.js, PY 에서 MCP를 주력으로 만들었지만 Spring에 현재 Spring AI 라는 기능이 생기며 JAVA 에서 MCP 개발이 쉬워졌습니다.
하지만, 이를 모르고 직접 SseEmitter 을 통해 PlayMCP와 무한 연결 및 정보를 보내는 PUSH 방법을 구현했습니다.
@RestController
@RequestMapping("/legacy")
@RequiredArgsConstructor
public class McpController {
private final ServerDoctorService serverDoctorService;
private final LogService logService;
private final ServerRegisterService serverRegisterService;
private final ObjectMapper objectMapper = new ObjectMapper();
// 현재 활성화된 단일 Emitter 관리
private final ConcurrentMap<String, SseEmitter> emitters = new ConcurrentHashMap<>();
/**
* MCP 가 접속하는 EndPoint
*/
@CrossOrigin(origins = "*")
@RequestMapping(
value = "/sse",
method = {RequestMethod.GET, RequestMethod.POST},
produces = MediaType.TEXT_EVENT_STREAM_VALUE
)
public ResponseEntity<SseEmitter> connect(@RequestBody(required = false) String body) {
String sessionId = UUID.randomUUID().toString();
SseEmitter emitter = new SseEmitter(0L); // 0L = no timeout (Spring 관례)
emitters.put(sessionId, emitter);
emitter.onCompletion(() -> emitters.remove(sessionId));
emitter.onTimeout(() -> emitters.remove(sessionId));
emitter.onError((e) -> emitters.remove(sessionId));
log.info("🔌 PlayMCP 연결됨 (Session ID: {})", sessionId);
// 2. 비동기 스레드에서 이벤트 및 초기화 메시지 처리
new Thread(() -> {
try {
emitter.send(SseEmitter.event().name("endpoint").data("messages?sessionId=" + sessionId));
log.info("✅ Endpoint 이벤트 전송 완료");
// 요청 Body에 'initialize' 메시지가 있었다면 즉시 처리
if (body != null && !body.isEmpty() && !body.equals("{}")) {
log.info("📩 연결 요청에 포함된 메시지 처리 중...");
handleMessage(body, sessionId); // 기존 handleMessage 메서드 재사용
}
} catch (ClientAbortException e) {
log.warn("❌ 클라이언트가 연결을 끊음 (Endpoint 전송 중)");
}
catch (Exception e) {
log.error("❌ 초기 이벤트 또는 메시지 처리 실패", e);
emitters.remove(sessionId);
emitter.completeWithError(e);
}
}).start();
return ResponseEntity.ok()
.header("X-Accel-Buffering", "no")
.header("Cache-Control", "no-cache, no-transform") // 캐싱 방지
.contentType(MediaType.TEXT_EVENT_STREAM)
.body(emitter);
}
/**
* MCP 가 명령을 보내는 Endpoint
*/
@PostMapping("/messages")
@CrossOrigin(origins = "*")
public ResponseEntity<Void> handleMessage(@RequestBody String jsonBody, @RequestParam("sessionId") String sessionId) {
try {
SseEmitter emitter = emitters.get(sessionId);
JsonNode request = objectMapper.readTree(jsonBody);
String method = request.path("method").asText();
JsonNode idNode = request.get("id");
if (emitter == null) {
log.warn("⚠️ 연결된 클라이언트가 없습니다. 요청 무시됨.");
return ResponseEntity.notFound().build();
}
log.info("📩 MCP 요청 수신: {}", method);
switch (method) {
case "initialize":
JsonNode params = request.path("params");
handleInitialize(emitter, idNode, params);
break;
case "notifications/initialized":
log.info("🚀 PlayMCP 초기화 완료됨.");
break;
case "tools/list":
handleToolsList(emitter, idNode);
break;
case "tools/call":
handleToolsCall(emitter, idNode, request);
break;
case "ping":
sendJsonRpcResponse(emitter, idNode, "pong");
break;
default:
log.warn("❓ 알 수 없는 메서드: {}", method);
break;
}
return ResponseEntity.accepted().build();
} catch (Exception e) {
log.error("❌ 메시지 처리 중 오류 발생 (무시함): {}", e.getMessage());
return ResponseEntity.internalServerError().build();
}
}
/**
* 초기화 요청 메소드
*/
private void handleInitialize(SseEmitter emitter, JsonNode id, JsonNode params) {
String clientVersion = params.path("protocolVersion").asText("2025-03-26");
log.info("Client Protocol Version: {}", clientVersion);
Map<String, Object> result = new HashMap<>();
result.put("protocolVersion", clientVersion);
result.put("capabilities", Map.of(
"tools", Map.of("listChanged", true)
));
result.put("serverInfo", Map.of(
"name", "SpringServerDoctor",
"version", "1.0.0"
));
sendJsonRpcResponse(emitter, id, result);
}
// [핸들러] 도구 목록 제공 (여기에 4가지 도구 정의)
private void handleToolsList(SseEmitter emitter, JsonNode id) {
// 도구 목록 정의
...
}
// ========================================================================
// 4. JSON-RPC 응답 전송 헬퍼
// ========================================================================
private void sendJsonRpcResponse(SseEmitter emitter, JsonNode requestId, Object result) {
...
}
}
/legacy/mcp 경로로 접속하면 MCP 서버와 LLM 서버는 SSE 연결이 되며 Tool List, MCP 메타 정보를 넘겨줍니다.
여기서
JSON응답을 직접 파싱하고 넣어서 보내는 수작업을 했습니다.
MCP의 기본을 직접 구현했지만 문제가 발생했습니다.
해당 방법으로 Claude Code에서 잘 작동했으나 PlayMCP에 엔드포인트 등록에서 원인을 알 수 없는 PlayMCP 서버 오류를 접했습니다.
MCP 서버에는 요청이 잘 들어와 연결을 수립했지만 PlayMCP 서버는 오류를 원인을 알 수 없는 오류를 반환했습니다.
MCP 표준을 찾아보니 원인을 대략 알 수 있었습니다.
Streamable HTTP로 SSE HTTP 방식은 레거시 취급.Claude code 는 레거시 방법도 허용하고 표준 방법도 허용하므로 잘 작동함.해당 근거를 토대로 JAVA에서 표준 방법대로 리펙토링 하기로 결정했습니다.
MCP를 위한 라이브러리가 존재했습니다.
제가 직접 구현할 필요 없이 라이브러리를 통해 쉽게 개발 할 수 있었습니다.
덕분에 4일을 날렸네요.
@Component
@RequiredArgsConstructor
public class ServerDoctorMcpTools {
private final ServerDoctorService serverDoctorService;
private final LogService logService;
private final ServerRegisterService serverRegisterService;
private final HealthService healthService;
/**
* 데모 서버 테스트용 tool
*/
@McpTool(
name = "ServerDoctor-list_demo_servers",
description = "PlayMCP 원격 환경에서 즉시 체험 가능한 데모 서버 목록을 반환합니다. 실제 사용자 서버 목록은 반환하지 않습니다."
)
public String listDemoServers() {
String[] demos = {"demo", "target"};
StringBuilder sb = new StringBuilder();
sb.append("즉시 체험 가능한 데모 서버 목록입니다.\n")
.append("""
아래 serverName으로 get_health_status / diagnose_server 를 호출해 보세요.
(demo 서버는 현재 실행되고 있는 서버, target 서버는 현재 실행중이 아닌 서버입니다.)
""");
for (String s : demos) {
sb.append("=== ").append(s).append(" ===\n");
sb.append(healthService.getHealthStatusForMcp(s)).append("\n\n");
}
return sb.toString();
}
@McpTool(
name = "ServerDoctor-diagnose_server",
description = "대상 서버의 최근 에러 로그와 리소스 상태를 조회하여 종합적으로 분석합니다."
)
public String diagnoseServer(
@McpToolParam(description = "진단할 서버 이름") String serverName
) {
return serverDoctorService.diagnoseForMcp(serverName);
}
@McpTool(
name = "ServerDoctor-fetch_error_logs",
description = "서버에서 최근 발생한 에러 로그들을 조회 후 분석합니다."
)
public String fetchErrorLogs(
@McpToolParam(description = "대상 서버 이름") String serverName
) {
ErrorLogAnalysisDto logs = logService.analyzeErrorLogs(serverName);
return (logs.getErrorCount() == 0) ? "발견된 에러 로그가 없습니다." : logs.toString();
}
@McpTool(
name = "ServerDoctor-register_server",
description = "모니터링할 새로운 대상 서버를 등록하고, 연동 가이드(yml, env 등)를 생성합니다."
)
public String registerServer(
@McpToolParam(description = "서버 고유 이름") String serverName
) {
RegisterServerRequest req = new RegisterServerRequest(serverName);
RegisterServerResponse res = serverRegisterService.registerServer(req);
return String.format(
"✅ 서버 [%s]가 성공적으로 등록되었습니다. (IngestToken: %s)\n서버 가이드:\n%s",
serverName, res.getIngestToken(), res.getGuide()
);
}
@McpTool(
name = "ServerDoctor-get_health_status",
description = "서버의 현재 Health 상태(UP/DOWN), 마지막 체크 시각, latency, stale 여부(60초 기준)를 반환합니다."
)
public String getHealthStatus(
@McpToolParam(description = "대상 서버 이름") String serverName
) {
return healthService.getHealthStatusForMcp(serverName);
}
@McpTool(
name = "ServerDoctor-get_setup_guide",
description = "서버 모니터링을 시작하는 방법을 보여줍니다. Application.yml, docker-compose.yml, .env 설정에 대해 알려줍니다."
)
public String getSetupGuide() {
return serverRegisterService.generateSetupGuide(null, null);
}
}
연결 과정은 전부 라이브러리에 맡기고 비즈니스 로직에 집중할 수 있었습니다.
@McpTool 을 통해 LLM이 사용할 수 있는 도구를 정의하면 Tool List에 넣어서 LLM에게 전달됩니다.
서버 모니터링을 위한 Tool 4개와 시작 방법, demo 서버 테스트를 포함해 총 6개의 도구를 만들었습니다.
<모니터링 도구>
이렇게 4개를 만들었습니다.
프로토콜 연결 과정은 성공했으니 실제 로직을 알아봅시다.
MCP 서버 -> LLM 연결 과정은 Streamable HTTP를 이용했고
Forwarder -> MCP 서버 연결은 REST HTTP를 이용했습니다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class IngestController {
private final MetricService metricService;
private final LogService logService;
private final HealthService healthService;
// 로그 수신(PUSH): 포워더/사용자 서버가 호출
@PostMapping("/servers/{name}/ingest/logs")
public IngestResultDto ingestLogs(@PathVariable("name") String serverName,
@RequestHeader("X-MCP-TOKEN") String token,
@RequestBody List<LogEventDto> events,
@RequestHeader(value = "X-DISCORD-WEBHOOK-URL", required = false) String discordWebhookUrl) {
return logService.ingestLogs(serverName, token, discordWebhookUrl, events);
}
@PostMapping("/servers/{serverName}/ingest/metrics")
public ResponseEntity<String> ingestMetrics(
@PathVariable String serverName,
@RequestHeader("X-MCP-TOKEN") String token,
@RequestHeader(value = "X-DISCORD-WEBHOOK-URL", required = false) String discordWebhookUrl,
@RequestBody MetricIngestDto dto) {
metricService.saveMetric(serverName, dto, token, discordWebhookUrl);
return ResponseEntity.ok("ok");
}
@PostMapping("/servers/{serverName}/ingest/health")
public ResponseEntity<String> ingestHealth(
@PathVariable String serverName,
@RequestHeader("X-MCP-TOKEN") String token,
@RequestBody HealthIngestDto dto) {
healthService.saveHealth(serverName, dto, token);
return ResponseEntity.ok("ok");
}
}
Forwarder는 해당 엔드포인트로 정보를 보냅니다.
그리고 service 로직을 통해 DB에 저장 후 LLM이 요청하면 보내줍니다.

요약하면 해당 그림처럼 나옵니다!
public String diagnoseForMcp(String serverName) {
// 1. 데이터 수집
ErrorLogAnalysisDto logAnalysis = logService.analyzeErrorLogs(serverName);
String metricTrend = metricService.getMetricTrend(serverName);
// 2. LLM(Claude)이 읽기 좋은 형태로 Raw Data 포맷팅
StringBuilder sb = new StringBuilder();
sb.append(String.format("### 📊 서버 진단 데이터 (Server: %s)\n\n", serverName));
// 리소스 상태
sb.append("**1. CPU/RAM 상태:**\n").append(metricTrend).append("\n\n");
// 에러 로그
sb.append("**2. 최근 에러 로그 분석:**\n");
sb.append("- 총 에러 수: ").append(logAnalysis.getErrorCount()).append("건\n");
if (logAnalysis.getErrorCount() > 0) {
sb.append("- 주요 로그 내역:\n```text\n");
// 로그 원문을 그대로 Claude에게 전달 (토큰 제한 고려하여 적당히 자르기)
sb.append(logAnalysis.getRecentErrors().stream()
.limit(15)
.collect(Collectors.joining("\n")));
sb.append("\n```\n");
} else {
sb.append("- 특이사항: 발견된 에러 로그 없음 (Healthy)\n");
}
return sb.toString();
}
위 로직은 도구 6개중 1개인 종합 서버 진단 서비스 입니다.
로그와 Metrics 정보를 가져와 에러 로그 + 서버 리소스 를 반환합니다.
반환된 값은
LLM이 해석하여 사용자에게 답변합니다.

사용자 서버 정보를 저장하는 Target_Server 테이블을 기점으로
log, health check, metrics 저장 테이블을 만들었습니다.
약 16일동안 기획 + 개발(삽질하느라 4일 소요) + 배포 + 심사(4일 소요) 을 하며 마무리했습니다.
처음에 MCP 가 뭔지도 모르고 시작해서 많이 애먹었습니다.
또, PlayMCP 등록을 통해 Claude Code, ChatGPT도 자동으로 가능한지 모르고 LLM 각각 직접 등록으로 착각하여 시간도 뺐기고 쉽지 않았습니다.
그래도 이번 기회를 통해 MCP의 작동원리 및 실제로 도구도 만들어보며 저도 개발에 있어서 MCP를 적극 이용해볼 예정입니다!
곧, 개발 대회에 제
MCP를 이용하여 생산성을 높힐 계획입니다.
언제까지 서버를 올려둘지 미지수지만 방문해서 이용해주시면 감사하겠습니다!