MCP 서버 에러 핸들링: 1,800시간 분량의 지식 베이스를 위한 프로덕션 MCP 서버를 구축하며 배운 점
요약
실제 프로덕션 환경에서 MCP(Model Context Protocol) 서버를 운영하며 겪은 에러 핸들링 경험을 공유합니다. 빈 응답 처리와 타임아웃 문제 등 일반적인 튜토리얼에서 다루지 않는 실전적인 디버깅 노하우를 다룹니다.
핵심 포인트
- 결과가 없는 경우에도 빈 응답 대신 설명이 포함된 콘텐츠를 반환해야 함
- MCP 클라이언트가 요청 실패로 인식하지 않도록 적절한 메시지 구성 필요
- 해피 패스(Happy Path) 외의 예외 상황에 대한 에러 핸들링의 중요성
MCP 서버 에러 핸들링 (Error Handling): 1,800시간 분량의 지식 베이스를 위한 프로덕션 MCP 서버를 구축하며 배운 점
솔직히 말해서, MCP 서버를 실행시킨 후에는 모든 게 끝났다고 생각했습니다. 여러분도 그 기분을 아실 겁니다. /tools/list가 응답하고, tools/call이 텍스트를 반환하면, 그날 일은 다 했다고 생각하며 블로그 포스트를 쓰는 그런 기분 말이죠.
제 생각이 틀렸습니다.
상황은 이렇습니다. 저는 몇 주 동안 제 MCP 서버를 프로덕션(음, "프로덕션"이라고 하기엔 사용하지 않을 때 잠드는 무료 Heroku dyno이지만, 그래도 프로덕션입니다) 환경에서 운영해 왔는데, 에러 핸들링 (Error Handling)이야말로 대부분의 MCP 튜토리얼이 놓치고 있는 부분이라는 것을 깨달았습니다. 모두가 해피 패스 (Happy Path)만을 보여줍니다. 상황이 잘못되었을 때 어떤 일이 발생하는지에 대해서는 아무도 이야기하지 않습니다.
제 1,800시간 분량의 지식 베이스 프로젝트인 Papers의 실제 코드를 통해, 제가 고생하며 배운 것들을 공유하고자 합니다. 만약 여러분이 지금 MCP 서버를 구축하고 있다면, 이 글이 AI 클라이언트가 왜 계속 연결이 끊기는지 디버깅하느라 밤을 지새우는 시간을 줄여줄 수도 있습니다.
초심자를 위한 프로젝트 배경
처음 접하시는 분들을 위해 설명하자면, 저는 6년 동안 이 지식 베이스를 구축해 왔습니다. AI 기반의 모든 것을 위한 시맨틱 검색 (Semantic Search)이라는 원대한 야망으로 시작했습니다. 결과적으로 2,847개의 저장된 기사, 1,847시간의 개발, 그리고 약 2.9%라는 실제 사용률을 얻게 되었습니다. 그리 좋지는 않죠.
그러다 Model Context Protocol (MCP)이 등장했고, 모든 것이 바뀌었습니다. 지식 베이스를 똑똑하게 만들려고 노력하는 대신, 단순히 이를 AI 어시스턴트를 위한 MCP 도구 (Tools)로 노출하기 시작했습니다. 이제 제가 작업할 때 Claude가 관련 기사들을 컨텍스트 (Context)로 자동으로 가져옵니다. 완벽합니다.
하지만 실제로 매일 사용하기 시작했을 때, 기본적인 예제들로는 전혀 대비되지 않았던 에러들이 연이어 발생했습니다.
첫 번째 문제: 빈 응답이 모든 것을 망가뜨린다
제가 맞닥뜨린 첫 번째 문제는 누군가 제 지식 베이스에 없는 주제에 대해 물었을 때였습니다. 제 코드는 빈 결과 리스트를 반환했고... 아무 일도 일어나지 않았습니다. AI 클라이언트는 그냥 로딩 중인 상태로 멈춰 있었습니다.
제가 처음에 했던 방식은 다음과 같습니다:
@PostMapping("/mcp/tools/call")
public McpResponse callTool(@RequestBody McpRequest request) {
String query = request.getParams().getQuery();
...
여기서 무엇이 잘못되었는지 추측할 수 있나요? MCP 클라이언트는 _무언가_를 기대합니다. 결과가 없더라도, 그 사실을 설명하는 적절한 콘텐츠 메시지를 보내야 합니다. 대부분의 클라이언트에게 빈 응답은 요청 실패로 보입니다.
해결 방법은 다음과 같습니다:
@PostMapping("/mcp/tools/call")
public McpResponse callTool(@RequestBody McpRequest request) {
String query = request.getParams().getQuery();
...
지나고 보니 당연해 보이죠? 하지만 제가 읽은 그 어떤 예제에서도 이 점을 언급하지 않았습니다. 그들은 모두 결과가 있다는 것을 전제로 합니다. 교훈을 얻었습니다: 제공할 내용이 없을 때라도 항상 사람이 읽을 수 있는 콘텐츠를 반환해야 합니다.
두 번째 문제: 연결을 끊어버리는 타임아웃 (Timeout)
제 지식 베이스(Knowledge Base)는 무료 Heroku 다이노(dyno)에 있습니다. 한동안 사용하지 않았다면 깨어나는 데 시간이 필요합니다. 그 과정은 10~15초 정도 걸릴 수 있습니다.
결과가 어떨 것 같나요? 대부분의 MCP 클라이언트는 이보다 훨씬 짧은 기본 타임아웃(Timeout) 설정을 가지고 있습니다. 저의 첫 번째 구현 방식은 청킹(Chunking)도, 스트리밍(Streaming)도 없이—그저 전체 응답을 한 번에 전송하는 방식이었습니다. 만약 다이노가 콜드 스타트(Cold start) 상태였다면, 데이터가 전송되기도 전에 연결이 타임아웃되었습니다.
이번에도 기초적인 예제들에는 이에 대한 내용이 전혀 없었습니다. 그래서 저는 두 가지를 구현했습니다:
1. 느린 작업에 대한 조기 확인 (Early Acknowledgment)
@PostMapping("/mcp/tools/call")
public ResponseEntity<McpResponse> callTool(
@RequestBody McpRequest request,
...
2. 크기 제한을 통한 클라이언트 타임아웃 준수
응답 크기에 대한 엄격한 제한도 추가했습니다. 결과가 너무 많으면, 타임아웃에 걸릴 수 있는 50KB의 콘텐츠를 보내는 대신 내용을 잘라내고(Truncate) 사용자에게 검색 범위를 좁히라고 안내합니다:
private String formatResults(List<Article> results) {
StringBuilder sb = new StringBuilder();
int count = 0;
...
이것은 게임 체인저(Game-changer)가 되었습니다. 이전에는 대규모 검색이 그냥 타임아웃되어 실패하곤 했습니다. 이제는 사용자에게 부분적인 결과와 가이드를 제공합니다. 훨씬 좋아졌습니다.
세 번째 문제: 잘못된 형식의 JSON (Malformed JSON) = 침묵하는 실패 (Silent Failure)
저는 어처구니없는 실수를 했습니다. 제가 작성한 기사 제목 중 하나에 큰따옴표(")가 포함되어 있었는데, JSON에서 이를 제대로 이스케이프 (escape) 처리하는 것을 잊어버렸습니다.
어떤 일이 발생했을까요? 응답 전체가 유효하지 않은 JSON이 되었습니다. MCP 클라이언트는 아무런 메시지 없이 그냥 조용히 연결을 끊어버렸습니다. 에러 메시지도, 아무것도 없었습니다. 그냥... 아무것도 없었습니다. 실제 문제는 이스케이프 처리되지 않은 문자 하나뿐인데, 서버가 왜 응답하지 않는지 알아내기 위해 한 시간을 허비하게 됩니다.
제가 이를 해결한 방법은 다음과 같습니다. JSON을 수동으로 빌드하는 것을 멈추세요. 프레임워크가 대신 처리하게 하세요:
이전 (나쁜 방식, 이렇게 하지 마세요):
// 이렇게 하지 마세요! 여러분은 그러지 않도록 제가 대신 해봤습니다.
public String badIdea(McpRequest request) {
return "{\"result\": \"" + article.getTitle() + "\"}"; // 제목에 "가 있으면, 쾅.
...
이후 (좋은 방식):
// 프레임워크의 직렬화 (serialization)를 사용하세요 - 이스케이프 처리를 올바르게 수행합니다.
@PostMapping("/mcp/tools/call")
public McpResponse callTool(@RequestBody McpRequest request) {
...
알고 있습니다, 알고 있어요. 이건 아주 기초적인 Java 지식입니다. 하지만 MCP 서버를 작동시키기 위해 서두르다 보면, 문자열을 수동으로 빠르게 직렬화하고 싶은 유혹에 빠지기 쉽습니다. 그러지 마세요. 지금 아끼는 5분이 나중에 3시간의 디버깅(debugging)으로 돌아옵니다. 제 말을 믿으세요.
네 번째 문제: 인증 (Authentication)은 생각보다 까다롭습니다
저는 API 키 인증을 추가하는 것이 간단할 것이라고 생각했습니다. 클라이언트 설정에 Authorization: Bearer <key> 헤더를 넣고, 서버에서 이를 확인하면 끝이라고 생각했죠.
또 틀렸습니다. 서로 다른 MCP 클라이언트들은 헤더를 다르게 처리합니다. 어떤 클라이언트는 쿼리 파라미터 (query params)에 키가 있기를 기대합니다. 어떤 클라이언트는 커스텀 헤더를 전혀 지원하지 않기도 합니다 (초기 구현체들이 그렇습니다). 어떤 클라이언트는 특정 형식을 요구하기도 합니다.
제가 테스트해 본 대부분의 클라이언트에서 작동하도록 최종적으로 구현한 방식은 다음과 같습니다:
@Component
public class McpAuthFilter extends OncePerRequestFilter {
...
중요한 점은 단순히 여러 곳을 시도하는 것이 아니라, 클라이언트가 파싱할 수 있는 적절한 JSON 에러 응답을 반환하는 것입니다. 이를 추가하기 전에는 본문(body) 없이 401 상태 코드만 보냈고, 클라이언트는 무엇이 잘못되었는지 전혀 알 수 없었습니다.
다섯 번째 문제: Content Length 혼동 (네, 정말입니다)
이 문제는 디버깅하는 데 정말 오랜 시간이 걸렸습니다. 때로는 응답이 잘리기도(truncated) 했고, 때로는 문제없이 작동하기도 했습니다. 완전히 일관성이 없었습니다.
알고 보니... 저는 압축(compression)이 활성화된 Spring Boot를 사용하고 있었고, 일부 MCP 클라이언트들은 콘텐츠 길이(content length)를 알 수 없을 때 Transfer-Encoding: chunked를 올바르게 처리하지 못했습니다.
잠깐, 뭐라고요? 네, 저도 그랬습니다. 저는 모든 현대적인 HTTP 클라이언트가 청크 인코딩(chunked encoding)을 잘 처리할 것이라고 생각했습니다. 일부 MCP 구현체들은 아직 초기 단계이며, 모든 예외 케이스(edge cases)가 처리되어 있지는 않습니다.
해결책은 무엇일까요? MCP 엔드포인트에서 응답 크기를 미리 계산하고, 가능한 경우 Content-Length 헤더를 명시적으로 설정하십시오:
@PostMapping("/mcp/tools/call")
public ResponseEntity<String> callTool(@RequestBody McpRequest request) {
McpResponse response = service.process(request);
...
그게 전부입니다. 명시적인 콘텐츠 길이를 추가한 이후로, 응답이 잘리는 현상은 단 한 번도 발생하지 않았습니다. 결국 사람을 힘들게 하는 건 이런 사소한 것들이죠, 그렇지 않나요?
제가 구축한 시스템의 솔직한 장단점
매일 MCP 서버를 운영한 지 몇 주가 지난 지금, 솔직하게 분석해 보겠습니다:
장점 ✅
-
기대했던 것보다 실제로 더 잘 작동합니다 — 모든 에러 핸들링 (Error handling)을 제대로 설정하고 나면, Claude가 알아서 관련 컨텍스트 (Context)를 가져옵니다. 마치 마법처럼 느껴집니다. 이제 이것 없이 작업하는 것은 상상할 수 없습니다.
-
개인정보 보호가 놀랍습니다 — 저의 모든 개인적인 노트는 제 서버에 그대로 머뭅니다. 현재 쿼리 (Query)에 필요한 특정 스니펫 (Snippet)만 AI로 전송됩니다. 제 지식 베이스 전체를 제3자 서버에 업로드할 필요가 없습니다. 저에게는 매우 큰 이점입니다.
-
표준 프로토콜은 상호 운용성을 의미합니다 — 세 가지 서로 다른 MCP 클라이언트 (Client)로 제 MCP 서버를 테스트해 보았는데, 그냥 바로 작동합니다. 클라이언트를 바꿔도 서버를 수정할 필요가 없습니다. 그것이 표준의 핵심이며, 표준은 그 역할을 충실히 수행합니다.
-
단순한 아키텍처는 지속 가능합니다 — 제 MCP 서버 전체 코드는 약 150줄 정도입니다. 그게 전부입니다. MCP 이전에는 아무도 사용하지 않는 2,000줄의 AI 검색 로직이 있었습니다. 단순할수록 유지보수가 쉽습니다.
단점 ❌
-
생태계가 아직 초기 단계입니다 — 모든 클라이언트가 모든 MCP 기능을 지원하는 것은 아닙니다. 에러 핸들링 (Error handling) 방식도 제각각입니다. 문서에 나와 있지 않은 엣지 케이스 (Edge case)를 마주하게 될 것입니다. 디버깅 (Debugging)할 준비를 하세요.
-
호스팅은 여전히 사용자의 책임입니다 — 대부분의 클라이언트가 연결되려면 공개적으로 접근 가능한 엔드포인트 (Endpoint)가 필요합니다. 로컬 개발은 ngrok으로 가능하지만, 24시간 가용성을 확보하려면 실제 호스팅이 필요합니다. 저처럼 무료 티어를 사용한다면 콜드 스타트 (Cold start)가 실제적인 문제가 됩니다.
-
내장된 관측성 (Observability) 기능이 없습니다 — MCP는 로깅 (Logging), 모니터링 (Monitoring), 또는 속도 제한 (Rate limiting)에 대해 아무것도 명시하지 않습니다. 이 모든 것을 직접 구축해야 합니다. 개인 프로젝트라면 괜찮지만, 규모가 커지면 더 많은 작업이 필요합니다.
-
여전히 많은 파괴적 변경 (Breaking changes)이 있습니다 — 프로토콜은 여전히 진화 중입니다. 스펙 (Spec) 변경 때문에 이미 구현 내용을 세 번이나 업데이트해야 했습니다. 프로덕션 (Production) 환경의 결과물을 만들고 있다면, 계속해서 따라갈 준비를 해야 합니다.
다시 한다면 할 것인가?
솔직히 말씀드리면? 네, 그렇습니다. 방금 말씀드린 그 모든 골칫거리들을 겪었음에도 불구하고 말이죠. MCP는 제가 "실패"했던 1,800시간 분량의 지식 베이스를 매일 실제로 유용하게 사용할 수 있는 무언가로 탈바꿈시켜 주었습니다.
MCP를 도입하기 전에는 6개월 전에 작성했던 주제에 대한 글이 있다는 사실조차 잊어버리곤 했습니다. 하지만 이제는 관련 문제를 해결할 때 Claude가 자동으로 그 글들을 불러옵니다. 마치 제가 이미 배운 모든 것을 실제로 알고 있는 비서를 둔 것과 같습니다.
에러 핸들링 (Error handling) 과정에서의 골칫거리들은 대부분 성장통이었습니다. 이제 그 문제들을 해결하고 나니 시스템은 매우 견고해졌습니다. 그리고 더 큰 교훈은 — 특히 AI 시대에는 단순한 표준 (Standards)이 복잡하고 "똑똑한" 시스템을 언제나 이긴다는 점입니다. 이제 여러분의 역할은 더 이상 똑똑해지는 것이 아닙니다. AI가 데이터를 활용해 똑똑해질 수 있도록, 표준 인터페이스 (Standard interfaces)를 통해 데이터를 사용할 수 있게 만드는 것입니다.
여러분의 차례
여러분은 이미 MCP 서버를 구축해 보셨나요? 제가 여기서 다루지 않은 이상한 에러 핸들링 문제를 겪으셨나요? 아니면 구축을 고민 중인데 아직 궁금한 점이 있으신가요? 아래에 댓글을 남겨주세요. 여러분이 무엇을 만들고 있는지, 어떤 문제에 부딪히고 있는지 정말 듣고 싶습니다.
저 또한 여전히 이 분야를 배우는 중이므로, 만약 MCP 서버에서 더 나은 에러 핸들링을 위한 비결을 알고 계신다면 공유해 주세요. 서로의 실수로부터 배우는 것은 우리 모두에게 도움이 됩니다.
그리고 제 MCP 서버의 전체 코드를 보고 싶다면, GitHub 프로젝트를 확인해 보세요. 모든 것은 오픈 소스(Open source)이므로, 여러분의 MCP 프로젝트에 필요한 것이 있다면 무엇이든 자유롭게 가져가셔도 좋습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기