들어가며
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) 방식을 채택했습니다. 이 방식은 두 개의 별도 엔드포인트를 사용합니다:
- SSE 엔드포인트 (
/sse): 서버 → 클라이언트 메시지 전송 - HTTP POST 엔드포인트 (
/message): 클라이언트 → 서버 메시지 전송
연결 흐름:
[1단계] 클라이언트 → 서버: SSE 연결 수립
GET /sse
Accept: text/event-stream
[2단계] 서버 → 클라이언트: 메시지 전송 URI 제공
event: endpoint
data: {"uri": "/message/session-123"}
[3단계] 클라이언트 → 서버: 메시지 전송
POST /message/session-123
Content-Type: application/json
{
"jsonrpc": "2.0",
"method": "tools/list",
"id": 1
}
[4단계] 서버 → 클라이언트: SSE를 통한 응답
event: message
data: {
"jsonrpc": "2.0",
"result": {"tools": [...]},
"id": 1
}
장점
- WebSocket보다 단순한 구현
- 표준 HTTP 인프라 활용 가능
- 프록시와 방화벽 친화적
치명적인 한계들
실제 운영 환경에서 HTTP+SSE 방식은 여러 문제점을 드러냈습니다:
1. 스트림 재개 불가능
네트워크 연결은 언제든 끊어질 수 있습니다. 서버 배포, 네트워크 장애, 클라이언트 일시 중단 등 다양한 이유로 연결이 끊기면, 클라이언트는 처음부터 다시 시작해야 했습니다.
클라이언트: "긴 문서 요약해줘" 서버: "첫 번째 단락은... 두 번째 단락은... [연결 끊김]" 클라이언트: [다시 연결] "긴 문서 요약해줘" ← 처음부터 다시!
이는 특히 긴 AI 응답을 받는 도중 연결이 끊기면 치명적입니다.
2. 장시간 연결 유지 부담
SSE는 각 클라이언트마다 지속적인 HTTP 연결을 유지합니다. 사용자가 많아질수록:
- 서버 리소스 소비가 선형적으로 증가
- 로드 밸런서와 게이트웨이의 연결 제한 문제
- 수평 확장 시 스티키 세션(sticky session) 필요
클라이언트 1 ─────────[연결 유지]─────────▶ 서버
클라이언트 2 ─────────[연결 유지]─────────▶ 서버
클라이언트 3 ─────────[연결 유지]─────────▶ 서버
... [메모리 부담] ...
클라이언트 N ─────────[연결 유지]─────────▶ 서버
3. 단방향 통신의 한계
SSE는 본질적으로 서버 → 클라이언트 단방향입니다. 클라이언트가 서버로 메시지를 보낼 때마다 새로운 HTTP 요청을 생성해야 하므로:
- 실시간 양방향 통신에 부적합
- 매 요청마다 TCP 핸드셰이크 및 HTTP 헤더 오버헤드
- AI 작업 취소, 중단 같은 즉각적인 제어 어려움
Streamable HTTP: 새로운 접근법
2025년 3월, MCP는 이러한 문제들을 해결하기 위해 Streamable HTTP로 전환했습니다.
핵심 설계 원칙
Streamable HTTP는 하나의 엔드포인트로 모든 통신을 처리하며, 필요에 따라 스트리밍을 활용합니다.
주요 특징:
- 단일 엔드포인트 (
/message)가 GET과 POST 모두 지원 - 서버는 요청에 따라 일반 JSON 또는 SSE 스트림으로 응답
- 완전한 무상태(stateless) 서버 구현 가능
- 메시지 ID 기반 재개(resume) 지원
동작 방식
케이스 1: 간단한 요청/응답 (무상태)
클라이언트 → 서버:
POST /message
Content-Type: application/json
Accept: application/json
{
"jsonrpc": "2.0",
"method": "tools/list",
"id": 1
}
서버 → 클라이언트:
HTTP/1.1 200 OK
Content-Type: application/json
{
"jsonrpc": "2.0",
"result": {"tools": [...]},
"id": 1
}
간단한 도구 목록 조회나 빠른 작업은 일반 HTTP 요청/응답으로 처리됩니다. 연결을 유지할 필요가 없으므로 서버는 완전히 무상태로 동작할 수 있습니다.
케이스 2: 스트리밍 응답 (진행 상황 포함)
클라이언트 → 서버:
POST /message
Content-Type: application/json
Accept: text/event-stream
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "analyze_document",
"arguments": {"document_id": "doc-123"}
},
"id": 1
}
서버 → 클라이언트:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Transfer-Encoding: chunked
data: {"jsonrpc":"2.0","id":1,"messageId":"msg-1","progress":{"current":1,"total":10}}
data: {"jsonrpc":"2.0","id":1,"messageId":"msg-2","progress":{"current":5,"total":10}}
data: {"jsonrpc":"2.0","id":1,"messageId":"msg-3","result":{"analysis":"..."}}
event: end
data: [DONE]
긴 작업의 경우 서버는 SSE 스트림으로 응답하며, 각 메시지에 messageId를 포함합니다.
케이스 3: 연결 재개 (Resume)
연결이 끊긴 경우, 클라이언트는 마지막으로 받은 메시지 ID를 전달하여 이어받을 수 있습니다:
클라이언트 → 서버:
POST /message
Content-Type: application/json
Accept: text/event-stream
Last-Message-Id: msg-5
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {"name": "analyze_document", "arguments": {"document_id": "doc-123"}},
"id": 1
}
서버 → 클라이언트:
HTTP/1.1 200 OK
Content-Type: text/event-stream
data: {"jsonrpc":"2.0","id":1,"messageId":"msg-6","progress":{"current":6,"total":10}}
data: {"jsonrpc":"2.0","id":1,"messageId":"msg-7","progress":{"current":7,"total":10}}
...
서버는 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+SSE | Streamable HTTP |
|---|---|---|
| 엔드포인트 | 2개 (SSE + POST) | 1개 (통합) |
| 연결 재개 | 불가능 | 메시지 ID 기반 재개 |
| 서버 상태 | 항상 세션 필요 | 선택적 (무상태 가능) |
| 리소스 소비 | 높음 (모든 연결 유지) | 낮음 (필요시에만 유지) |
| 확장성 | 제한적 (스티키 세션) | 우수 (메시지 버스 활용) |
| 구현 복잡도 | 중간 | 낮음 (간단한 경우) ~ 높음 (복잡한 경우) |
| 실시간성 | 서버→클라이언트만 | 양방향 가능 |

왜 WebSocket이 아닌가?
많은 사람들이 “그냥 WebSocket을 쓰면 되지 않나?”라고 생각할 수 있습니다. MCP 팀이 WebSocket을 선택하지 않은 이유는:
- 복잡성: WebSocket은 연결 관리, 재연결, 하트비트 등 구현이 복잡합니다.
- 브라우저 제약: 브라우저에서 WebSocket은 커스텀 헤더 설정이 불가능하며, 표준 fetch API로 재구현할 수 없습니다.
- 인프라 호환성: 많은 프록시, 로드 밸런서, CDN이 WebSocket을 완전히 지원하지 않거나 제한적입니다.
- 과잉 기술: 대부분의 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가 프로덕션 환경에서 안정적으로 운영될 수 있는 프로토콜로 성숙해가는 과정의 중요한 이정표입니다.
참고 자료:






답글 남기기