본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 25. 23:37

MCP Content-Length: Chunked Encoding이 MCP 서버를 망가뜨린 이유와 91번의 운영 장애 후 해결 방법

요약

MCP 서버 운영 중 Chunked Encoding과 Content-Length 헤더 부재로 인해 발생한 91번의 장애 사례와 해결 방법을 다룹니다. 스트리밍 응답 시 클라이언트와 프록시가 응답 종료를 인식하지 못해 발생하는 타임아웃 및 데이터 유실 문제를 분석합니다.

핵심 포인트

  • MCP의 text/event-stream 사용 시 Chunked Encoding 발생
  • Content-Length 헤더 부재 시 클라이언트의 무한 대기(Hang) 현상 발생
  • 프록시(Nginx 등) 및 클라이언트의 버퍼링 방식 차이로 인한 데이터 유실
  • 운영 환경에서 스트리밍 응답의 안정적인 종료 처리가 중요함

MCP Content-Length: Chunked Encoding이 MCP 서버를 망가뜨린 이유와 91번의 운영 장애 후 해결 방법

솔직히 말해서, 저는 MCP 운영 이슈를 모두 파악했다고 생각했습니다.

91번의 장애, 그리고 타임아웃(timeouts), 연결(connections), 캐싱(caching), 검증(validation), 로깅(logging), 상태 확인(health checks) 등 상상할 수 있는 모든 문제에 관한 89개의 글을 작성한 후, 저는 꽤 자신감이 붙어 있었습니다. 그러다 Chunked Encoding이 저를 덮쳤습니다. 3일간의 디버깅 끝에 저는 뼈아픈 교훈을 얻었습니다: 정확히 무엇을 하고 있는지 알지 못한다면, MCP와 Chunked Encoding은 서로 잘 맞지 않습니다.

제가 낭비한 3일을 여러분은 아낄 수 있도록 해드리겠습니다.

배경 이야기: 도대체 문제가 무엇인가?

만약 여러분이 Spring Boot(또는 사실상 어떤 Java 프레임워크든)로 MCP 서버를 구축하고 있다면, Content-Length와 Chunked Encoding에 대해 아마 생각하지 않을 것입니다. Spring Boot가 자동으로 처리해주니까요, 그렇지 않나요?

저도 그렇게 생각했습니다.

MCP는 tools/call 엔드포인트에 text/event-stream을 사용합니다. 서버가 콘텐츠를 생성할 때마다 이벤트를 전송합니다. Spring Boot는 스트리밍 응답(streaming responses)을 위해 자동으로 Chunked Encoding을 사용합니다. 콘텐츠 길이를 미리 알 수 없기 때문에 청크(chunk) 단위로 전송하는 것이죠. 이는 논리적입니다.

하지만 문제는 이것입니다: 많은 MCP 클라이언트(및 프록시)는 Content-Length 헤더를 정말, 정말로 원한다는 것입니다. 그리고 이 헤더를 받지 못하면 이상한 일들이 발생합니다.

저는 다음과 같은 무작위적인 문제들을 목격하기 시작했습니다:

  • 30%의 확률로, 클라이언트가 마지막 이벤트 이후 영원히 대기 상태(hang)에 빠짐
  • 때때로 부분적인 응답을 받음 — 마지막 몇 백 개의 토큰이 그냥 사라져 버림
  • 서버는 모든 것을 보냈음에도 불구하고 Claude Desktop에서 "stream ended unexpectedly"라는 메시지가 표시됨
  • Nginx가 스트림 중간에 연결을 끊어버리는 경우가 있음
  • 로컬에서는 100% 완벽하게 작동했지만, 운영 환경에서는 무작위로 실패함

익숙한 상황인가요? 계속 읽어보세요.

왜 이런 일이 발생하는가: 세 가지 고통의 계층

이 문제가 왜 이렇게 미묘한 문제인지 분석해 보겠습니다. MCP는 상당히 구체적인 스트리밍 패턴을 가지고 있습니다:

클라이언트 연결 → 서버 처리 → 서버가 여러 개의 SSE 이벤트 전송 → 서버가 연결 종료

문제는 스트리밍 그 자체가 아닙니다. 서로 다른 프록시(Proxy)와 클라이언트가 알 수 없는 콘텐츠 길이(Content-Length)를 처리하는 방식에 문제가 있습니다.

계층 1: 클라이언트의 기대 (The Client Expectation)

많은 MCP 클라이언트들은 응답을 처리하기 전에 전체 응답을 버퍼링(Buffering)합니다. 만약 Content-Length가 없다면, 이들은 응답이 언제 끝나는지 알 수 없습니다. 그저 계속 기다릴 뿐입니다.

Claude Desktop은 사실 이 부분에 대해 꽤 잘 대처하지만, 제가 테스트한 일부 서드파티(Third-party) MCP 클라이언트들은 그냥 영원히 멈춰버리곤(Hang) 했습니다. 이들은 연결이 닫힐 때까지 계속 읽으려고 시도하지만, 청크 인코딩(Chunked encoding) 방식에서는 연결이 닫히는 것이 완료 신호입니다. 잠깐 — 그러면 작동해야 하는 것 아닌가요? 바로 여기서 계층 2가 여러분을 괴롭히기 시작합니다.

계층 2: 프록시 버퍼링 문제 (The Proxy Buffering Problem)

만약 Nginx(또는 Cloudflare, Fly.io의 프록시, 혹은 그 어떤 리버스 프록시(Reverse proxy)) 뒤에서 실행 중이라면, 프록시의 기본 설정이 문제를 일으킬 수 있습니다.

Nginx는 기본적으로 응답을 버퍼링합니다. 만약 Nginx가 청크 인코딩을 버퍼링하고 있는데 콘텐츠 길이(Content-Length)를 모른다면... 글쎄요, 그냥 계속 버퍼링만 하게 됩니다. 때로는 연결이 닫힐 때까지 버퍼를 플러시(Flush)하지 않기도 합니다. 하지만 연결은 모든 데이터가 전송될 때까지 닫히지 않습니다. 이는 데드락(Deadlock, 교착 상태)입니다.

농담이 아닙니다. 실제로 저에게 일어난 일입니다. 로컬 환경에서 Nginx 없이 실행했을 때는 완벽하게 작동했습니다. 하지만 운영 환경에서 Nginx 뒤에 두었더니, 세 번의 요청 중 한 번꼴로 무작위적인 멈춤 현상이 발생했습니다.

계층 3: 청크 크기 문제 (The Chunk Size Issue)

MCP는 작은 청크(Chunk)를 보냅니다. 각 이벤트는 보통 몇 백 바이트 정도입니다. 일부 프록시는 작은 청크를 즉시 플러시하지 않습니다. 데이터를 보내기 전에 버퍼가 가득 찰 때까지 기다립니다. 그래서 프록시가 응답의 절반을 여전히 메모리에 붙잡고 있는 동안, 사용자는 아무 일도 일어나지 않는 것을 지켜보며 계속 기다리게 됩니다.

3일간의 디버깅 끝에, 저는 마침내 이 세 가지 문제를 모두 해결하는 깔끔한 해결책을 찾아냈습니다. 제가 정확히 무엇을 했는지 보여드리겠습니다.

해결책: MCP SSE를 위한 명시적 콘텐츠 길이 (Explicit Content-Length For MCP SSE)

MCP의 핵심은 이것입니다: 스트리밍을 시작하기 전에 전체 콘텐츠 길이(Total content length)를 이미 알고 있다는 점입니다.

잠깐, 정말인가요? 한번 생각해 봅시다. MCP tools/call 요청을 처리할 때 다음과 같은 과정을 거칩니다:

  1. 도구 호출 (tool call)을 처리합니다.
  2. 응답 텍스트를 생성합니다.
  3. 첫 번째 바이트를 보내기 전에 이미 메모리에 전체 응답을 가지고 있습니다.
  4. 그런 다음 SSE (Server-Sent Events) 이벤트로서 청크(chunk) 단위로 스트리밍합니다.

아! 맞습니다! 토큰이 생성될 때마다 토큰 단위로 스트리밍하는 ChatGPT와 달리, MCP에서는 서버가 모든 작업을 먼저 수행한 다음 결과를 스트리밍하여 다시 보냅니다. 따라서 이미 메모리에 전체 응답을 가지고 있습니다. 네트워크를 통해 전송될 바이트 수가 정확히 얼마인지 이미 알고 있는 것입니다.

이것이 모든 것을 바꿉니다. 우리는 사전에 정확한 Content-Length (콘텐츠 길이)를 계산하여 보낼 수 있습니다. 더 이상 청크 인코딩 (chunked encoding)이 필요하지 않습니다.

여러분의 프로젝트에 복사해서 붙여넣을 수 있는 저의 전체 Java Spring Boot 구현체입니다:

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
...

잠깐 — 이게 끝인가요? 이게 해결책의 전부인가요? 네. 진심입니다.

response.setContentLength()를 명시적으로 설정함으로써, 여러분은 Spring Boot에게 "청크 인코딩을 사용하지 마세요 — 제가 처리하겠습니다"라고 말하는 것입니다. 그러면 Spring Boot는 Content-Length 헤더를 전송하고, 모든 것이 원활하게 작동합니다.

필요한 Nginx 설정 수정 사항

명시적인 Content-Length를 설정하더라도, 버퍼링 (buffering) 문제를 피하기 위해서는 여전히 Nginx 설정을 조정해야 합니다. 제가 MCP 엔드포인트를 위해 nginx.conf의 location 블록에 작성한 내용은 다음과 같습니다:

location /mcp/ {
    proxy_pass http://localhost:8080;
    proxy_http_version 1.1;
...

여기서 핵심적인 라인은 다음과 같습니다:

proxy_buffering off;
proxy_buffers 0;

이 설정은 Nginx에게 "버퍼링하지 마세요 — 모든 것을 즉시 통과시키세요"라고 지시합니다. Content-Length가 있더라도, 버퍼링은 긴 응답에 대해 여전히 지연을 초래할 수 있습니다.

Cloudflare는 어떤가요?

MCP 서버 앞에 Cloudflare를 사용하고 있다면, 또 다른 주의 사항이 있습니다. Cloudflare의 "자동 HTTPS 재작성 (automatic HTTPS rewrites)" 기능이 때때로 청크 인코딩을 방해할 수 있습니다. 하지만 명시적인 Content-Length를 사용하면 이 문제는 완전히 사라집니다.

명시적인 Content-Length (콘텐츠 길이)로 전환한 이후로는 아무런 문제가 발생하지 않았습니다. Cloudflare (클라우드플레어)는 이를 정확하게 그대로 전달합니다.

장단점: 당신의 MCP 서버에 적합할까요?

솔직히 말씀드리면, 이 방식이 모든 상황에 적합한 것은 아닙니다. 명확하게 정리해 드리겠습니다:

✅ 장점

  1. 무작위로 발생하는 멈춤(hanging) 문제의 99%를 해결 — 운영 환경에서의 실패율이 30%에서 0%로 감소했습니다.
  2. 클라이언트 변경 불필요 — 기존의 모든 MCP 클라이언트와 호환됩니다.
  3. 코드 변경이 매우 적음 — 기존의 스트리밍 엔드포인트(streaming endpoint)와 비교했을 때 단 한 줄의 코드만 추가하면 됩니다.
  4. 프록시(Proxy) 친화적 — 모든 프록시는 Content-Length를 이해하므로, 더 이상 버퍼링 휴리스틱(buffering heuristics) 문제를 겪지 않아도 됩니다.
  5. 예측 가능한 실패 — 문제가 발생하더라도, 무한히 대기하는 대신 빠르게 실패(fail fast)합니다.

❌ 단점

  1. 전체 응답을 메모리에 버퍼링해야 함 — 만약 서버를 통해 LLM 스트리밍을 직접 전달하는 것처럼 토큰을 실시간으로 생성하는 경우에는 이 방식이 작동하지 않습니다.
  2. 추가적인 메모리 사용 — 예상되는 가장 큰 응답을 담을 수 있을 만큼 충분한 힙(heap) 메모리가 필요합니다.
  3. 첫 번째 바이트 도달 시간(Time-to-first-byte) 지연 — 사용자는 아무것도 보기 전에 전체 처리가 완료될 때까지 기다려야 합니다.

언제 이 방식을 사용해야 할까요?

이 방식을 사용해야 하는 경우:

  • MCP 서버가 작업을 먼저 완료한 후 결과를 스트리밍하는 경우 (예: 저의 지식 베이스 검색 방식)
  • 응답 크기가 일반적으로 100KB 미만인 경우 (대부분의 도구 호출(tool calls)이 이에 해당합니다)
  • 청크 인코딩 (chunked encoding) 사용 시 무작위 멈춤/연결 끊김 문제를 겪고 있는 경우

이 방식을 사용하지 말아야 하는 경우:

  • 상위 API(upstream API)로부터 LLM 토큰을 클라이언트로 직접 스트리밍하는 경우
  • 응답 크기가 정기적으로 1MB를 초과하는 경우
  • 긴 콘텐츠에 대해 즉각적인 첫 번째 바이트 렌더링을 원하는 경우

수치로 보는 결과: 실제로 도움이 되었을까?

저는 현재 이 방식을 운영 환경에서 3주 동안 실행하고 있으며, 하루에 약 50~100개의 MCP 요청을 처리하고 있습니다. 변화된 결과는 다음과 같습니다:

지표이전 (chunked)이후 (명시적 Content-Length)
실패한 요청28%0%
.........

실패율 0%. 저에게는 이것으로 충분합니다.

고생하며 배운 교훈

이 문제를 디버깅하며 사흘을 보낸 끝에 얻은 핵심 요점은 다음과 같습니다:

  1. MCP의 스트리밍 패턴은 LLM 스트리밍과 다릅니다 — 대부분의 MCP 서버는 먼저 처리한 후 나중에 스트리밍합니다. 이 점을 활용하세요.

  2. 기본 자동 설정(auto-config)은 도움이 되지 않습니다 — Spring Boot는 Content-Length를 설정하지 않으면 자동으로 chunked encoding을 선택하지만, 이는 MCP에서 원하는 방식이 아닙니다.

  3. 로컬 테스트로는 이를 잡아낼 수 없습니다 — 로컬에는 프록시(proxy)가 없으므로 모든 것이 정상 작동합니다. 문제는 프록시 뒤에 있는 운영 환경에서만 나타납니다. 이것이 제가 원인을 파악하는 데 그렇게 오래 걸린 이유입니다.

  4. 암시적인 것보다 명시적인 것이 더 낫습니다 — 응답의 크기가 정확히 얼마인지 모두에게 알려주면 프록시와 클라이언트의 모든 추측을 제거할 수 있습니다.

  5. 대부분의 "이상하고 무작위적인 불안정한 문제"는 간단한 해결책이 있습니다 — 복잡한 레이스 컨디션 (race condition)이 아니라, 버퍼링 (buffering)이나 헤더 (headers) 같은 아주 단순한 문제인 경우가 거의 대부분입니다.

피해야 할 일반적인 실수 (Common Gotchas)

여러분의 시간을 더 아껴드리겠습니다:

실수 1: 문자 인코딩 (character encoding)을 잊지 마세요

Content-Length를 계산할 때는 반드시 올바른 인코딩으로 바이트 (bytes) 변환을 마친 후에 계산해야 합니다. 저는 MCP가 기대하는 방식인 UTF-8을 모든 곳에서 사용합니다.

잘못된 예:

// 이것은 바이트 수가 아니라 문자 수를 반환합니다!
response.setContentLength(fullSseContent.length());

올바른 예:

byte[] bytes = fullSseContent.getBytes(StandardCharsets.UTF_8);
response.setContentLength(bytes.length);

저지르기 쉬운 실수입니다. Content-Length는 문자가 아니라 바이트 (bytes) 단위입니다. 멀티바이트 UTF-8 문자가 포함되어 있다면, 문자 수 ≠ 바이트 수가 됩니다.

실수 2: CORS 프리플라이트 (preflight)는 여전히 특별한 처리가 필요합니다

CORS에 관한 이전 글에서 언급했듯이, OPTIONS 프리플라이트 (preflight) 요청에는 인증이 필요하지 않습니다. 이 방식에서도 그 점은 여전히 유효합니다. 그 부분은 변하지 않으니 잊지 마세요.

실수 3: 매우 큰 응답

가끔 정말 큰 응답 (1MB 초과)이 발생하는 경우, 해당 응답에 대해서는 여전히 chunked encoding을 사용할 수 있습니다. 결정하기 전에 크기를 확인하기만 하면 됩니다:

if (contentLength < MAX_EXPLICIT_LENGTH) {
    response.setContentLength(contentLength);
}
...

하이브리드 접근 방식 (Hybrid approach)은 매우 효과적입니다. 대부분의 응답은 크기가 작으므로 명시적인 수정 사항 (explicit fix)을 적용하고, 큰 응답에는 chunked encoding을 사용합니다.

마무리하며

만약 MCP 서버를 구축 중인데 chunked encoding으로 인해 무작위로 멈춤(hanging), 부분적인 응답, 또는 연결 끊김 현상이 발생하고 있다면 이 해결 방법을 시도해 보세요. 저에게는 효과가 있었으니, 여러분에게도 효과가 있을 것입니다.

전체 해결 방법은 코드 5줄 정도를 추가하는 수준입니다. 그게 전부입니다. 대대적인 리팩토링 (refactoring)은 필요하지 않습니다.

제가 이 작업을 진행 중인 프로젝트는 Papers입니다. 이는 AI가 제 개인 노트를 사용하여 질문에 답할 수 있도록 해주는, 제가 1,800시간 동안 구축한 개인 MCP 지식 베이스 (knowledge base)입니다. 궁금하시다면 GitHub에서 확인하실 수 있습니다:

📝 github.com/kevinten10/Papers

이 프로젝트는 오픈 소스이며, 이 Content-Length 수정 사항을 포함하여 제가 지금까지 작성했던 모든 다른 해결 방법들(타임아웃 (timeout), 검증 (validation), 로깅 (logging), 상태 확인 (health check), 캐싱 (caching), 디스커버리 (discovery), 연결 처리 (connection handling) 등 모든 것)이 포함된 모든 MCP 서버 코드가 그곳에 있습니다.

그럼 여러분은 어떠신가요? MCP 서버를 구축하고 계신가요? 제가 아직 발견하지 못한 어떤 기이한 운영 환경 문제 (production issues)를 겪으셨나요? 저는 다음 글을 위해 여전히 실전 경험담 (war stories)을 수집하고 있습니다. 아래에 댓글을 남겨 알려주세요!

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0