3단계로 완성하는 MCP 서버 Docker 배포 — SSE deprecated, 이제는 Streamable HTTP
이 글의 전제 조건: Python MCP 서버가 로컬에서 STDIO 모드로 정상 동작하는 상태를 가정합니다. Docker와 Python 기초 지식이 있으면 더 수월하게 따라갈 수 있습니다.
로컬 머신에서 잘 돌아가던 MCP 서버를 팀 전체가 공유하는 순간, 문제는 시작됩니다. "내 컴퓨터에서는 되는데"라는 말이 오가고, 환경 변수 세팅을 반복하고, 누군가 라이브러리를 업그레이드하면 다른 사람의 서버가 죽습니다. 이 글은 그 문제를 Docker 컨테이너화로 해결하는 전 과정을 다룹니다. 2025년 현재 SSE Transport가 deprecated되고 Streamable HTTP가 새 표준으로 자리잡은 배경과 대응법을 함께 다루는 것이 이 글의 차별점입니다. 레거시 환경 대응법과 최신 권장 방식을 구분해 설명하므로, Dockerfile 작성부터 Transport 선택 기준까지 실무에 바로 적용할 수 있는 기준이 생깁니다.
핵심 개념
MCP (Model Context Protocol)란?
MCP는 Anthropic이 오픈소스로 공개한 표준 프로토콜로, LLM 애플리케이션이 외부 데이터소스와 도구를 일관된 방식으로 연결할 수 있도록 정의한 인터페이스입니다. 클라이언트(Claude, Cursor 등 AI 어시스턴트)와 서버(파일 시스템, DB, API 등 도구 제공자) 사이의 통신 규약을 표준화합니다.
┌──────────────┐ JSON-RPC 2.0 ┌────────────────────┐
│ MCP Client │◄─────────────────►│ MCP Server │
│ (AI Assistant)│ Transport Layer │ (Tools / Resources)│
└──────────────┘ └────────────────────┘MCP의 핵심 가치: 도구 연동 방식을 표준화함으로써, 특정 AI 제품에 종속되지 않는 재사용 가능한 도구 생태계를 구축할 수 있습니다.
Transport 기술의 진화: STDIO → SSE → Streamable HTTP
| Transport | 통신 방식 | 용도 | 상태 |
|---|---|---|---|
| STDIO | 양방향 (stdin/stdout) | 로컬 단일 클라이언트 | ✅ 로컬 권장 |
| HTTP + SSE | 클라이언트→서버: HTTP POST, 서버→클라이언트: SSE 스트림 | 원격 다중 클라이언트 | ⚠️ Deprecated |
| Streamable HTTP | 단일 HTTP 엔드포인트 — 응답을 SSE 스트림 또는 일반 JSON으로 선택 제공 | 원격 다중 클라이언트 | ✅ 현재 표준 |
SSE Transport 구조: 클라이언트가 /sse에 연결해 서버 푸시 스트림을 열고, 명령은 별도 HTTP POST로 보내는 두 채널 구조입니다.
Client ──── GET /sse ─────────────────────► Server
◄─── text/event-stream ─────────────
──── POST /messages (JSON-RPC) ────►왜 SSE가 deprecated되었는가? SSE는 두 개의 별도 채널(SSE 수신 + POST 송신)을 유지해야 하는 구조적 오버헤드가 있었고, 연결 끊김 시 재연결 처리가 복잡했습니다. Streamable HTTP는 단일
/mcp엔드포인트로 이를 통합합니다.
Streamable HTTP 구조: 클라이언트가 HTTP POST로 요청을 보내면, 서버는 Accept 헤더에 따라 SSE 스트림 또는 일반 JSON으로 응답합니다. 단방향 HTTP 요청-응답 기반이되, 응답을 스트리밍으로 내려줄 수 있는 유연한 구조입니다.
Client ──── POST /mcp (Accept: text/event-stream) ───► Server
◄─── SSE 스트림 (또는 일반 JSON 응답) ──────────세션 관리와 다중 클라이언트
팀 환경에서 여러 클라이언트가 동시에 연결될 때, Streamable HTTP는 각 요청에 세션 ID를 부여해 클라이언트 간 격리를 보장합니다. Python SDK의 StreamableHTTPSessionManager가 세션별 상태를 독립적으로 관리하므로, 클라이언트 A의 요청이 클라이언트 B의 응답에 섞이지 않습니다. STDIO와 달리 stateless HTTP 위에서 세션을 추적하기 때문에, 연결이 끊겼다 재연결돼도 세션 ID로 컨텍스트를 복원할 수 있습니다.
레거시 클라이언트 지원: supergateway
아직 SSE Transport만 지원하는 클라이언트를 써야 한다면, supergateway를 활용합니다. STDIO 기반 MCP 서버를 코드 수정 없이 SSE 서버로 노출하는 프로토콜 변환 게이트웨이입니다.
npx supergateway --stdio "python -m my_mcp_server" --port 8000supergateway: STDIO ↔ SSE 프로토콜 변환 게이트웨이. 마이그레이션 과도기에 기존 STDIO MCP 서버를 HTTP로 노출할 때 유용합니다.
실전 적용
Multi-stage Dockerfile 작성
단순 Dockerfile은 빌드 도구와 런타임 파일을 모두 포함해 이미지가 비대해집니다. Multi-stage 빌드를 사용하면 최종 이미지에는 실행에 필요한 파일만 남길 수 있습니다.
# ── 1단계: 빌드 스테이지 ──────────────────────────────────────
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
# --user 플래그로 /root/.local에 설치 (다음 스테이지로 복사 용이)
RUN pip install --user --no-cache-dir -r requirements.txt
# ── 2단계: 런타임 스테이지 ────────────────────────────────────
FROM python:3.12-slim
# 보안: root가 아닌 전용 사용자 생성
RUN useradd -m -u 1000 mcp-user
WORKDIR /app
# 빌드 스테이지에서 설치된 패키지만 복사
COPY --from=builder /root/.local /home/mcp-user/.local
# 소유권 지정과 함께 애플리케이션 코드 복사
COPY --chown=mcp-user:mcp-user . .
USER mcp-user
ENV PATH=/home/mcp-user/.local/bin:$PATH
EXPOSE 8000
# 헬스체크: python:3.12-slim에는 curl이 없으므로 Python 내장 urllib 활용
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
CMD ["python", "-m", "mcp_server"]| 구성 요소 | 역할 |
|---|---|
AS builder |
빌드 전용 스테이지 분리 — pip, gcc 등이 최종 이미지에 포함되지 않음 |
useradd -m -u 1000 |
root 권한 없는 전용 사용자 — 컨테이너 탈출 시 피해 최소화 |
COPY --from=builder |
빌드 아티팩트만 선택적으로 복사 — 이미지 크기 절감 |
HEALTHCHECK (urllib) |
python:3.12-slim에는 curl 미포함 — Python 내장 모듈로 안전하게 구현 |
비밀정보 주의:
COPY . .명령이.env파일을 이미지 레이어에 포함시키지 않도록, 반드시.dockerignore를 작성하세요.
# .dockerignore
.env
.env.*
__pycache__/
*.pyc
.git/
.venv/
*.egg-info/빌드 검증:
docker build -t my-mcp:latest .성공 후docker inspect my-mcp:latest --format='{{.Size}}'로 이미지 크기를 확인하세요. Multi-stage 빌드 적용 시 단일 스테이지 대비 이미지 크기가 절반 이하로 줄어야 정상입니다.
Docker Compose로 팀 공유 환경 구성
팀 전체가 동일한 MCP 서버를 사용하려면 docker-compose.yml로 서비스를 정의하고, 민감 정보는 .env 파일로 분리합니다.
# docker-compose.yml
# Docker Compose v2에서는 version 필드가 불필요합니다 (공식 문서 권장 제거)
services:
mcp-server:
build:
context: .
dockerfile: Dockerfile
ports:
- "8000:8000"
environment:
- MCP_HOST=0.0.0.0
- MCP_PORT=8000
- MCP_TRANSPORT=streamable-http
- API_KEY=${API_KEY} # .env 파일에서 주입
- DATABASE_URL=${DATABASE_URL}
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
deploy:
resources:
limits:
memory: 512M # 메모리 상한 — OOM 보호
cpus: '0.5'
# 필요 시 nginx 리버스 프록시 추가 (TLS 종단)
nginx:
image: nginx:alpine
ports:
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/ssl/certs:ro
depends_on:
- mcp-serverTLS 필수: HTTP로 노출 시 API 키와 쿼리 내용이 평문 전송됩니다. nginx 또는 Caddy를 리버스 프록시로 앞에 두고 반드시 TLS를 종단 처리하세요.
# .env.example (팀 공유 — 실제 값은 팀원 각자 .env에 설정)
API_KEY=your_api_key_here
DATABASE_URL=postgresql://user:pass@host:5432/db
# 팀원이 서버를 시작하는 명령
cp .env.example .env # API 키 등 환경변수 설정
docker compose up -d # 백그라운드 실행
docker compose logs -f # 로그 실시간 확인FastMCP로 STDIO/Streamable HTTP 전환
로컬 개발 시 STDIO로 빠르게 테스트하고, 프로덕션 배포 시 Streamable HTTP로 전환하는 패턴입니다. Python MCP SDK는 mcp.server.fastmcp의 ASGI 기반 방식을 권장합니다. 내부적으로 StreamableHTTPSessionManager가 세션 격리와 스트리밍 응답을 담당합니다.
# mcp_server.py
import os
from mcp.server.fastmcp import FastMCP
# FastMCP로 서버 정의 (Transport에 무관하게 동일)
mcp = FastMCP("my-mcp-server")
@mcp.tool()
async def search_docs(query: str) -> str:
"""내부 문서를 검색합니다"""
return await do_search(query)
async def do_search(query: str) -> str:
"""검색 로직 구현 예시 — 실제 환경에 맞게 교체하세요"""
# 예: DB 쿼리, 벡터 검색, Elasticsearch 호출 등
return f"'{query}'에 대한 검색 결과: [관련 문서 목록]"
# ── Transport 선택: 환경변수로 분기 ──────────────────────────
if __name__ == "__main__":
transport = os.getenv("MCP_TRANSPORT", "stdio")
if transport == "streamable-http":
# 프로덕션: Streamable HTTP
# FastMCP는 내부적으로 StreamableHTTPSessionManager와
# Starlette ASGI 앱을 구성한 뒤 uvicorn으로 실행합니다.
mcp.run(
transport="streamable-http",
host=os.getenv("MCP_HOST", "0.0.0.0"),
port=int(os.getenv("MCP_PORT", "8000")),
)
else:
# 로컬 개발: STDIO
mcp.run(transport="stdio")
/health엔드포인트: Docker와 Kubernetes 헬스체크에 필수입니다. FastMCP가 노출하는 ASGI 앱에/health라우트를 추가하거나,mcp.custom_route()데코레이터를 활용하세요. 이 엔드포인트 없이 배포하면 초기화 중인 서버로 트래픽이 라우팅됩니다.
# 로컬 개발 (STDIO)
python mcp_server.py
# Docker 컨테이너 (Streamable HTTP)
MCP_TRANSPORT=streamable-http python mcp_server.py| 구성 요소 | 역할 |
|---|---|
FastMCP |
고수준 MCP 서버 빌더 — 데코레이터로 도구를 선언적으로 등록 |
@mcp.tool() |
도구 등록 — 함수 시그니처에서 inputSchema 자동 생성 |
mcp.run(transport=...) |
Transport 선택 실행 — 내부적으로 ASGI 앱 구성 및 uvicorn 기동 |
do_search() |
실제 비즈니스 로직 분리 — DB, 검색 엔진 등 실제 구현으로 교체 |
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 환경 일관성 | python:3.12-slim 이미지 위에서 개발·테스트·운영이 동일하게 동작 |
| 팀 협업 | docker compose up 한 줄로 전체 팀이 동일한 서버 사용 가능 |
| 의존성 격리 | 시스템 Python, pip 버전 충돌 없이 독립적인 런타임 보장 |
| 버전 관리 | 이미지 태그(v1.2.3)로 배포 버전 명확히 추적, 롤백 용이 |
| 수평 확장 | Kubernetes HPA 또는 Docker Swarm으로 트래픽에 따라 자동 스케일링 |
| 클라우드 이식성 | Azure Container Apps, Fly.io, Render 등 어느 플랫폼에서든 동일하게 배포 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| SSE Transport 레거시 | 2025년 현재 deprecated, 새 프로젝트에는 부적합 | Streamable HTTP 우선, 레거시 클라이언트만 SSE 유지 |
| 비밀정보 관리 | Dockerfile에 API 키 하드코딩 시 이미지 레이어에 영구 노출 | .dockerignore + .env + Docker Secrets 사용 필수 |
| Alpine 호환성 | C 확장 패키지(numpy, cryptography 등)가 Alpine musl libc와 충돌 가능 | Python 서버는 python:3.12-slim (Debian 기반) 권장 |
| 운영 복잡도 | Graceful shutdown, 로그 수집, 프로세스 감독 설정 필요 | tini를 PID 1로 사용, structured logging 적용 |
| TLS 부재 | HTTP 노출 시 API 키·쿼리 내용 평문 전송 | nginx/Caddy 리버스 프록시로 TLS 종단 처리 |
Distroless 이미지: 보안이 중요한 환경에서는
python:3.12-slim대신gcr.io/distroless/python3사용을 고려하세요. 셸과 패키지 관리자가 제거되어 공격 표면이 최소화됩니다.
tini: Docker 컨테이너에서 Python 프로세스를 PID 1로 직접 실행하면 SIGTERM 핸들링과 좀비 프로세스 수거에 문제가 생깁니다.
docker run --init플래그 또는 Dockerfile에tini를 init 프로세스로 추가해 해결하세요.
실무에서 가장 흔한 실수
.env를.dockerignore에 추가하지 않음 —COPY . .이 API 키가 담긴.env를 이미지 레이어에 포함시킵니다.docker history <image>로 레이어 내용을 확인할 수 있으므로, 빌드 전 반드시.dockerignore를 점검하세요.- 신규 프로젝트에 SSE Transport 적용 — 레거시 클라이언트를 지원해야 하는 명확한 이유 없이 SSE를 선택하면, 곧 deprecated API 마이그레이션 부채를 떠안게 됩니다.
/health엔드포인트 미구현 — 헬스체크 엔드포인트가 없으면 Docker와 Kubernetes는 컨테이너 준비 상태를 판단하지 못하고, 초기화 중인 서버로 트래픽을 라우팅합니다.
마치며
이 글을 읽기 전에는 MCP 서버가 "내 컴퓨터에서만 돌아가는 도구"였다면, 이제는 팀 전체가 신뢰하고 공유할 수 있는 프로덕션 서비스로 바라볼 수 있어야 합니다. 컨테이너 경계가 환경 불일치를 제거하고, Transport 선택 기준(Streamable HTTP vs SSE)이 명확해졌다면 이 글의 목적은 달성된 것입니다.
지금 바로 시작할 수 있는 3단계:
- Multi-stage Dockerfile 작성 —
FROM python:3.12-slim AS builder패턴으로 Dockerfile을 작성하고.dockerignore를 함께 추가하세요. 빌드 후docker inspect my-mcp:latest --format='{{.Size}}'로 이미지 크기를 확인하고, 단일 스테이지 대비 절반 이하인지 검증합니다. - docker-compose.yml과 .env.example 작성 —
version필드 없이 Compose v2 형식으로 작성한 뒤,docker compose up -d로 팀 공유 환경이 정상 시작되는지 확인합니다.curl http://localhost:8000/health응답이{"status": "ok"}이면 성공입니다. - Transport를 Streamable HTTP로 전환 —
MCP_TRANSPORT환경변수로 STDIO/HTTP를 분기 처리하고, Claude 또는 사용 중인 MCP 클라이언트에서http://localhost:8000/mcp로 연결해 도구 호출이 정상 동작하는지 엔드투엔드로 검증합니다.
다음 글: Kubernetes에서 MCP 서버를 HPA(수평 파드 자동 확장)와 함께 운영하고, Prometheus + Grafana로 호출 지연과 에러율을 모니터링하는 방법
참고 자료
- Build to Prod MCP Servers with Docker | Docker Blog
- MCP Server Best Practices | Docker Blog
- MCP Practical Guide with SSE Transport | F22 Labs
- How to Build and Deploy a Model Context Protocol MCP Server | Northflank
- How to Run MCP Servers with Docker | Snyk
- Understanding MCP Recent Change Around HTTP+SSE | Christian Posta
- Exposing MCP Tools Remotely Using SSE | Medium
- Running MCP Servers on Containers using Finch | AWS Dev Community
- supergateway — STDIO↔SSE Gateway | GitHub
- Model Context Protocol 공식 문서