튜토리얼

Remote MCP Server를 프로덕션에 올리려면? 2026 무상태 MCP + HTTP 로드 밸런서 + JSON-RPC 아키텍처

프로토콜이 무상태여도 평범한 HTTP 로드 밸런서를 쓰지 않으면 이득이 없다. 프로덕션 Remote MCP가 푸는 것은 복제 확장, 프록시가 SSE를 쌓지 않게 하는 것, 긴 스트림을 끊지 않는 drain이지 또 다른 Session이 아니다.

2026-07-28 이후 Remote MCP는 initialize와 Mcp-Session-Id로 클라이언트를 한 프로세스에 고정하지 않는다. 각 JSON-RPC 요청은 _meta에 프로토콜 버전과 클라이언트 능력을 싣고, Streamable HTTP는 필요한 필드를 HTTP 헤더로 비춘다. 로드 밸런서와 게이트웨이는 body를 파싱하지 않고도 라우팅, 한도, 지표를 낼 수 있다. 프로덕션에서 어려운 것은 tools/call 작성이 아니다. 복제를 어떻게 늘릴지, 균형 정책, nginx가 SSE 진행을 쌓지 않게 할지, 배포 때 subscriptions/listen을 끊지 않는 drain, 애플리케이션 상태를 어디에 둘지다. 프로토콜 층은 Stateless MCP, 누가 호출할 수 있는지는 OAuth 2.1에서 이미 다뤘다. 이 글은 「HTTP 로드 밸런서 뒤에 어떻게 걸지」만 보탠다. 전송 원문: Streamable HTTP. 봉투 규칙: JSON-RPC 2.0. 로컬 stdio 자식 프로세스에 이 공망 토폴로지를 씌우지 마라.

프로덕션 함정: 세션 친화는 수평 확장을 단일 장애로 되돌린다

많은 사람이 「무상태」를 듣고 컨테이너 하나로 끝낸다. 이득은 round-robin, least-conn, CPU 가중 같은 평범한 HTTP 로드 밸런서를 쓰고 세션 친화를 쓰지 않을 때만 나온다. 옛 개정은 연결 범위 Session에 의존했다. 핸드셰이크 뒤 tools/call은 Mcp-Session-Id를 가진 프로세스로 돌아가야 했다. 복제가 늘면 ALB / nginx에 쿠키 점착이나 IP 해시가 필요하고, 그 상자가 죽으면 Agent 세션이 같이 죽는다. 2026-07-28은 프로토콜 세션을 제거했다. 어떤 복제도 어떤 POST든 혼자 끝내야 한다.

Cursor, Claude Desktop, OpenAI 원격 MCP, 자체 Agent Host는 한 엔드포인트에 순하게 직렬로 오지 않는다. 같은 초에 tools/list, tools/call, 수명이 긴 subscriptions/listen이 동시에 올 수 있다. 이 세 홉은 같은 인스턴스에 떨어질 필요가 없다. 클라이언트 IP로 친화하면 수평 확장은 단일 장애로 돌아가고, 한도와 카나리도 그 상자에 묶인다. 입구는 MCP란 무엇인가. Agent 루프는 AI Agent란 무엇인가.

층 대조: 무상태화가 없앤 것은 프로토콜 Session이지 업무 데이터가 아니다. 청구서 초안, 장바구니, 끝나지 않은 다중 라운드 도구 호출은 여전히 Redis나 DB에 넣고 arguments의 draftId로 되찾는다. 프로세스 안 map을 프로덕션 상태로 두지 마라. 인증도 이 층이 아니다. 공망 Remote MCP의 Bearer는 HTTP 헤더에 있고, 이전 OAuth 글을 보면 된다. 이 글은 세 층이 이미 갈라져 있다고 보고 토폴로지와 운영 계약만 말한다.

방식 로드 밸런서가 보는 것 프로덕션 결과
cookie / IP 친화 + 프로세스 안 Session같은 클라이언트를 같은 upstream에 붙여야 함확장 어렵고, 배포가 끊기고, 단일 장애점
무상태 복제 + 평범한 HTTP LB어떤 복제도 어떤 JSON-RPC POST를 받을 수 있음수평 확장, 카나리, 도구별 한도
Serverless 콜드 스타트 + 단일 POST핸드셰이크를 「기억하는」 상주 프로세스가 없음짧은 RPC에 맞음. 긴 SSE는 별도 타임아웃

목표 구조: Client → HTTP LB → N개의 무상태 복제

토폴로지는 얇게 유지한다. 공망 DNS → TLS 종료(ALB, NLB+sidecar, nginx, Caddy, 클라우드 LB) → 같은 이미지, 같은 설정의 MCP 복제 집합. 프로토콜을 메우려고 앞에 「MCP Session Store」를 달지 마라. 헬스 체크는 독립 GET /healthz다. 프로세스가 살아 있고 의존성에 닿는지만 답한다. /mcp에 빈 body POST를 하지 마라. 살아있는 tools/list를 프로브로 쓰지 마라. 실제 레지스트리를 치고 401이 나면 복제를 잘못 뺀다.

각 복제는 한 홉을 혼자 끝내야 한다. Origin 검증(DNS 리바인딩이면 403), MCP-Protocol-Version / Mcp-Method / Mcp-Name 읽기, 필요하면 Bearer 검증, JSON-RPC 봉투 파싱, 도구 실행, 단일 JSON 또는 요청 범위 SSE 반환. 규격은 POST만 받는 단일 MCP 엔드포인트를 요구한다. 예: https://mcp.example.com/mcp. GET 스트림과 프로토콜 세션은 2026-07-28에서 사라졌다. 「옛 프로브 호환」을 위해 GET /sse를 다시 열지 마라.

복제가 공유하는 것은 애플리케이션 의존성이다. 데이터베이스, 오브젝트 스토리지, 제3자 API, 선택적 Redis. 「지금 열린 MCP 연결」은 공유하지 않는다. Cloud Run, Cloud Functions, Knative처럼 요청 단위로 늘어나는 플랫폼이 이 모델에 맞다. 따로 손볼 것은 긴 SSE의 유휴 타임아웃이지 세션 점착이 아니다. 프로세스는 비특권 사용자로 리버스 프록시 뒤에 둔다. MCP를 0.0.0.0:80으로 공망에 직접 열지 마라.

JSON-RPC 2.0이 로드 밸런서를 어떻게 건너는가

MCP는 JSON-RPC 2.0으로 메시지를 인코딩하고 UTF-8이 필수다. Streamable HTTP에서 클라이언트가 보내는 각 요청 또는 알림은 새 HTTP POST다. 서버는 JSON-RPC 요청을 시작하지 않는다. 로드 밸런서는 method의 업무 의미를 알 필요가 없다. 바이트를 넘긴다. 2026 전송은 method를 Mcp-Method로, 도구/리소스/프롬프트 이름을 Mcp-Name으로 비춘다. 중간 장치는 body를 파싱하지 않고 도구별 한도, 메서드 분할, 버전 카나리를 할 수 있다.

body가 진실이다. 헤더의 MCP-Protocol-Version은 params._meta.io.modelcontextprotocol/protocolVersion과 바이트가 같아야 한다. 아니면 서버는 400과 HeaderMismatch를 돌려야 한다. 클라이언트는 Accept: application/json, text/event-stream도 넣는다. 알림 POST가 받아들여지면 202 Accepted, body 없음. 요청은 JSON 객체 하나 또는 SSE. JSON-RPC id는 이 홉의 요청-응답만 맞춘다. 세션 번호가 아니며 복제 사이에서 id로 「문맥을 되찾으」면 안 된다.

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 42,
  "error": {
    "code": -32600,
    "message": "HeaderMismatch: MCP-Protocol-Version does not match params._meta"
  }
}

아래는 프로덕션 형태의 tools/call이다. 인증은 HTTP 헤더, 봉투는 body, 게이트웨이가 보는 라우팅 키도 헤더에 있다. Access Token을 params나 _meta에 넣는 것은 2026 Remote MCP가 아니다. 인자 모양은 여전히 Schema 검사가 필요하다. MCP와 JSON Schema.

POST /mcp HTTP/1.1
Host: mcp.example.com
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: searchInvoices
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "tools/call",
  "params": {
    "name": "searchInvoices",
    "arguments": {
      "startDate": "2026-01-01",
      "endDate": "2026-01-31",
      "status": "paid"
    },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {},
      "io.modelcontextprotocol/clientInfo": {
        "name": "jsonvue-demo",
        "version": "1.0.0"
      }
    }
  }
}

Streamable HTTP: 버퍼링, 타임아웃, SSE

짧은 도구 호출은 Content-Type: application/json을 돌려야 한다. 긴 작업은 text/event-stream일 수 있다. 먼저 이 요청과 관련된 notifications/progress를 밀고, 마지막 JSON-RPC 응답으로 스트림을 닫는다. 프로덕션 사고는 MCP SDK보다 리버스 프록시에 많다. nginx 기본 proxy_buffering은 진행 이벤트를 쌓아 한 덩어리로 보낸다. Agent는 멈춘 것처럼 보인다. 규격은 중개자를 위해 응답에 X-Accel-Buffering: no를 넣으라고 한다. nginx proxy_buffering을 보라.

Streamable HTTP에서 취소 신호는 클라이언트가 그 SSE를 닫는 것이다. 추가로 notifications/cancelled POST를 보내는 것이 아니다(그건 stdio 바인딩이다). 따라서 LB는 백엔드 끊김을 클라이언트에, 클라이언트 끊김을 복제에 그대로 전해 worker가 손을 멈추게 해야 한다. 가운데 「POST를 재시도하는」 게이트웨이를 두지 마라. JSON-RPC 요청은 기본적으로 멱등이 아니고, tools/call은 이미 DB에 썼을 수 있다.

subscriptions/listen은 다른 종류의 긴 스트림이다. 응답 스트림은 열린 채로 tools/list_changed 같은 변경을 나른다. 한 호출의 progress가 아니다. 규격은 콜론으로 시작하는 SSE 주석 줄을 주기적으로 보내 유휴 중개자가 끊지 않게 하라고 권한다. Last-Event-ID로 재개 가능한 SSE는 없다. LB idle / read timeout은 keep-alive 간격보다 길어야 한다. Cloudflare, ALB, nginx 기본 60초는 종종 짧다. 아래는 최소 리버스 프록시 스케치이지 보안 기준이 아니다. TLS, 한도, WAF는 따로 짠다.

upstream mcp_replicas {
  least_conn;
  server 10.0.1.11:8080;
  server 10.0.1.12:8080;
  server 10.0.1.13:8080;
}

server {
  listen 443 ssl;
  server_name mcp.example.com;

  location /healthz {
    proxy_pass http://mcp_replicas;
    proxy_connect_timeout 2s;
    proxy_read_timeout 3s;
  }

  location /mcp {
    proxy_pass http://mcp_replicas;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header Connection "";
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
  }
}

롤링 배포, drain, subscriptions/listen

무상태 복제는 배포를 쉽게 만든다. SSE는 여전히 진행 중인 HTTP 요청이다. 순서는 이렇다. 인스턴스를 타깃 그룹에서 빼고 → 새 /mcp를 멈추고 → 열린 JSON 응답과 SSE가 끝나거나 공표한 drain 시간까지 기다리고 → 그다음 worker에 SIGTERM. progress를 밀고 있는 프로세스에 SIGKILL 하지 마라. 헬스 체크가 초록이 된 뒤에만 되돌린다.

새 버전은 옛 요청 모양을 여전히 처리해야 한다. 「먼저 핸드셰이크를 올리고 그다음 트래픽을 바꾼다」는 프로토콜 단계가 없다. 카나리는 POST의 몇 퍼센트를 새 이미지로 보내는가다. MCP-Protocol-Version을 라우팅 키로 쓸 수 있다. 2026-07-28을 선언한 클라이언트만 새 풀로 넣고, 옛 클라이언트는 호환 풀에 남긴다. 버전이 안 맞으면 400과 UnsupportedProtocolVersionError. 조용히 낮추고 도구를 계속 실행하지 마라.

배포 창에서 subscriptions/listen은 거의 끊긴다. 클라이언트는 listen을 다시 열어야 하고, 이벤트가 안 빠졌다고 가정하면 안 된다. 서버는 미전달 list_changed를 프로세스 메모리에 쌓지 마라. 확실한 전달이 필요하면 외부 큐에 쓴다. listen은 구독 입일 뿐이다. MRTR(다중 라운드 입력)도 독립 POST다. 중간 결과는 공유 저장소에 두고, 다음 홉은 다른 복제에 떨어져도 된다.

게이트웨이 한도, 인증, 관측, JSONVue

게이트웨이가 Mcp-Method와 Mcp-Name을 보면 QPS를 소스 IP만이 아니라 도구별로 두어라. 비싼 도구(쓰기, 결제, 긴 SQL)는 별도 할당. tools/list는 느슨해도 된다. 공망에서는 Origin을 검증하고 OAuth 2.1 Resource Server로 동작한다. 401, Protected Resource Metadata, 매 홉 Bearer. 자세한 내용은 MCP OAuth 2.1. 인증은 전송, 한도는 게이트웨이, Schema는 업무. 셋을 한 미들웨어에 섞지 마라.

관측 필드 출처 용도
MCP-Protocol-Version / Mcp-Method / Mcp-Name요청 헤더(body _meta와 맞춤)도구별 한도, 카나리, 대시보드
JSON-RPC id이 홉의 봉투클라이언트 재시도와 복제 로그를 맞춤
HTTP 상태 + JSON-RPC error.code전송 층 vs 메서드 층401 / HeaderMismatch / 업무 error를 구분

액세스 로그에는 위 세 묶음을 최소한 남겨라. 복제 로그에는 jsonrpc가 2.0인지, 부작용 전에 도구가 돌았는지를 더한다. 헤더와 body 불일치, 빠진 Accept, 잘못된 Origin은 게이트웨이 또는 복제 입구에서 막고 도구 함수까지 내려보내지 마라. 실패 샘플을 하나씩 남겨라. HeaderMismatch, 401, 빠진 arguments, 버퍼링된 SSE(클라이언트가 마지막 덩어리만 본 경우).

브라우저에서 연동할 수 있다:JSON 포맷으로 봉투가 parse되는지 보고,JSON Schema 검증으로 params.arguments를 확인하고,JSON Diff로 두 실패 응답을 비교하고,JWT 디코드로 공망 배포의 Bearer aud를 본다. 데이터는 이 기기를 떠나지 않는다.

이어서 읽기: MCP란 무엇인가, Stateless MCP, OAuth 2.1, MCP와 JSON Schema, A2A vs MCP.

자주 묻는 질문 FAQ

프로덕션에서 sticky session이 아직 필요한가?

2026-07-28 프로토콜 층에서는 필요 없다. sticky는 「아직 Session이 있다」고 착각하게 할 뿐이다. 앱이 같은 지역이나 데이터 샤드에 붙어야 하면 Mcp-Param-*나 arguments의 테넌트 키로 애플리케이션 라우팅을 하라. 쿠키 친화로 MCP 프로세스에 붙이지 마라.

로드 밸런서 헬스에 GET /mcp를 써도 되나?

안 된다. 현대 MCP 엔드포인트는 POST만 받고 GET 스트림은 사라졌다. /mcp에 아무 GET을 보내면 405이거나 옛 호환 로직을 헷갈리게 한다. 프로브는 독립 /healthz. 프로세스와 의존성만 보고 도구는 실행하지 않는다.

SSE가 끊기면 Last-Event-ID로 이어야 하나?

규격은 재개 가능한 SSE를 지원하지 않는다. 클라이언트는 해당 요청을 다시 연다(listen이면 새 subscriptions/listen, 긴 도구면 업무가 멱등할 때만 재시도). keep-alive 주석 줄과 버퍼링하지 않는 프록시가 직접 만든 event id 캐시보다 계약에 맞다.

로컬 stdio MCP도 로드 밸런서 뒤에 두나?

두지 마라. stdio는 클라이언트가 띄운 자식 프로세스이고 바이트는 표준 입출력에 탄다. HTTP hop이 없다. 로드 밸런싱, Origin 검사, Bearer, X-Accel-Buffering은 Streamable HTTP / Remote의 일이다. 같은 도구가 두 전송을 동시에 제공할 수 있다. 프로덕션 토폴로지는 HTTP 면에만 씌운다.

요약과 다음 단계

Remote MCP 프로덕션 구조는 한 문장이다. 무상태 JSON-RPC 요청이 평범한 HTTP 로드 밸런서를 건너 동일한 복제 아무 곳에나 착륙한다. 긴 스트림은 요청 범위로 열리고, 연결 범위로 클라이언트를 기억하지 않는다. 헤더는 게이트웨이용, body가 진실, 애플리케이션 상태는 외부 저장소.

올리는 순서: 먼저 어떤 복제도 혼자 tools/call을 끝내게 하고, 그다음 버퍼링을 끄고 타임아웃을 늘리고 drain을 더한 뒤, 마지막에 도구별 한도와 카나리. 성공 봉투, HeaderMismatch, 401을 JSONVue로 이 기기에 남겨라. 프로토콜 의미는 Stateless MCP. 누가 호출하는지는 OAuth 2.1.