하나의 요청, 두 개의 스트림: MCP의 Streamable HTTP에서 발생한 45초간의 데드락(Deadlock) 디버깅
요약
Model Context Protocol(MCP)의 Go 구현체인 mcp-go에서 발생한 45초간의 데드락 현상을 디버깅한 사례를 다룹니다. Streamable HTTP 전송 방식에서 메시지 라우팅 규칙 위반으로 인해 발생하는 순환 대기 문제를 분석하고 해결 과정을 설명합니다.
핵심 포인트
- MCP Streamable HTTP의 두 가지 메시지 전송 방식(POST 스트림 vs GET 스트림) 분석
- 도구 호출 중 사용자 확인(elicitation) 시 발생하는 메시지 라우팅 오류 식별
- mcp-go 라이브러리에서 발생한 데드락의 원인이 잘못된 스트림 사용임을 규명
- MCP 사양(spec)에 따른 올바른 JSON-RPC 메시지 전달 규칙 제시
도구 호출(tool call)이 정확히 45초 동안 멈췄다가 실패합니다. 사용자는 45초나 지나서야 권한 팝업을 받게 되고, 이에 응답하더라도 그 답변은 어디로도 전달되지 않습니다. 과정 중에 충돌이 발생하거나 로그에 오류가 기록된 것도 없었습니다. 이것은 며칠 전 mcp-go#932에 접수된 버그 리포트로, Model Context Protocol의 널리 사용되는 Go 구현체인 mcp-go로 구축된 서버를 대상으로 한 Codex Desktop 설정에서 관찰되었습니다.
저는 MCP와 인접한 몇 가지 패키지를 유지 관리하고 있으며, 이미 mcp-go에 대해 두 개의 전송(transport) PR을 열어둔 상태였기에 이를 재현하러 나섰습니다. 해결책은 현재 리뷰 중인 하나의 PR로 마무리되었습니다. 이 버그 유형은 해당 라이브러리보다 더 광범위하며, 7월 28일에 예정된 다음 MCP 사양(spec) 릴리스가 나오면 더 흔해질 것입니다. 그 전체 과정을 소개합니다.
60초 만에 파악하기
MCP 서버는 단순히 요청에 응답만 하는 것이 아닙니다. 요청 중간에 서버는 클라이언트에게 질문을 던지고 답변을 기다릴 수 있습니다. 가장 눈에 띄는 형태는 유도(elicitation)입니다. 도구 핸들러(tool handler)가 사용자의 확인이 필요하다고 판단하면 클라이언트에 elicitation/create 요청을 보내고, 클라이언트는 팝업을 표시하며, 도구 호출은 사용자의 답변과 함께 계속됩니다.
따라서 하나의 tools/call 내부에는 반대 방향으로 중첩된 왕복(round trip)이 존재합니다. 서버에서 클라이언트로 가는 요청은 일반적인 JSON-RPC이며, 여기서 흥미로운 질문은 순수하게 전송(transport)에 관한 것입니다. 즉, 그 중첩된 요청이 어떤 HTTP 연결을 통해 이동하느냐 하는 것입니다.
두 개의 스트림, 두 개의 규칙
MCP의 Streamable HTTP 전송 방식은 클라이언트에게 서버 메시지를 수신하는 두 가지 방법을 제공합니다.
첫 번째는 POST 자체입니다. 클라이언트가 tools/call을 POST할 때, 서버는 Content-Type: text/event-stream으로 응답하며 연결을 열어둘 수 있습니다. 최종 응답 이벤트가 발생하기 전에 서버는 이 스트림에 다른 메시지를 푸시(push)할 수 있습니다. 사양(spec)에는 어떤 메시지가 여기에 속하는지에 대해 다음과 같이 한 문장으로 명시되어 있습니다:
서버는 JSON-RPC 응답을 보내기 전에 JSON-RPC 요청(request) 및 알림(notification)을 보낼 수 있습니다(MAY). 이러한 메시지들은 원래의 클라이언트 요청과 관련이 있어야 합니다(SHOULD).
두 번째는 독립적인 GET 요청입니다. 클라이언트는 진행 중인 요청(request in flight)이 없는 상태에서 장기 유지되는 SSE 스트림을 열 수 있으므로, 서버는 언제든지 클라이언트에게 도달할 수 있습니다. 사양(spec)은 다음과 같은 규칙을 반영합니다:
이러한 메시지들은 클라이언트로부터 동시에 실행되는 어떠한 JSON-RPC 요청과도 관련이 없어야 합니다(SHOULD).
진행 중인 tools/call에 의해 트리거된 유도(elicitation)는 메시지가 "원래의 요청과 관련될" 수 있는 가장 밀접한 형태입니다. 두 문장 모두 동일한 방향을 가리키고 있습니다: 즉, 이는 POST 스트림에 속해야 한다는 것입니다.
mcp-go는 이를 GET 스트림으로 보냈습니다.
순환 대기 (The circular wait)
클라이언트가 현재 읽고 있는 스트림이 무엇이든 상관없이 서버 요청을 전달(dispatch)하는 구조라면, 이러한 라우팅 선택은 타이머가 작동하는 데드락(deadlock)으로 변질됩니다:
- 클라이언트가
tools/call을 POST하고, 응답을 받기 위해 POST의 SSE 스트림을 읽기 시작합니다. - 도구 핸들러(tool handler)가
RequestElicitation을 호출하고 답변을 기다리며 블로킹(blocking)됩니다. - 서버는 독립적인 GET 스트림에
elicitation/create를 큐(queue)에 넣습니다. - 클라이언트는 현재 GET 스트림으로부터 요청을 전달(dispatching)하고 있지 않습니다. 클라이언트는 POST 스트림에서 대기 중입니다.
- 서버는 클라이언트를 기다리고, 클라이언트는 서버를 기다립니다. 아무도 움직이지 않습니다.
- 서버 측 타임아웃(timeout)이 발생한 후(보고된 설정에서는 45초 후), 도구 호출은 실패합니다.
- 클라이언트는 마침내 큐에 쌓인 유도(elicitation)를 처리하고 팝업을 표시합니다.
- 서버가 이미 잊어버린 요청에 대한 답변이 도착합니다.
8단계는 제가 가장 좋아하는 디테일입니다. 타임아웃이 발생했을 때 대기 중인 요청(pending-request) 항목이 삭제되었기 때문에, 늦게 도착한 답변은 사용자가 볼 수 있는 에러조차 발생시키지 않습니다. 그것은 허공을 향해 400 에러를 반환할 뿐입니다.
클라이언트 또한 내내 GET 스트림을 읽고 있어야 했는지에 대한 의문은 타당하며, 이 문제는 클라이언트 측의 원인이 확인되지 않았음을 신중하게 명시하고 있습니다. 하지만 스펙(spec)에 명시된 SHOULD(권고 사항)들은 바로 서버가 클라이언트의 스트림 조작(stream-juggling) 여부에 사용자에게 보이는 요청을 거는 상황을 방지하기 위해 존재합니다. 서버는 모호함을 제거할 수 있는 위치에 있었음에도 그렇게 하지 않았습니다.
서버가 잘못된 이유
mcp-go의 서버 내부에서, POST 핸들러(handler)는 핸들러가 실행되는 동안 대기 중인 알림(notification)을 POST의 SSE 스트림으로 펌핑(pumping)하는 작은 포워더(forwarder) 고루틴(goroutine)을 실행합니다. 하지만 클라이언트로 향하는 요청들은 모두 GET 핸들러만이 비우는(drain) 채널을 통해 전달되었습니다:
// POST 핸들러: 이 POST의 스트림으로 알림을 펌핑함
case nt := <-session.notificationChannel:
writeSSEEvent(w, nt)
...
여기서 부주의한 부분은 없습니다. 알림은 이미 요청 범위(request-scoped)의 경로를 가지고 있었고, 요청 또한 GET 스트림이 존재하고 클라이언트가 이를 즉시 읽는 한 작동하는 전달 경로를 가지고 있었습니다. 이 간극은 이러한 가정들이 주의력을 직렬화(serialize)하는 클라이언트를 만날 때만 드러납니다.
해결책: 연결이 아닌 요청 단위로 라우팅하기
변경 사항은 POST 핸들러에 요청 범위(request-scoped)의 송신자(sender)를 부여하고 이를 핸들러의 컨텍스트(context)에 넣습니다. 도구 핸들러(tool handler)가 유도(elicitation)를 트리거하면, 전달(delivery)은 해당 유도를 일으킨 요청의 스트림을 우선적으로 사용합니다:
type requestScopedSSE struct {
requests chan<- mcp.JSONRPCRequest
done <-chan struct{}
...
RequestElicitation은 JSON-RPC 요청을 생성하고, 먼저 범위 지정된 송신자(scoped sender)를 시도하며, 올라탈 POST가 없거나 이미 종료된 경우에는 기존의 GET 스트림 채널로 폴백(fallback)합니다. 포워더 고루틴은 알림과 정확히 마찬가지로 범위 지정된 요청을 POST 스트림에 쓰는 케이스를 하나 더 추가하게 되었습니다.
회귀 테스트(Regression test)는 GET 스트림이 전혀 열려 있지 않은 실제 HTTP 환경에서 전체 루프를 실행합니다. 즉, tools/call을 POST하고, 해당 POST의 자체 SSE 스트림에서 elicitation/create를 읽은 뒤, 별도의 POST로 이에 응답하고, 마지막으로 원래 스트림에서 최종 도구 결과(tool result)를 읽는 방식입니다. 마스터(master) 브랜치에서는 이 테스트가 타임아웃(timeout)이 발생하지만, 수정 후에는 0.5초 만에 통과합니다.
리뷰를 통해 발견한, 내가 놓쳤던 부분
PR(Pull Request) 리뷰 과정에서 두 가지 엣지 케이스(edge case)가 드러났으며, 두 가지 모두 실제 문제였습니다.
첫째, 응답은 다시 돌아올 길을 찾아야 합니다. 대기 중인 elicitation은 세션 객체(session object)별로 추적되었는데, GET 스트림이 없으면 동일한 세션 ID에 대한 두 개의 동시 POST 요청이 각각 별개의 일시적인(ephemeral) 세션 객체를 생성하게 됩니다. 각 객체는 1부터 시작하는 자체 request-ID 카운터를 유지합니다. 두 카운터가 모두 1이라는 것은, 한 POST의 elicitation에 대한 응답이 다른 POST의 대기 항목에 대해 해결(resolve)될 수 있음을 의미합니다. 이는 타임아웃이 아니라 잘못된 전달(wrong delivery) 문제입니다. 카운터는 서버로 이동했습니다:
// 수정 전: 세션 객체별
requestID := s.requestIDCounter.Add(1) // 두 객체 모두 1에서 시작
...
둘째, 서버의 하트비트(heartbeat) 핑(ping)은 별도의 맵(map)에 세션별 카운터를 유지하고 있었습니다. 동일한 세션에 대한 핑과 elicitation이 모두 id: 1로 나갈 수 있었는데, 이는 JSON-RPC에서 엄격히 금지하는 사항입니다. 동일한 처리를 적용하여 맵과 그 정리(cleanup) 코드를 완전히 제거했습니다. 새로운 테스트는 하나의 스트림에서 핑과 elicitation을 읽어 ID가 절대 충돌하지 않음을 확인(assert)합니다.
나는 이 두 버그를 모두 그대로 배포했을 것입니다. 교훈은 오래되었지만 여전히 유효합니다. ID 할당(allocation)과 라우팅(routing)은 두 개의 결정이 아니라 하나의 설계 결정입니다. 전달(delivery)이 요청 범위(request-scoped)라면, 정체성(identity) 또한 그에 맞춰 범위를 지정해야 하며, 그렇지 않으면 응답이 엉키게 됩니다.
7월 28일, 판돈이 커지다
다음 MCP 명세(spec) 릴리스가 일주일 앞으로 다가왔으며, 릴리스 후보(release candidate)는 5월부터 공개되어 왔습니다. 이는 초기화 핸드셰이크(initialize handshake)와 프로토콜 수준의 세션이 제거된, 일반적인 HTTP 인프라에서 실행되는 상태 비저장(stateless) 코어입니다.
상태 비저장(Stateless) 배포 환경은 단독 GET 스트림(stream)이 존재할 가능성이 가장 낮은 환경입니다. 세션도 없고, 장기 실행 리스너(long-lived listener)도 없으며, 오직 요청과 응답을 실어 나르는 POST 요청만 존재합니다. 이러한 환경에서 POST 스트림은 요청 범위(request-scoped)의 서버 질문을 위한 선호되는 장소가 아닙니다. 유일한 장소입니다. GET 스트림을 서버에서 클라이언트로의 요청을 위한 기본 경로로 취급하는 전송(Transport) 구현체들은, 그 기본 경로가 자신들의 발밑에서 조용히 사라지는 것을 발견하게 될 것입니다.
만약 어떤 언어로든 이 전송 계층을 구현하거나 래핑(wrapping)하고 있다면, 이 버그로부터 도출되는 체크리스트는 다음과 같습니다:
- 클라이언트의 진행 중인(in-flight) 요청으로 인해 발생한 서버-to-클라이언트 요청은 해당 요청의 스트림을 타고 이동합니다. GET 스트림은 그 외의 용도를 위한 것입니다.
- 실패하지 말고 폴백(Fall back)하십시오: 탈 수 있는 스트림이 없다면, 단독 스트림을 사용하는 것이 메시지를 드롭(dropping)하는 것보다 여전히 낫습니다.
- 요청이 취할 수 있는 모든 경로에 대해 응답 라우팅(response routing)이 작동하도록 만드십시오. 라우팅 가능성 없는 전달은 더 느린 타임아웃(timeout)일 뿐입니다.
- 세션당 하나의 ID 공간, 하나의 할당자(allocator)를 사용하십시오. 핑(Ping)도 포함됩니다.
- GET 스트림이 없는 케이스를 엔드 투 엔드(end-to-end)로 테스트하십시오. 7월 28일 이후로 그것은 엣지 케이스(edge case)가 아니라, 표준 배포 모델입니다.
수정 사항은 리뷰 중입니다. 45초라는 시간은 결코 버그가 아니었습니다. 그것은 진짜 버그가 가시화되는 데 걸린 시간이었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기