# AI 에이전트

MCP 서버를 로드밸런서 뒤에 두면 왜 깨질까?

@Hong2026-10-03

이 글에 담긴 코드와 결과는 직접 실행해 측정한 값을 바탕으로 작성했습니다. 실측은 Hong과 팀원들이 진행했고, 그 결과를 바탕으로 AI가 글을 쓰고 Hong이 검토합니다.

실습 코드 내려받기

질문
MCP 서버를 여러 대 띄워 로드밸런서 뒤에 두면 왜 Session not found가 날까?
답
세션은 ID를 발급한 서버 하나만 기억해, 라운드로빈 뒤 다른 서버로 간 요청이 404를 받았고 세션을 없앤 무상태 방식은 요청 6개가 모두 200이었다.
조건
MCP Python SDK mcp 1.30.0과 2.2.0, nginx 1.30.5 라운드로빈 뒤 서버 두 대, Docker Engine 27.5.1, 세 번씩 반복

환경

MCP 스펙
2026-07-28판, 2025-11-25판
Python SDK
mcp 2.2.0(2026-07-28 방식), 1.30.0(2025-11-25 방식), 둘 다 2026-09-07 게시
로드밸런서
nginx 1.30.5 라운드로빈, 뒤에 서버 두 대
실행
macOS(Apple Silicon) Docker Desktop(Docker Engine 27.5.1), 리눅스 VM에 CPU 8개·메모리 약 9.7GB
반복
캡처한 실행은 세 번씩
목차
  1. 실습 준비
  2. 실습 1: 세션 방식에서 도구 호출 한 번에 오가는 것
  3. 실습 2: 라운드로빈 뒤에 두면 세션 방식은 404
  4. 실습 3: 같은 구성에서 무상태 방식
  5. 정리

이번 시간에는 같은 MCP 도구 서버를 세션 방식과 무상태 방식으로 띄우고, 중간에 둔 프록시로 실제 HTTP 요청을 캡처하며 두 방식의 차이를 배워가는 시간입니다.

예를 들어서 사내 도구 서버를 MCP로 만들어 HTTP로 열었다고 해볼게요. 에이전트가 늘어서 서버를 두 대로 늘리고 nginx 뒤에 두었더니, 도구 호출이 될 때도 있고 안 될 때도 있어요. 서버 로그에는 Session not found가 찍혀 있고요.

왜 그럴까요? 원인은 MCP 2025-11-25 스펙의 세션이에요. 서버가 첫 요청에서 세션 ID를 발급하고, 클라이언트는 이후 모든 요청에 이 ID를 붙여 보냅니다. 그런데 이 세션을 기억하는 건 ID를 발급한 서버 하나뿐이에요.

2026-07-28 스펙은 이 세션과 initialize 핸드셰이크(연결을 맺을 때 버전과 기능을 맞추는 첫 주고받기)를 프로토콜에서 없앴어요.

스펙과 SDK가 모두 새것이라 버전을 고정해서 진행해요.

실습 준비

이 글의 명령은 모두 실습 코드의 mcp-stateless-server/ 디렉터리에서 실행합니다.

  • articles.py: 두 서버가 쓰는 가짜 글 데이터
  • Dockerfile.v1, Dockerfile.v2: mcp 1.30.0과 2.2.0을 각각 설치한 이미지
  • server_v1.py: 1.30.0 FastMCP로 만든 세션 방식 서버
  • server_v2.py: 2.2.0 MCPServer로 만든 무상태 방식 서버(동작을 바꾸는 환경 변수 두 개 포함)
  • client_v1.py, client_v2.py: SDK 버전별 클라이언트
  • proxy.py: 오간 헤더와 본문을 out/capture.jsonl에 남기는 로깅 프록시
  • nginx.conf: 라운드로빈·해시 분산·헤더 라우팅 설정
  • compose.yaml: 서버 4대, 프록시, nginx, 클라이언트, Ollama 서비스
  • run.sh: 실습에서 쓰는 셸 함수 모음
  • show.py: 프록시 기록을 요청 한 줄씩 요약하는 스크립트

그 밖의 파일은 데이터·보조 스크립트입니다. server_v2.py의 환경 변수 두 개, nginx.conf의 해시 분산·헤더 라우팅 설정, compose의 Ollama 서비스는 이 글에서는 쓰지 않아요.

도구 서버에는 도구 두 개를 둘게요. 글 slug를 받아 제목과 태그를 돌려주는 get_article_meta, 좋아요 수를 돌려주는 get_like_count예요.

값은 articles.py에 고정한 가짜 데이터(ARTICLES)이고, 두 서버가 이 모듈을 가져다 씁니다.

mcp 1.x와 2.x는 한 환경에 함께 설치할 수 없어서 이미지를 둘로 나눴어요. 둘 다 python:3.12-slim에서 시작합니다.

세션 방식 서버(server_v1.py)는 1.30.0의 FastMCP로 만들어요. stateless_http의 기본값이 False라서 따로 설정하지 않아도 세션을 씁니다.

무상태 방식 서버(server_v2.py)는 2.2.0의 MCPServer로 만들어요. 2.x에서 FastMCP는 MCPServer로 이름이 바뀌었고, 도구 정의는 같아요.

클라이언트도 SDK 버전별로 하나씩 둬요. 둘 다 tools/list 한 번, get_article_meta 한 번, get_like_count를 인자로 준 횟수만큼 부릅니다.

1.30.0 클라이언트(client_v1.py)는 ClientSession으로 initialize부터 직접 부르고, 실패하면 가장 안쪽 예외 하나만 출력해요.

2.2.0 클라이언트(client_v2.py)는 Client 하나로 끝납니다.

요청을 들여다볼 로깅 프록시(proxy.py)는 표준 라이브러리만으로 만들었어요. 받은 요청을 경로 앞부분에 따라 서버로 넘기고, 응답을 그대로 돌려주면서 양쪽의 헤더와 본문을 out/capture.jsonl에 한 줄씩 남깁니다.

SSE(Server-Sent Events, 서버가 한 HTTP 응답으로 메시지를 이어서 보내는 방식) 응답은 받은 조각을 바로바로 넘겨서 스트림이 끊기지 않게 했어요.

nginx 설정은 nginx.conf예요. upstream으로 서버 묶음(v1_pool, v2_pool 등)을 두고 /v1/, /v2/ 경로를 각 묶음에 라운드로빈으로 넘깁니다.

worker_processes 1은 일부러 넣었어요. 처음에 기본 설정으로 돌렸을 때 요청이 번갈아 가지 않고 한 서버로 몰리는 일이 있었거든요.

왜 그랬을까요? 워커 프로세스마다 라운드로빈 순서를 따로 가지고 있어서, 연결이 어느 워커에 붙느냐에 따라 순서가 달라졌던 거예요. 워커를 하나로 두면 요청이 들어온 순서대로 두 서버를 번갈아 갑니다.

접속 로그에는 요청이 간 서버 주소와 응답 코드, 그리고 MCP 헤더 세 개를 남겨요.

compose 파일은 compose.yaml이에요. 프로젝트 이름은 amcp이고, 메모리와 CPU 상한을 서비스마다 걸었어요.

  • v1-a, v1-b: 세션 방식 서버 두 대
  • v2-a, v2-b: 무상태 방식 서버 두 대
  • proxy: 로깅 프록시. /v1/은 v1-a로, /v2/는 v2-a로, /lb/는 nginx로 넘깁니다.
  • nginx: 라운드로빈 진입점
  • client-v1, client-v2: 실행할 때만 띄우는 클라이언트. 실습 디렉터리를 /app에 읽기 전용으로 마운트합니다.

실습에 쓰는 셸 함수는 run.sh에 모아 두었어요. zsh에서 source run.sh로 불러 쓰시면 됩니다. 함수마다 하는 일은 이래요.

v1, v2는 클라이언트 컨테이너를 새로 띄워 client_v1.py, client_v2.py를 실행하고 끝나면 지워요. show는 show.py로 프록시 기록을 요약하고, fresh는 out/capture.jsonl을 비웁니다.

now는 지금 시간을 찍고, lblog <시각>은 그 시간 이후의 nginx 접속 로그만, ips는 서버 컨테이너 이름과 IP를 보여 줘요.

show.py는 capture.jsonl을 요청 한 줄씩 요약해요. JSON-RPC 메서드, 응답 코드, 요청과 응답 본문의 바이트 수, 요청과 응답에 붙은 세션 ID 앞 8자리를 보여 줍니다. 번호를 주면 그 요청의 MCP 관련 헤더와 본문 전체를 보여 줘요.

source run.sh
mkdir -p out
docker compose up -d --build
docker compose run --rm -T client-v1 pip show mcp | grep -E '^(Name|Version)'
docker compose run --rm -T client-v2 pip show mcp mcp-types | grep -E '^(Name|Version)'
Name: mcp
Version: 1.30.0
Name: mcp
Version: 2.2.0
Name: mcp-types
Version: 2.2.0

실습 1: 세션 방식에서 도구 호출 한 번에 오가는 것

먼저 1.30.0 클라이언트로 1.30.0 서버를 불러 볼게요. 프록시를 거치게 했으니 오간 요청이 모두 기록됩니다.

fresh; v1 http://proxy:7541/v1/mcp
show
#1 POST   initialize                 200  요청 152B  응답 298B  세션(요청/응답) -/d2ee5bd9
#2 POST   notifications/initialized  202  요청  54B  응답   0B  세션(요청/응답) d2ee5bd9/d2ee5bd9
#3 GET    -                          200  요청   0B  응답   0B  세션(요청/응답) d2ee5bd9/d2ee5bd9
#4 POST   tools/list                 200  요청  46B  응답 690B  세션(요청/응답) d2ee5bd9/d2ee5bd9
#5 POST   tools/call                 200  요청 125B  응답 304B  세션(요청/응답) d2ee5bd9/d2ee5bd9
#6 POST   tools/call                 200  요청 123B  응답 151B  세션(요청/응답) d2ee5bd9/d2ee5bd9
#7 DELETE -                          200  요청   0B  응답   0B  세션(요청/응답) d2ee5bd9/d2ee5bd9
합계 요청 7개, 보낸 본문 500B, 받은 본문 1443B

도구는 두 번 불렀는데 HTTP 요청은 7개가 오갔어요. 하나씩 보면 이렇습니다.

  • 1번 initialize: 요청에는 세션 ID가 없고, 응답 헤더에서 d2ee5bd9...가 처음 나와요.
  • 2번 notifications/initialized: 핸드셰이크가 끝났다는 알림입니다. 응답 본문 없이 202가 돌아와요.
  • 3번 GET: 서버가 먼저 보낼 메시지를 받으려고 클라이언트가 열어 두는 SSE 스트림이에요. 세션이 끝날 때까지 열려 있다가 닫힙니다.
  • 4~6번: 실제로 하려던 tools/list와 tools/call 두 번이에요.
  • 7번 DELETE: 클라이언트가 끝날 때 세션을 지우라고 서버에 알립니다.

2번부터 7번까지 모든 요청에 같은 세션 ID가 붙어 있어요.

tools/call 요청 본문에는 도구 이름과 인자만 있어요. 버전과 클라이언트 정보는 다시 보내지 않죠. 서버가 세션에 기억해 두었기 때문이에요.

실습 2: 라운드로빈 뒤에 두면 세션 방식은 404

이제 nginx를 사이에 둘게요. 클라이언트 → 프록시(/lb/) → nginx → v1-a 또는 v1-b 순서로 갑니다.

프록시로는 요청 내용을, nginx 로그로는 요청이 간 서버를 봐요. nginx 로그에는 IP가 찍히니까 이름과 IP를 먼저 확인해 둘게요.

ips
v1-a 172.26.0.4
v1-b 172.26.0.2
v2-a 172.26.0.5
v2-b 172.26.0.3
T=$(now); fresh; v1 http://proxy:7541/lb/v1/mcp 3
show
lblog $T
실패: McpError: Session terminated
#1 POST   initialize                 200  요청 152B  응답 298B  세션(요청/응답) -/1700c47f
#2 POST   notifications/initialized  202  요청  54B  응답   0B  세션(요청/응답) 1700c47f/1700c47f
#3 GET    -                          404  요청   0B  응답  91B  세션(요청/응답) 1700c47f/-
#4 POST   tools/list                 404  요청  46B  응답  91B  세션(요청/응답) 1700c47f/-
#5 DELETE -                          200  요청   0B  응답   0B  세션(요청/응답) 1700c47f/1700c47f
합계 요청 5개, 보낸 본문 252B, 받은 본문 480B
POST /v1/mcp -> 172.26.0.4:8000 200 sid=- method=- name=-
POST /v1/mcp -> 172.26.0.4:8000 202 sid=1700c47fee2147ac99419f0c5cf24299 method=- name=-
GET /v1/mcp -> 172.26.0.2:8000 404 sid=1700c47fee2147ac99419f0c5cf24299 method=- name=-
POST /v1/mcp -> 172.26.0.2:8000 404 sid=1700c47fee2147ac99419f0c5cf24299 method=- name=-
DELETE /v1/mcp -> 172.26.0.4:8000 200 sid=1700c47fee2147ac99419f0c5cf24299 method=- name=-

nginx 로그는 요청이 끝난 순서로 찍혀요. 차례대로 따라가 볼게요.

initialize는 v1-a(172.26.0.4)로 가서 세션을 받았어요. 그다음 클라이언트가 initialized 알림과 GET 스트림을 거의 동시에 보냈고, initialized는 v1-a로, GET은 v1-b(172.26.0.2)로 가서 404를 받았어요.

이어서 tools/list가 다시 v1-b로 가서 404를 받자 클라이언트가 멈췄어요. 마지막 DELETE는 v1-a로 가서 200입니다. 4번 요청과 응답을 보면 이유가 그대로 나와요.

show 4
--- #4 요청 POST /lb/v1/mcp
accept: application/json, text/event-stream
content-type: application/json
mcp-session-id: 1700c47fee2147ac99419f0c5cf24299
mcp-protocol-version: 2025-11-25
{"method":"tools/list","jsonrpc":"2.0","id":1}
--- #4 응답 404
Content-Type: application/json
{"jsonrpc":"2.0","id":"server-error","error":{"code":-32600,"message":"Session not found"}}

v1-b는 1700c47f...라는 세션을 만든 적이 없어요. 세션은 v1-a의 메모리에만 있으니까요.

세 번을 더 돌렸는데 initialized와 GET 중 어느 쪽이 v1-b로 가는지만 바뀌고, 매번 tools/list에서 404를 받고 멈췄어요. 도구는 한 번도 불리지 않았습니다.

스펙은 404를 받은 클라이언트가 세션 ID 없이 initialize부터 새 세션을 시작해야 한다고 해요(2025-11-25 "Session Management"). 그런데 1.30.0 클라이언트는 다시 시작하지 않고 McpError: Session terminated로 끝났어요.

다시 시작하더라도 문제는 남아요. 새 initialize가 어느 서버로 갈지, 그다음 요청이 같은 서버로 갈지는 라운드로빈에 달려 있으니 실패가 반복될 수 있습니다.

실습 3: 같은 구성에서 무상태 방식

같은 nginx 구성에서 이번에는 v2-a, v2-b를 불러 볼게요.

T=$(now); fresh; v2 http://proxy:7541/lb/v2/mcp 3
show
lblog $T
tools: ['get_article_meta', 'get_like_count']
meta: {
  "slug": "agent-code-sandbox",
  "title": "LLM이 만든 코드를 컨테이너에서 돌리면 안전할까?",
  "tags": [
    "AI 에이전트",
    "Docker"
  ]
}
likes #1: 42 
likes #2: 42 
likes #3: 42 
#1 POST   server/discover            200  요청 245B  응답 339B  세션(요청/응답) -/-
#2 POST   tools/list                 200  요청 240B  응답 887B  세션(요청/응답) -/-
#3 POST   tools/call                 200  요청 308B  응답 456B  세션(요청/응답) -/-
#4 POST   tools/call                 200  요청 306B  응답 234B  세션(요청/응답) -/-
#5 POST   tools/call                 200  요청 306B  응답 234B  세션(요청/응답) -/-
#6 POST   tools/call                 200  요청 306B  응답 234B  세션(요청/응답) -/-
합계 요청 6개, 보낸 본문 1711B, 받은 본문 2384B
POST /v2/mcp -> 172.26.0.5:8000 200 sid=- method=server/discover name=-
POST /v2/mcp -> 172.26.0.3:8000 200 sid=- method=tools/list name=-
POST /v2/mcp -> 172.26.0.5:8000 200 sid=- method=tools/call name=get_article_meta
POST /v2/mcp -> 172.26.0.3:8000 200 sid=- method=tools/call name=get_like_count
POST /v2/mcp -> 172.26.0.5:8000 200 sid=- method=tools/call name=get_like_count
POST /v2/mcp -> 172.26.0.3:8000 200 sid=- method=tools/call name=get_like_count

server/discover는 2.2.0 클라이언트가 기본 모드(mode="auto")에서 서버가 2026-07-28을 지원하는지 확인하려고 보내는 요청이에요. 세션 ID는 요청에도 응답에도 없어요.

요청 6개가 v2-a(172.26.0.5)와 v2-b(172.26.0.3)를 번갈아 갔고 모두 200이에요.

세션 방식과 달리 요청이 스스로 필요한 정보를 다 들고 있어요.

  • 본문 params._meta에 io.modelcontextprotocol/protocolVersion, clientInfo, clientCapabilities가 들어 있어요. 세션 방식에서 initialize 때 한 번만 보내던 정보입니다.
  • 헤더 mcp-protocol-version: 2026-07-28, mcp-method: tools/call, mcp-name: get_article_meta가 붙어 있어요. Mcp-Method는 본문 method와, Mcp-Name은 본문 params.name과 같습니다.

각 서버는 다른 서버가 앞에서 무엇을 받았는지 몰라요. 하지만 요청 하나에 필요한 정보가 다 들어 있어서 처리에 문제가 없죠. 세 번 반복해도 같았고, 로드밸런서에 세션 고정 설정을 넣을 필요도 없었어요.

여기서 "무상태"라는 말을 서버가 아무 상태도 가질 수 없다는 뜻으로 읽으시면 안 돼요. 이 글의 도구도 좋아요 수라는 데이터를 읽거든요. 서버는 DB나 캐시 같은 애플리케이션 상태를 평소처럼 가질 수 있어요.

없어진 것은 프로토콜 세션, 곧 "이 연결의 클라이언트는 어떤 버전과 기능을 쓰기로 했다"는 기억이에요. 그 기억이 요청마다 본문에 실려 오니 서버가 따로 들고 있을 필요가 없어진 거죠.

정리

범위: MCP Python SDK mcp 1.30.0(2025-11-25 방식)과 2.2.0(2026-07-28 방식), nginx 1.30.5 라운드로빈 뒤 서버 두 대, 캡처한 실행은 세 번씩 반복.

  • 세션 방식은 도구 두 번을 부르는 데 initialize, initialized, GET 스트림, DELETE까지 HTTP 요청 7개가 오갔고, 2번부터 모든 요청이 같은 세션 ID를 들고 다녔어요.
  • 라운드로빈 뒤에서 세션 방식은 다른 서버로 간 요청이 404(Session not found)를 받았고, 1.30.0 클라이언트는 새 세션을 열지 않고 멈춰 네 번 모두 도구를 한 번도 부르지 못했어요. 세션 방식 서버를 여러 대로 늘릴 때 라운드로빈만 두지 마세요.
  • 무상태 방식은 같은 라운드로빈 뒤에서 요청 6개가 두 서버를 번갈아 가며 모두 200이었어요. 이 글에 싣지 않은 추가 측정에서는 2.2.0 서버도 1.30.0 클라이언트에게는 세션을 만들어 실패했어요. 구형 클라이언트가 남아 있으면 서버를 stateless_http=True로 띄워 주세요.
내 링크 만들기