MCP 전송 프로토콜 변경: HTTP+SSE에서 Streamable HTTP로

  • 카카오톡 공유하기
  • 네이버 블로그 공유하기
  • 네이버 밴드에 공유하기
  • 페이스북 공유하기
  • 트위터 공유하기
  • 링크 복사하기

들어가며

2024년 11월 Anthropic이 발표한 Model Context Protocol(MCP)은 AI 애플리케이션과 다양한 데이터 소스를 연결하는 표준 프로토콜로 빠르게 자리잡았습니다. 하지만 2025년 3월, MCP는 전송 메커니즘에 있어 중대한 변경을 단행했습니다. 바로 HTTP+SSE 방식에서 Streamable HTTP로의 전환입니다.

이 글에서는 왜 이러한 변경이 필요했는지, 그리고 두 방식이 어떻게 다른지 살펴보겠습니다.

MCP와 JSON-RPC 2.0

먼저 MCP의 기본 통신 방식을 이해해야 합니다. MCP는 클라이언트와 서버 간 메시지 교환을 위해 JSON-RPC 2.0 프로토콜을 채택했습니다.

JSON-RPC 2.0 요청 예시:

{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "name": "search",
    "arguments": {"query": "MCP protocol"}
  },
  "id": 1
}

JSON-RPC 2.0 응답 예시:

{
  "jsonrpc": "2.0",
  "result": {
    "content": [{"type": "text", "text": "검색 결과..."}]
  },
  "id": 1
}

JSON-RPC 2.0의 장점은 명확합니다:

  • 전송 계층과 독립적인 경량 프로토콜
  • 언어와 플랫폼에 구애받지 않는 JSON 기반 구조
  • 비동기 처리 및 배치 요청 지원

하지만 JSON-RPC는 메시지 포맷만 정의할 뿐, 실제로 어떻게 전송할지는 정의하지 않습니다. 그래서 MCP는 별도의 전송 메커니즘이 필요했습니다.

HTTP+SSE 방식: 초기 접근법

동작 방식

MCP는 초기에 HTTP+SSE(Server-Sent Events) 방식을 채택했습니다. 이 방식은 두 개의 별도 엔드포인트를 사용합니다:

  1. SSE 엔드포인트 (/sse): 서버 → 클라이언트 메시지 전송
  2. HTTP POST 엔드포인트 (/message): 클라이언트 → 서버 메시지 전송

연결 흐름:

장점

  • WebSocket보다 단순한 구현
  • 표준 HTTP 인프라 활용 가능
  • 프록시와 방화벽 친화적

치명적인 한계들

실제 운영 환경에서 HTTP+SSE 방식은 여러 문제점을 드러냈습니다:

1. 스트림 재개 불가능

네트워크 연결은 언제든 끊어질 수 있습니다. 서버 배포, 네트워크 장애, 클라이언트 일시 중단 등 다양한 이유로 연결이 끊기면, 클라이언트는 처음부터 다시 시작해야 했습니다.

이는 특히 긴 AI 응답을 받는 도중 연결이 끊기면 치명적입니다.

2. 장시간 연결 유지 부담

SSE는 각 클라이언트마다 지속적인 HTTP 연결을 유지합니다. 사용자가 많아질수록:

  • 서버 리소스 소비가 선형적으로 증가
  • 로드 밸런서와 게이트웨이의 연결 제한 문제
  • 수평 확장 시 스티키 세션(sticky session) 필요
3. 단방향 통신의 한계

SSE는 본질적으로 서버 → 클라이언트 단방향입니다. 클라이언트가 서버로 메시지를 보낼 때마다 새로운 HTTP 요청을 생성해야 하므로:

  • 실시간 양방향 통신에 부적합
  • 매 요청마다 TCP 핸드셰이크 및 HTTP 헤더 오버헤드
  • AI 작업 취소, 중단 같은 즉각적인 제어 어려움

Streamable HTTP: 새로운 접근법

2025년 3월, MCP는 이러한 문제들을 해결하기 위해 Streamable HTTP로 전환했습니다.

핵심 설계 원칙

Streamable HTTP는 하나의 엔드포인트로 모든 통신을 처리하며, 필요에 따라 스트리밍을 활용합니다.

주요 특징:

  • 단일 엔드포인트 (/message)가 GET과 POST 모두 지원
  • 서버는 요청에 따라 일반 JSON 또는 SSE 스트림으로 응답
  • 완전한 무상태(stateless) 서버 구현 가능
  • 메시지 ID 기반 재개(resume) 지원

동작 방식

케이스 1: 간단한 요청/응답 (무상태)

간단한 도구 목록 조회나 빠른 작업은 일반 HTTP 요청/응답으로 처리됩니다. 연결을 유지할 필요가 없으므로 서버는 완전히 무상태로 동작할 수 있습니다.

케이스 2: 스트리밍 응답 (진행 상황 포함)

긴 작업의 경우 서버는 SSE 스트림으로 응답하며, 각 메시지에 messageId를 포함합니다.

케이스 3: 연결 재개 (Resume)

연결이 끊긴 경우, 클라이언트는 마지막으로 받은 메시지 ID를 전달하여 이어받을 수 있습니다:

서버는 msg-5 이후의 메시지부터 전송하여, 클라이언트가 중단된 지점부터 이어받을 수 있습니다.

아키텍처 패턴

패턴 1: 완전 무상태 서버
// 간단한 도구 서버 - 세션 불필요
app.post('/message', async (req, res) => {
  const request = req.body;

  if (request.method === 'tools/list') {
    res.json({
      jsonrpc: '2.0',
      result: { tools: getAvailableTools() },
      id: request.id
    });
  } else if (request.method === 'tools/call') {
    const result = await executeToolSync(request.params);
    res.json({
      jsonrpc: '2.0',
      result: result,
      id: request.id
    });
  }
});
패턴 2: 스트리밍 지원 서버
// 진행 상황을 보고하는 서버
app.post('/message', async (req, res) => {
  const request = req.body;

  if (req.headers.accept === 'text/event-stream') {
    res.setHeader('Content-Type', 'text/event-stream');

    let messageId = 1;
    for await (const chunk of processLongTask(request.params)) {
      res.write(`data: ${JSON.stringify({
        jsonrpc: '2.0',
        id: request.id,
        messageId: `msg-${messageId++}`,
        progress: chunk
      })}\n\n`);
    }

    res.write(`event: end\ndata: [DONE]\n\n`);
    res.end();
  } else {
    // 일반 응답
    const result = await processTask(request.params);
    res.json({ jsonrpc: '2.0', result, id: request.id });
  }
});
패턴 3: 세션 기반 상태 유지 서버
// 메시지 히스토리를 유지하는 서버
const sessions = new Map();

app.post('/message', async (req, res) => {
  const sessionId = req.headers['session-id'] || generateSessionId();
  const lastMessageId = req.headers['last-message-id'];

  let session = sessions.get(sessionId);
  if (!session) {
    session = { messages: [], currentId: 0 };
    sessions.set(sessionId, session);
  }

  // 재개 요청인 경우
  if (lastMessageId) {
    const startIndex = session.messages.findIndex(
      m => m.id === lastMessageId
    ) + 1;

    res.setHeader('Content-Type', 'text/event-stream');
    for (const msg of session.messages.slice(startIndex)) {
      res.write(`data: ${JSON.stringify(msg)}\n\n`);
    }
    res.end();
    return;
  }

  // 새 요청 처리
  // ...
});

비교 정리

측면HTTP+SSEStreamable HTTP
엔드포인트2개 (SSE + POST)1개 (통합)
연결 재개불가능메시지 ID 기반 재개
서버 상태항상 세션 필요선택적 (무상태 가능)
리소스 소비높음 (모든 연결 유지)낮음 (필요시에만 유지)
확장성제한적 (스티키 세션)우수 (메시지 버스 활용)
구현 복잡도중간낮음 (간단한 경우) ~ 높음 (복잡한 경우)
실시간성서버→클라이언트만양방향 가능
<출처: https://medium.com/@higress_ai/comparison-of-data-before-and-after-using-streamable-http-b094db8b414e, SSE vs Streamable HTTP Diagram>

왜 WebSocket이 아닌가?

많은 사람들이 “그냥 WebSocket을 쓰면 되지 않나?”라고 생각할 수 있습니다. MCP 팀이 WebSocket을 선택하지 않은 이유는:

  1. 복잡성: WebSocket은 연결 관리, 재연결, 하트비트 등 구현이 복잡합니다.
  2. 브라우저 제약: 브라우저에서 WebSocket은 커스텀 헤더 설정이 불가능하며, 표준 fetch API로 재구현할 수 없습니다.
  3. 인프라 호환성: 많은 프록시, 로드 밸런서, CDN이 WebSocket을 완전히 지원하지 않거나 제한적입니다.
  4. 과잉 기술: 대부분의 MCP 사용 사례는 WebSocket의 저지연 양방향 통신이 필요하지 않습니다.

Streamable HTTP는 필요한 만큼만 복잡하게 사용할 수 있다는 점에서 pragmatic한 선택입니다.

실제 사례: 마이그레이션 관점

Before (HTTP+SSE)

# 서버는 항상 SSE 연결 유지 필요
@app.get("/sse")
async def sse_endpoint(request: Request):
    session_id = generate_session_id()
    sessions[session_id] = Session()

    async def event_generator():
        yield f"event: endpoint\ndata: {{'uri': '/message/{session_id}'}}\n\n"

        # 연결 유지하며 메시지 대기
        while True:
            message = await sessions[session_id].wait_for_message()
            yield f"event: message\ndata: {json.dumps(message)}\n\n"

    return StreamingResponse(event_generator(), media_type="text/event-stream")

@app.post("/message/{session_id}")
async def message_endpoint(session_id: str, request: dict):
    # 메시지를 세션의 큐에 추가
    await sessions[session_id].add_message(request)
    return {"status": "ok"}

After (Streamable HTTP)

# 간단한 경우 - 무상태로 동작
@app.post("/message")
async def message_endpoint(request: dict):
    result = await process_request(request)
    return {"jsonrpc": "2.0", "result": result, "id": request["id"]}

# 복잡한 경우 - 필요시에만 스트리밍
@app.post("/message")
async def message_endpoint(request: Request):
    if request.headers.get("accept") == "text/event-stream":
        async def stream_response():
            message_id = 1
            async for chunk in process_streaming(request.json()):
                yield f"data: {json.dumps({
                    'jsonrpc': '2.0',
                    'messageId': f'msg-{message_id}',
                    'result': chunk
                })}\n\n"
                message_id += 1
            yield "event: end\ndata: [DONE]\n\n"

        return StreamingResponse(stream_response(), media_type="text/event-stream")
    else:
        result = await process_request(await request.json())
        return {"jsonrpc": "2.0", "result": result, "id": request.json()["id"]}

마치며

Streamable HTTP로의 전환은 MCP 생태계에 상당한 변화를 요구했지만, 실제 운영 환경에서 마주친 문제들을 해결하기 위한 필수적인 결정이었습니다.

핵심 개선사항:

  • 연결 끊김에도 안정적인 재개 기능
  • 서버 리소스 사용 최적화 (무상태 가능)
  • 유연한 확장성 (수평 확장 용이)
  • 단순화된 엔드포인트 구조

MCP를 사용하는 개발자라면, 이제는 Streamable HTTP 방식으로 서버와 클라이언트를 구현해야 합니다. 다행히 공식 SDK들이 이미 업데이트되어 마이그레이션 경로를 제공하고 있습니다.

이 변경은 MCP가 프로덕션 환경에서 안정적으로 운영될 수 있는 프로토콜로 성숙해가는 과정의 중요한 이정표입니다.


참고 자료:


게시됨

카테고리

작성자

댓글

“MCP 전송 프로토콜 변경: HTTP+SSE에서 Streamable HTTP로” 에 하나의 답글

  1. […] MCP 전송 프로토콜 변경: HTTP+SSE에서 Streamable HTTP로 […]

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다