0

[AI] MCP 통신 방법(stdio·SSE·Streamable HTTP Transport 비교)

개요

MCP(Model Context Protocol)가 클라이언트와 서버 사이에서 JSON-RPC 2.0 메시지를 주고받을 때 수단을 Transport라고 한다. JSON-RPC는 메시지 포맷을 정의하고, Transport는 그 메시지를 어떤 채널로 전송하는가를 정의한다.

MCP는 세 가지 Transport를 정의한다. 로컬에서 동작하는 stdio(표준 입출력), HTTP 위에서 SSE(Server-Sent Events) 스트림을 활용하는 방식(현재 deprecated), 그리고 단일 HTTP 엔드포인트로 통합한 Streamable HTTP(현재 권장)가 있다.

서버가 어디서 실행되느냐(로컬/원격), 세션 상태를 유지해야 하느냐, 수평 확장이 필요하느냐에 따라 적합한 Transport가 달라진다. 잘못 고르면 로드밸런서 라운드로빈에서 세션이 깨지거나, 서버리스 배포가 불가능해지거나, 서버 재시작 시마다 모든 클라이언트가 재연결해야 하는 상황이 생긴다.

핵심 용어설명
TransportJSON-RPC 메시지를 실어 나르는 물리 채널(stdio / HTTP / WebSocket 등)
stdio표준 입출력. 자식 프로세스의 stdin/stdout 으로 메시지 교환
SSEServer-Sent Events. HTTP 연결을 유지하며 서버가 이벤트를 클라이언트에 푸시
Streamable HTTP단일 POST 엔드포인트로 요청을 받고, 응답을 JSON 또는 SSE 스트림으로 선택
stateful서버 인스턴스가 특정 클라이언트의 세션을 메모리에 유지하는 운영 방식
stateless요청마다 독립적으로 처리. 서버가 세션 상태를 들고 있지 않음
resumability네트워크 단절 후 재연결 시 진행 중이던 작업을 이어받는 기능

Transport 종류와 구조

stdio(로컬 전용 Transport)

stdio는 로컬 MCP Server의 기본 Transport다. Host가 MCP Server를 프로세스로 실행하고, 그 프로세스의 stdin으로 JSON-RPC 요청을 쓰고 stdout에서 응답을 읽는다. 네트워크 소켓을 전혀 쓰지 않는다.

Host와 Server가 1:1로 연결된다. 다른 Host가 같은 Server 프로세스에 붙을 수 없다. Host가 서버 프로세스를 직접 관리하므로 인증이 거의 필요 없다. 네트워크 레이턴시가 없고 직렬화 오버헤드가 최소화된다.

stdio 서버 프로세스의 생명 주기는 Host가 전적으로 관리한다. Host가 프로세스를 시작하면 세션이 시작되고, Host가 프로세스를 종료하면 세션이 끝난다.

Claude Desktop에서 stdio 서버를 설정하는 방법은 다음과 같다.

// claude_desktop_config.json - stdio Transport 설정
{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/projects"]
    },
    "github": {
      "command": "uvx",
      "args": ["mcp-server-github"],
      "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "<token>" }
    }
  }
}

Claude Desktop이 설정 파일을 읽어 각 서버를 프로세스로 띄운다. commandargs가 프로세스 실행 명령이고, Host가 그 프로세스의 stdin/stdout 을 파이프로 연결해 메시지를 주고받는다. env 키로 서버 프로세스에 환경 변수를 주입할 수 있어 API 키를 전달할 때 사용한다.

Claude CLI를 쓰면 stdio Transport 서버를 명령줄에서 바로 연결할 수 있다.

# stdio Transport 서버를 CLI 에서 등록
claude mcp add filesystem npx @modelcontextprotocol/server-filesystem /Users/me/projects
claude mcp add github uvx mcp-server-github
 
# 등록된 서버 목록 확인
claude mcp list
 
# 연결 테스트
claude mcp test filesystem

파일 시스템 접근, 로컬 DB 조회, Playwright 자동화, 터미널 명령 실행처럼 사용자 PC에서 실행하는 도구에 stdio가 가장 어울린다.

HTTP+SSE(구 방식, 현재 deprecated)

2024-11-05 스펙에서 도입된 원격 Transport다. 현재는 deprecated 상태이므로 신규 구현에서 이 방식을 채택하지 않는다.

두 개의 HTTP 엔드포인트로 양방향 통신을 구현한다.

  • GET /sse: 서버가 SSE 스트림을 열어 클라이언트에게 메시지를 전송하는 다운스트림 채널. 연결이 유지된다.
  • POST /messages: 클라이언트가 서버로 요청을 보내는 업스트림 채널. 각 요청은 독립적이다.

클라이언트는 먼저 GET /sse 로 연결을 열어 SSE 스트림을 수신 대기 상태로 만든다. 서버는 그 스트림으로 endpoint 이벤트를 보내 POST 엔드포인트 URL을 알려준다. 이후 클라이언트가 그 URL로 JSON-RPC 요청을 POST하면 서버가 SSE 스트림으로 응답을 흘려보낸다.

# HTTP+SSE Transport 로 원격 서버 연결
claude mcp add --transport sse my-server http://localhost:8080/sse \
  --header "Authorization: Bearer $TOKEN"

SSE 스트림을 항상 열어 두어야 한다. 이 때문에 구조적으로 항상 stateful이다. 서버 인스턴스가 특정 클라이언트의 SSE 연결을 메모리에 들고 있어야 한다. 로드밸런서 뒤에 여러 인스턴스를 두면 POST 요청이 SSE 연결과 다른 인스턴스로 라우팅될 수 있어 세션이 깨진다. 이 문제를 피하려면 sticky session 설정이 필수다. 서버가 죽으면 연결이 끊기고 클라이언트는 처음부터 재연결해야 한다.

Spring AI에서 HTTP+SSE 방식 서버를 띄우려면 spring-ai-starter-mcp-server-webmvc 의존성을 사용한다.

# application.yml - HTTP+SSE 방식 (구 방식, deprecated)
spring:
  ai:
    mcp:
      server:
        enabled: true
        name: my-mcp-server
        version: 0.0.1
        type: SYNC
# 의존성: spring-ai-starter-mcp-server-webmvc
# 노출 엔드포인트:
#   GET  /sse           (SSE 다운스트림)
#   POST /mcp/message   (업스트림 메시지)

Streamable HTTP(현재 권장 원격 Transport)

2025-03-26 스펙으로 도입되어 HTTP+SSE를 대체한 현재 권장 Transport다. 단일 엔드포인트(POST /mcp)로 클라이언트의 모든 요청을 받는다.

# Streamable HTTP Transport 로 원격 서버 연결
claude mcp add --transport http my-server http://localhost:8080/mcp \
  --header "Authorization: Bearer $TOKEN"

서버는 POST 요청 내용을 보고 응답 방식을 선택한다.

  • 결과가 즉시 나오는 단순 요청(tools/list, tools/call 결과가 짧은 경우): 단일 JSON 응답 반환.
  • 진행 상황 중계, 긴 작업 스트리밍, 서버 능동 알림이 필요한 경우: SSE 스트림으로 업그레이드.

클라이언트가 Accept: application/json, text/event-stream 헤더를 보내면 서버가 두 방식 중 하나를 선택한다. 클라이언트가 Accept: application/json 만 보내면 서버는 반드시 단일 JSON으로만 응답해야 한다.

핵심은 세션 관리 방식이다. Mcp-Session-Id 헤더로 세션을 선택적으로 관리하거나, 세션 상태를 서버에 두지 않는 완전 무상태 운영이 모두 가능하다. 서버가 세션을 들고 있지 않으면 로드밸런서가 요청을 어느 인스턴스로 보내도 문제없다. 서버리스 배포도 가능해진다.

Spring AI에서 Streamable HTTP 방식 서버를 띄우려면 spring-ai-starter-mcp-server-webflux 의존성을 사용한다.

# application.yml - Streamable HTTP 방식 (현재 권장)
spring:
  ai:
    mcp:
      server:
        enabled: true
        name: my-mcp-server
        version: 0.0.1
        type: ASYNC
# 의존성: spring-ai-starter-mcp-server-webflux (webmvc 가 아님)
# 노출 엔드포인트: POST /mcp (단일)

type을 ASYNC로 설정하면 Spring AI 가 리액티브 스트림 기반으로 SSE 업그레이드를 처리한다. SYNC로 설정하면 단일 JSON 응답만 가능하다.

webmvc vs webflux를 선택할 때는 기존 Spring 프로젝트의 기반 스택을 따른다. 새 프로젝트라면 webflux + Streamable HTTP가 더 유연하다.

첫 번째, 동시 연결 처리 효율이다. webflux는 논블로킹 리액티브 스택이라 SSE 스트림처럼 오래 열려 있는 연결을 적은 수의 이벤트 루프 스레드로 동시에 많이 처리한다. webmvc는 요청당 스레드를 점유하는 모델이라 SSE 연결 하나가 스레드 하나를 붙잡는다. 긴 스트림이 많아지면 동시 연결 수가 톰캣 스레드 풀 크기에 묶인다.

두 번째, 응답 방식 전환이 자연스럽다. Streamable HTTP는 같은 POST 요청에 대해 단일 JSON 과 SSE 스트림 중 하나를 골라 응답한다. webflux의 리액티브 스트림이 이 전환과 백프레셔를 언어 차원에서 표현하므로, 단건 응답과 스트리밍 응답을 같은 컨트롤러 반환 타입으로 다룰 수 있다.

세 번째, notification, sampling 요청, 진행률 스트리밍처럼 서버가 클라이언트에게 먼저 메시지를 보내는 기능은 비동기 처리를 전제로 한다. webflux와 type ASYNC 조합이 이를 지원한다. webmvc 와 type SYNC는 단일 JSON 응답만 가능해 서버 푸시를 구현할 수 없다.

정리하면 webmvc + HTTP+SSE는 동기 요청-응답에 맞춰진 구성이고, webflux + Streamable HTTP는 스트리밍, 서버 푸시, 대규모 동시 연결까지 같은 스택으로 커버한다. 그래서 어떤 도구 패턴이 들어올지 확정되지 않은 신규 서버일수록 후자가 유연하다.

세 가지 차이 중 핵심인 스레드 점유 방식을 그림으로 보면 다음과 같다. 같은 수의 동시 연결이 들어왔을 때 webmvc는 연결마다 워커 스레드를 하나씩 붙잡아 스레드 풀 크기에 묶이고, webflux는 적은 수의 이벤트 루프 스레드로 다수 연결을 논블로킹 처리한다.

실제 동작: Transport별 메시지 흐름

아래는 Streamable HTTP 기준 초기화부터 도구 실행까지의 전체 흐름이다. POST /mcp 단일 엔드포인트 하나로 모든 단계가 처리된다.

sequenceDiagram
    autonumber
    participant Client as MCP Client
    participant Server as MCP Server (POST /mcp)

    Client->>Server: POST /mcp initialize
    Server-->>Client: 200 JSON (serverInfo, capabilities, Mcp-Session-Id)
    Client->>Server: POST /mcp notifications/initialized
    Server-->>Client: 200 OK
    Client->>Server: POST /mcp tools/list
    Server-->>Client: 200 JSON (tools 배열)
    Client->>Server: POST /mcp tools/call (도구 실행 요청)
    Server-->>Client: SSE 스트림 시작 (진행 중 알림) 또는 200 JSON (즉시 완료)

HTTP+SSE 방식에서는 GET /sse 연결을 먼저 열어 두고, 그 SSE 스트림에서 서버가 endpoint 이벤트로 POST 엔드포인트 URL을 알려준 다음 클라이언트가 별도 POST 채널로 메시지를 보내야 했다. 초기화 단계에만 두 번의 왕복이 필요하다. Streamable HTTP는 이 두 채널을 단일 POST로 통합했다.

세션 식별자가 필요한 stateful 운영에서는 서버가 initialize 응답에 Mcp-Session-Id 헤더를 포함해 돌려보낸다. 이후 클라이언트는 모든 요청에 이 헤더를 붙여 보내고, 서버는 이 헤더로 세션 상태를 찾는다. stateless 운영에서는 서버가 이 헤더를 보내지 않고, 클라이언트도 포함하지 않는다.

다이어그램

Transport 채널 구조 비교

구 HTTP+SSE 방식(2채널: GET /sse + POST /messages)과 현재 권장 Streamable HTTP 방식(단일 POST /mcp)의 채널 구조 차이다.

stateful vs stateless 토폴로지

로드밸런서 뒤 다중 인스턴스 환경에서 두 운영 방식의 차이다. HTTP+SSE는 세션이 특정 인스턴스에 고정되지만, Streamable HTTP stateless 운영은 어느 인스턴스로 보내도 정상 처리된다.

MCP Transport 스펙 변화와 마이그레이션

HTTP+SSE 에서 Streamable HTTP 로

HTTP+SSE는 두 개의 채널(SSE 다운스트림 + POST 업스트림)을 반드시 함께 열어야 한다. 이 구조는 다음 세 가지 문제를 만든다.

첫 번째, 항상 stateful이다. SSE 연결을 유지하는 인스턴스에 POST 요청도 라우팅해야 한다. 로드밸런서에 sticky session 설정이 필수다. 수평 확장이 복잡해진다.

두 번째, 서버리스 배포가 불가능하다. Lambda, Cloud Run처럼 요청이 없을 때 인스턴스를 종료하는 환경에서는 SSE 연결을 유지할 수 없다.

세 번째, 클라이언트 구현이 복잡하다. GET /sse를 먼저 열고, 그 스트림에서 endpoint 이벤트를 수신하고, 그 URL로 POST를 보내는 3단계 초기화를 구현해야 한다.

Streamable HTTP 는 이 세 문제를 단일 POST 엔드포인트로 해결한다. 요청과 응답을 같은 HTTP 연결에서 처리하고, stateless 운영 시 어느 인스턴스로 라우팅해도 된다.

stateless vs stateful 비교

stateless 가 기억하지 않는 ‘상태’는 무엇인가

stateless를 이해할 때 흔히 “그럼 대화 맥락도 서버가 안 들고 있느냐”를 헷갈린다. 여기엔 서로 다른 두 종류의 상태가 섞여 있어 구분이 필요하다.

상태의 종류무엇인가누가 보유stateless 와의 관계
LLM 대화 컨텍스트메시지 히스토리, 직전 도구 호출 결과의 누적항상 Host(Client)무관. 서버는 stateful 이든 stateless 든 이 컨텍스트를 들고 있지 않음
MCP 프로토콜 세션 상태initialize 협상 결과·구독·진행 중 작업 등stateful 이면 서버 인스턴스 / stateless 면 어디에도 유지 안 됨stateless 가 토글하는 실제 대상

핵심은 LLM 대화 메모리는 stateless/stateful 여부와 무관하게 원래부터 Host(Client)의 몫이라는 점이다. MCP 서버는 어떤 운영 방식이든 이 대화 컨텍스트를 저장하지 않는다. 서버가 stateless라서 “기억하지 않는” 것은 대화 메모리가 아니라 아래의 MCP 프로토콜 세션 상태다.

stateless 서버가 요청 사이에 유지하지 않는 상태는 다음과 같다.

  • initialize 협상 결과(서버·클라이언트 capabilities, 프로토콜 버전)
  • Mcp-Session-Id ↔ 세션 데이터 바인딩
  • resources/subscribe 로 걸어 둔 구독 상태
  • 진행 중인 장기 작업과 그 progress token
  • SSE resumability 를 위한 이벤트 로그(Last-Event-ID 재전송 버퍼)

그래서 stateless 서버는 일반 REST AP 처럼 각 POST 요청을 자기완결적으로 처리한다. 클라이언트가 매 요청에 필요한 정보를 담아 보내고, 서버는 그 요청 하나만으로 응답을 만든다. 다음 요청이 어느 인스턴스로 가도 무방한 이유가 여기에 있다. 반대로 stateful 서버가 추가로 들고 있는 것도 대화 메모리가 아니라 위 목록의 프로토콜 세션 상태(특히 구독·진행 중 작업·SSE 스트림)다.

stateful 서버가 들고 있는 상태와 세션을 유지하는 이유

stateful 서버가 들고 있는 것은 MCP 프로토콜 세션 상태다. 이 상태들은 요청과 요청 사이에도 살아 있어야 하며, 서버는 이들을 Mcp-Session-Id 하나로 묶어 조회한다. id는 키일 뿐 실제 데이터는 서버 인스턴스 메모리 또는 외부 저장소에 있다.

상태무엇인가이 상태가 없으면 못 하는 것
initialize 협상 결과핸드셰이크에서 정한 capabilities·프로토콜 버전매 요청 재협상해야 함
resources/subscribe 구독 목록이 클라이언트가 어떤 리소스를 구독했는지리소스 변경 시 누구에게 알릴지 모름
진행 중 장기 작업 + progress token실행 중인 tools/call 과 진행률을 보낼 대상진행률 스트리밍 불가
열려 있는 SSE 스트림그 클라이언트로 향하는 서버→클라이언트 채널서버가 먼저 말을 걸 통로가 없음
resumability 이벤트 로그Last-Event-ID 재전송 버퍼끊긴 뒤 이어받기 불가

세션을 유지하는 이유는 한 문장으로 요약된다. 서버가 클라이언트에게 push해야하기 때문이다.

stateless 요청-응답은 클라이언트가 물어봐야만 서버가 답하는 구조다. 그런데 MCP 에는 서버가 능동적으로 메시지를 보내야 하는 기능이 있다.

  • notification: 리소스가 바뀌었음, 도구 목록이 갱신됨, 로그 알림
  • sampling: 서버가 클라이언트 쪽 LLM 에게 생성을 요청
  • elicitation: 서버가 사용자에게 추가 입력을 요청
  • progress 스트리밍: 긴 작업의 진행률을 중간중간 밀어줌

이걸 하려면 두 가지가 요청 사이에도 살아 있어야 한다. 하나는 그 클라이언트로 향하는 열린 채널(SSE 스트림) 밀어 넣을 통로다. 다른 하나는 그 클라이언트가 무엇을 구독했고 무엇이 진행 중이고 무엇을 push할지이다.

  1. 클라이언트가 resources/subscribeconfig.json 을 구독한다. 서버는 세션 id 밑에 “이 클라이언트는 config.json 구독 중” 을 기록한다.
  2. 이후 config.json 이 변경된다.
  3. 서버가 그 세션의 SSE 스트림으로 notifications/resources/updated 를 먼저 보낸다.

stateless라면 1번의 구독 기록이 POST 응답과 함께 사라지고, 3번에서 밀어 넣을 채널도 없다. 서버 능동 통신 기능 자체가 성립하지 않는다.

정리하면, 서버 능동 알림·sampling·진행률 스트리밍이 필요할 때만 stateful을 선택한다. 도구 호출 결과만 돌려주는 대부분의 서버는 stateless로 충분하다. stateful은 이 채널을 유지하기 위해 sticky session 또는 공유 저장소라는 인프라 부담을 함께 진다.

항목stdioHTTP+SSE(구)Streamable HTTP
상태성프로세스 생명주기와 동일항상 statefulstateless 또는 stateful 선택 가능
수평 확장불필요(1:1)어려움(세션 고정 필요)가능(무상태 운영 시)
서버리스 배포해당 없음불가(연결 유지 필요)가능
재연결/재개프로세스 재시작으로 처리재연결 시 세션 소실resumability 지원
단일 엔드포인트stdin/stdout불가(2개 필요)가능(POST /mcp)
인증거의 불필요API Key / JWTAPI Key / JWT / OAuth 2.1
양방향 통신가능(동기)SSE 다운스트림 + POST 업스트림POST + 응답(JSON/SSE 선택)
스펙 상태현행deprecated(2024-11-05)현재 권장(2025-03-26)

멀티 인스턴스에서 stateful 세션 운영

Streamable HTTP는 요청마다 독립적인 POST이고, 클라이언트는 여러 요청을 동시에 띄울 수 있다. 멀티 인스턴스 환경에서 로드밸런서는 이 요청들을 여러 인스턴스로 분산한다. 여기서 stateful 운영을 잘못 구성하면 같은 Mcp-Session-Id가 서로 다른 인스턴스로 갈라져 세션이 분열된다.

핵심은 Mcp-Session-Id가 식별자(키)일 뿐 상태 저장소가 아니라는 점이다. 웹의 JSESSIONID 와 같다.

구성세션 상태 위치라운드로빈 라우팅결과
stateless상태 없음가능정상. 어느 인스턴스나 처리
stateful + sticky session특정 인스턴스 메모리id 로 같은 인스턴스 고정정상. 항상 한 인스턴스가 처리
stateful + 공유 저장소외부 스토어(Redis 등)가능정상. 한 레코드를 공유
stateful + 인메모리 + 라운드로빈특정 인스턴스 메모리막지 않음세션 분열(오류)
  • 요청 1이 인스턴스 A로 가서 A의 메모리에 세션을 생성한다.
  • 같은 id의 요청 2가 라운드로빈으로 인스턴스 B로 간다.

B는 세션이 없다며 요청을 거부하거나, 빈 세션을 새로 만들어 버린다. 동일 세션이지만 두 인스턴스가 독립적으로 유지하는 상황이다.

  • sticky session: 로드밸런서가 Mcp-Session-Id 를 라우팅 키로 사용해 그 세션의 모든 요청을 항상 한 인스턴스로 보낸다. 인스턴스가 N개여도 그 세션은 한 곳만 건드리므로 SSOT가 유지된다.
  • 공유 저장소: 세션 상태를 Redis 같은 외부 저장소에 두고 id를 조회 키로 쓴다. 아무 인스턴스나 같은 레코드를 읽고 쓴다. 동시 요청이 와도 한 레코드이므로 일반적인 동시성 제어(락 또는 낙관적 버전)만 적용하면 되고, 여전히 논리적으로 한 세션이다.

GET /mcp SSE 스트림도 stateful의 일부다. 그 스트림이 열린 인스턴스에 후속 처리가 묶이므로, 서버 푸시를 쓰면 자연히 sticky session 또는 공유 저장소가 따라온다.

결국 stateless가 권장되는 이유로 돌아온다. stateful을 유지하겠다면, 인스턴스 메모리에 세션을 두게 되고, 라우팅을 고정하거나 상태를 외부로 빼야 한다. stateless는 인스턴스에 세션 상태를 두지 않으므로 이 문제 자체가 사라지고, 모든 인스턴스가 교체 가능해진다.