장르: 이관 기록 + 발견 기록. "올릴 필요가 있는지 검토해달라"로 시작해 실제 이관과 배포까지 갔다. 이관 자체는 네 줄이라 이 글의 분량을 차지하지 않는다. 남길 가치가 있는 것은 검토 단계에서 세운 예측 두 개가 실측으로 뒤집힌 지점이다.

사내 규정을 검색하는 MCP 서버(rag-mcp)가 쓰는 파이썬 SDK에 메이저 버전이 나왔다. 올려야 하는지 판단해달라는 요청으로 시작한 작업이다. 결론부터 쓰면 올렸고, 올릴 이유는 "신버전이 나왔으니까"가 아니었다.

1. 검토 — 올릴 이유가 있는가

상황은 단순했다. mcp 2.0.0이 전날 정식 릴리스됐고, 같은 날 나온 1.29.0을 끝으로 1.x는 보안 픽스만 받는 유지보수 라인이 됐다. 기존 requirements.txt에는 이런 핀이 걸려 있었다.

# 2.0에서 패키지 구조가 바뀌어 `mcp.server.fastmcp`가 사라졌다(FastMCP가 최상위에도
# 없다). 상한을 열어 두면 이미지를 다시 구울 때마다 서버가 import 단계에서 죽는다.
mcp>=1.9,<2

먼저 이 주석이 맞는지부터 확인했다. 2.0 wheel을 받아 열어보니 정확했다.

$ python -c "import mcp.server.fastmcp"
ModuleNotFoundError: No module named 'mcp.server.fastmcp'

전에 상한을 걸어둔 판단은 옳았다. 그러면 지금 푸는 게 맞는가를 봐야 했다.

2. 이관 자체는 네 줄이었다

마이그레이션 가이드를 이 서버가 실제로 쓰는 API와 대조하니 깨지는 것은 하나뿐이었다. FastMCPMCPServer로 바뀌고, 전송 파라미터가 생성자에서 run()으로 옮겨간 것.

# 1.x
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("docs", host="0.0.0.0", port=PORT, stateless_http=True)
mcp.run(transport="streamable-http")

# 2.x
from mcp.server.mcpserver import MCPServer

mcp = MCPServer("docs")
mcp.run(transport="streamable-http", host="0.0.0.0", port=PORT, stateless_http=True)

@mcp.tool() 데코레이터, custom_route(헬스체크 엔드포인트에 쓴다), stateless_http 동작은 그대로다. 저수준 Server도, MCP_* 환경변수도, elicitation도 원래 쓰지 않았고 테스트는 SDK를 import하지 않아 회귀 게이트도 영향이 없었다.

한 가지 함정이 있다. run()host="0.0.0.0"을 반드시 넘겨야 한다. 생략하면 2.x 기본값이 127.0.0.1이고, 그때 DNS 리바인딩 보호가 자동으로 켜져 컨테이너 밖에서 온 요청이 421 Invalid Host header로 거절된다. 1.x에서는 생성자에 넘기던 값이라 옮기는 과정에서 조용히 사라지기 쉽다.

여기까지가 이관의 전부다. 그런데 가이드를 읽다가 다른 줄에 눈이 걸렸다.

3. 진짜 문제는 SDK가 아니라 지금까지의 동작이었다

1.x에서 동기(def) 도구 함수는 이벤트 루프에서 그대로 호출됐다. 2.x는 워커 스레드로 넘긴다.

이 서버의 도구 세 개는 전부 동기 def이고, 전부 블로킹이다. DB를 읽고, 임베딩 API를 호출하고, ONNX 크로스인코더로 리랭킹한다. 한 건에 수 초가 걸린다.

SDK 코드를 직접 열어 확인했다.

# 1.x — func_metadata.py
if fn_is_async:
    return await fn(**arguments_parsed_dict)
else:
    return fn(**arguments_parsed_dict)   # 이벤트 루프에서 그대로 실행

즉 지금까지 검색 요청이 한 건씩 직렬로 처리되고 있었다. 두 사람이 동시에 물으면 뒷사람은 앞사람 검색이 끝날 때까지 통째로 기다린다. 운영 중에 이걸 몰랐던 이유는 간단하다. 봇 한 명이 순차로 질문하는 동안에는 절대 드러나지 않는다.

4. 합성 벤치가 말한 것 — 3.6배

도구 세 개의 골격만 남기고 본문을 0.5초 블로킹으로 바꾼 뒤, 1.x와 2.x에 각각 띄워 4건을 동시에 호출했다.

4건 동시 부하 중 헬스체크 실행 스레드
1.x 2.12 s 1.88 s MainThread
2.x 0.59 s 0.0017 s AnyIO worker thread

깔끔한 숫자다. 여기서 "처리량이 3.6배 오른다"고 보고했다. 틀렸다.

5. 실제 워크로드가 말한 것 — 차이 없음

이관을 마치고 실제 검색으로 다시 쟀다. 컨테이너에서 6건 동시 검색, 워밍업 후 3회.

6건 총 소요 부하 중 헬스체크 피크 메모리
1.x 11.31 / 10.24 / 9.87 s 10.93 / 9.83 / 9.48 s 1015 MiB
2.x 8.66 / 10.11 / 14.57 s 0.115 / 0.103 / 0.095 s 1.11 GiB

처리량은 변동폭이 차이보다 커서 구분되지 않는다. 8.7초와 14.6초가 같은 설정에서 나온다.

이유는 명확했다. 0.5초 sleep은 I/O 대기를 흉내낸 것이다. 대기 중인 작업 여러 개는 당연히 겹친다. 그런데 이 서버의 실제 병목은 리랭킹의 CPU다. 동시에 돌려봐야 같은 코어를 나눠 쓸 뿐이라 총 소요는 그대로다.

벤치가 측정한 축과 실제 병목의 축이 달랐다. 배수가 깔끔할수록 의심했어야 했다.

6. 그럼 무엇을 얻었나 — 죽어 있던 헬스 신호

위 표에서 처리량 열보다 훨씬 중요한 것은 가운데 열이다.

부하 중 헬스체크    1.x  9.5 – 10.9 s    →    2.x  0.03 – 0.14 s

컨테이너의 헬스체크 설정은 이렇다.

healthcheck:
  test: ["CMD", "python", "-c", "import urllib.request as u; u.urlopen('http://127.0.0.1:8765/healthz', timeout=5)"]
  interval: 30s
  timeout: 10s
  retries: 3

timeout이 10초인데 실측치 하나가 10.93초다. 헬스체크 엔드포인트가 이벤트 루프에 얹혀 있으니, 검색이 루프를 잡고 있는 동안 헬스체크도 같이 막힌다. 질의가 몇 건만 겹쳐도 컨테이너가 아무 문제 없이 잘 돌면서 unhealthy로 뒤집힐 수 있는 상태였다.

이건 SDK를 올리지 않았으면 계속 몰랐을 문제다. 검토를 요청받은 질문("올릴 필요가 있는가")의 답은 결국 여기서 나왔다. 올릴 이유는 신버전이 아니라, 신버전을 검토하다 발견한 기존 결함이었다.

7. 공짜로 얻은 동시성이 컨테이너를 죽일 뻔했다

동기 도구가 워커 스레드로 옮겨간다는 말은, 동시 실행 수에 상한이 사라진다는 뜻이기도 하다. anyio의 기본 워커 스레드 한도는 40이다.

리랭커는 ONNX 세션이다. 세션 자체는 하나만 올라가 있지만 실행할 때마다 아레나 메모리를 따로 잡는다. 개발 머신에서 재보니 이랬다.

모델 로드 직후   :  724 MB
직렬 1건 실행 후 :  792 MB
동시 8건 실행 후 : 2238 MB      ← 실행 1건당 약 +200 MB

컨테이너의 mem_limit은 1500m다. 상한 없이 두면 동시 서너 건에서 OOM으로 죽는다. 1.x에서는 직렬 처리라 구조적으로 불가능하던 사고가, 이관과 동시에 가능해진다.

그래서 리랭킹 구간에만 세마포어를 걸었다. 문제는 값이었다.

8. 개발 머신에서 잰 헤드룸은 헤드룸이 아니었다

위 계산(상주 약 890MB + 건당 200MB, 한도 1500MB)으로 2를 제안했다. 컨테이너에서 다시 재보니 이랬다.

동시 리랭킹 상한 6건 총 소요 (3회) 피크 메모리
1 8.66 / 10.11 / 14.57 s 1.11 GiB (75.9%)
2 10.89 / 11.95 / 10.46 s 1.44 GiB (98.0%)

처리량은 구분되지 않는데 메모리만 22%p 더 쓴다. 98%는 OOM 직전이다.

원인은 5장과 같다. 리랭킹이 CPU 바운드라 동시 실행을 늘려도 처리량이 늘지 않는데, 메모리는 정직하게 실행 수만큼 늘어난다. 이득 없이 위험만 사는 설정이었다.

기본값을 1로 정했다. 중요한 건 1이어도 2.x의 이득은 그대로라는 점이다. 이득의 출처는 리랭킹 병렬화가 아니라 도구가 이벤트 루프에서 내려온 것이고, DB·임베딩 대기 구간은 여전히 겹친다. 배포본 실측은 헬스체크 0.038초, 메모리 74.7%다.

개발 머신의 RSS로 계산한 마진이 mem_limit 안에서 통째로 사라졌다는 것이 이 장의 교훈이다. 메모리 헤드룸은 제약이 걸린 곳에서 재야 한다.

9. 세마포어는 정합성이 아니라 메모리 때문이다

세마포어를 걸기 전에 먼저 확인할 게 있었다. 애초에 ONNX 세션과 토크나이저를 여러 스레드가 공유해도 되는가. 안 된다면 세마포어가 아니라 락이 필요하고, 값도 1로 강제된다.

실제 모델로 쟀다. 같은 세션에 4개 스레드가 동시에 서로 다른 질의를 던지고, 결과를 직렬 실행 기준선과 비교했다.

serial:   0.29s
parallel: 0.23s (threads=4)

score mismatches (tol 1e-6): 0 / 32
VERDICT: SAFE

32개 점수가 전부 일치했다. 공유해도 된다. 그러니 세마포어는 정합성 장치가 아니라 순수하게 메모리 상한이다. 이걸 코드 주석에 명시했다. 다음 사람이 "락이니까 건드리면 위험하겠지" 하고 물러서지 않게 하려는 것이다.

여기에 더해 모델 로딩 경로에 락을 하나 걸었다. 두 스레드가 같은 모델을 처음 요청하면 280MB짜리 로드가 겹친다. 프로젝트마다 리랭커가 다를 수 있어 실재하는 경로다.

10. 점수가 달라 보였을 때 — 원인을 분리하는 법

이관 후 검증하다 리랭커 점수가 어긋났다. 순위와 문서는 같은데 로짓이 0.03에서 0.1 사이로 벌어졌다.

이관이 원인이라고 보기 딱 좋은 상황이다. 두 단계로 분리했다.

  1. 같은 환경에서 코드만 바꿔 비교. git stash로 리랭커 수정을 원복하고 같은 가상환경·같은 머신에서 다시 돌렸다. → 출력이 완전히 동일했다. 세마포어는 무죄다.
  2. 같은 플랫폼끼리 비교. 이관 전 컨테이너와 이관 후 컨테이너를 나란히 띄워 비교했다. → 바이트 단위로 동일했다.

차이는 로컬 개발 머신(arm64)과 컨테이너(amd64)의 int8 커널 반올림이었다. 같은 모델 파일, 같은 토큰 배열인데 아키텍처가 다르면 양자화 커널의 반올림이 달라진다. 이 현상 자체는 하루 만에 설계 원칙을 뒤집다의 9장에서 파이썬과 노드 런타임 사이에서 이미 만난 것과 같은 뿌리다.

비교는 반드시 같은 플랫폼에서. 다른 아키텍처에서 잰 값을 기준선으로 쓰면 있지도 않은 회귀를 쫓게 된다.

11. 검증 — 라이브를 내리지 않고 나란히 세우기

검증 방식이 이번 작업에서 가장 재사용할 만한 부분이다. 새 이미지를 다른 포트에 별도 컨테이너로 띄워 기존 라이브와 나란히 두고 비교했다. 메모리 제약도 똑같이 걸었다.

이 방식의 이점은 세 가지다.

  • 다운타임이 0이다. 검증 내내 봇은 기존 서버를 계속 쓴다.
  • 설정 후보를 컨테이너 재기동만으로 갈아끼우며 실측할 수 있다. 8장의 표가 이렇게 나왔다.
  • 문제가 있으면 라이브를 그대로 두고 새 컨테이너만 버리면 된다.

비교 항목은 10개였다. 실제 검색 6건(관련 질의 5건 + 무관 질의 1건), 문서 목록, 조문 읽기, 없는 프로젝트, 없는 문서. 점수와 참고 플래그, 에러 메시지까지 전문을 비교했고 전부 일치했다. 도구 목록의 JSON 스키마와 설명 문자열도 동일했다.

무관 질의를 넣은 이유는 거리 게이트가 정상 동작하는지 보기 위해서다. "근거 문서 없음"과 게이트 수치까지 같아야 회귀가 없다고 말할 수 있다.

여기에 프로젝트 자체 회귀 게이트(관리자 콘솔의 타입스크립트 엔진과 결과를 대조하는 테스트) 3건이 통과했고, 마지막으로 실제 소비자인 에이전트 컨테이너 안에서 직접 호출해 이관 전 기준선과 점수까지 같은지 확인했다. 프로토콜 호환은 문서가 보장한다고 해도, 클라이언트 구현이 무엇인지 모르면 한 번은 찔러봐야 한다.

12. 남은 것

  • 서버 환경은 미적용이다. 이번 작업은 개발 머신뿐이다. CPU와 메모리 사정이 다르므로 동시 실행 상한을 그대로 옮기지 말고 다시 재야 한다.
  • 메모리 여유가 여전히 얇다. 상한 1에서도 75% 근처다. 리랭커 상주가 큰 탓이고 이건 이관과 무관한 기존 제약이다.
  • 봇을 통한 실제 대화는 미검증이다. 프로토콜 레이어까지만 확인했다.
  • 다음 메이저에 대비해 상한을 다시 걸었다. 상한이 없으면 다음 메이저가 나오는 순간 이미지를 다시 굽는 것만으로 서버가 import 단계에서 죽는다. 1에서 2로 올 때 겪은 그대로다.

13. 교훈

1. 합성 벤치의 배수를 실제 워크로드로 옮기지 말 것. 벤치가 측정한 축(I/O 대기)과 실제 병목의 축(CPU)이 다르면 배수는 아무 의미가 없다. 3.6배는 실제로 1.0배였다.

2. 성능 작업의 성과가 꼭 성능일 필요는 없다. 이번 이관으로 처리량은 한 톨도 늘지 않았다. 대신 헬스 신호가 살아났고, 그게 훨씬 값진 결과였다. 처리량이 안 늘었으니 실패라고 보고했다면 진짜 발견을 묻을 뻔했다.

3. 메모리 헤드룸은 제약이 걸린 곳에서 재라. 개발 머신에서 계산한 마진이 컨테이너 한도 안에서 통째로 사라졌다.

4. 신버전 검토는 기존 코드 감사이기도 하다. "동기 핸들러의 실행 위치가 바뀐다"는 한 줄이, 지금 코드가 어떻게 돌고 있는지를 되묻게 만들었다. 올릴지 말지와 별개로 마이그레이션 가이드를 읽는 것 자체에 값이 있다.

5. 원인을 분리할 때는 변수를 하나씩. 점수가 어긋났을 때 코드와 플랫폼을 한 번에 바꿔놓고 비교하면 답이 나오지 않는다. 같은 환경에서 코드만, 같은 코드로 환경만.

관련 문서