
하나의 MCP 서버가 두 개가 될 때 무엇이 망가지는가
요약
MCP(Model Context Protocol) 서버를 분산 환경에서 운영할 때 발생하는 상태 관리 문제를 다룹니다. 단일 인스턴스의 메모리에 의존하는 기존 방식이 로드 밸런싱 환경에서 어떻게 실패하는지 설명하고, 클라이언트가 상태를 명시적으로 전달해야 하는 새로운 설계 방향을 제시합니다.
핵심 포인트
- MCP 서버를 다중 인스턴스로 운영할 경우 메모리 기반 상태 유지가 불가능함
- 분산 시스템 환경에서는 호출마다 다른 서버 인스턴스에 도달할 수 있음
- 상태를 서버 메모리가 아닌 클라이언트가 명시적으로 전달하는 방식으로 전환 필요
- MCP 사양은 단일 프로세스 의존성을 탈피하는 방향으로 진화 중
제가 이 일을 시작한 것은 MCP가 유행이기 때문이 아닙니다. 문제가 다가오는 것을 보았기 때문입니다.
제가 만든 리드 발굴 에이전트(lead-finding agent)인 Anzuelo는 Reddit, Hacker News, Bluesky, Mastodon, YouTube, LinkedIn, Threads를 스크래핑하여 제가 관심 있는 주제에 대해 이야기하는 사람들을 찾고, Claude API를 사용하여 찾은 결과에 점수를 매깁니다. 현재는 하루에 한 번, 하나의 프로세스로 실행되는 예약된 작업(scheduled job)으로 운영되므로 동시성(concurrency)을 걱정할 필요가 없습니다. 하지만 저나 다른 누군가가 이러한 검색 및 페이지 워크플로(workflow)를 다른 도구들이 온디맨드(on demand)로 호출할 수 있도록 노출하려는 순간, "서버가 당신이 멈춘 지점을 기억한다"는 가정은 더 이상 안전하지 않게 됩니다. 로드 밸런서(load balancer) 뒤에서 두 개의 인스턴스를 실행하면, 두 번째 호출은 첫 번째 인스턴스에 대해 전혀 들어본 적 없는 머신에 도달할 수 있습니다.
이것은 구체적으로 MCP의 문제는 아닙니다. 이는 분산 시스템(distributed systems)의 문제이며, 7월 28일자 MCP 사양(spec)이 밀고 있는 방향이기도 합니다. 즉, 한 호출을 다음 호출로 연결하기 위해 단일 인스턴스의 메모리에 의존하는 것을 중단하는 것입니다. 상태(state)는 여전히 어딘가에 존재할 수 있습니다. 변하는 점은, 서버가 다음 호출이 동일한 프로세스로 돌아올 것이라고 가정하는 대신, 클라이언트가 상태를 가리키는 명시적인 무언가를 전달한다는 것입니다.
누가 누구와 대화하는가
이후의 내용을 이해하기 위해 필요한 빠른 설정입니다.
모델(model)이 있습니다. 즉, 누군가가 채팅하고 있는 어시스턴트를 의미합니다. MCP 서버가 있습니다. 이는 모델이 호출할 수 있도록 허용된 몇 가지 함수를 제공하는 당신이 작성한 작은 프로그램입니다. 이러한 함수들은 도구(tools)라고 불립니다. 그리고 그 중간에 위치하여 두 사이에서 메시지를 전달하는 클라이언트(client)가 있습니다.
모델이 당신의 멘션(mentions)을 검색하고 싶을 때, 모델은 결코 당신의 코드를 직접 실행하지 않습니다. 모델은 "start_search를 호출하라, 키워드는 mcp이다"라는 메시지를 보냅니다. 클라이언트는 이를 당신의 서버에 전달합니다. 당신의 서버는 함수를 실행하고 텍스트를 다시 보냅니다. 모델은 대화 중의 다른 내용을 읽는 것과 동일한 방식으로 그 텍스트를 읽습니다.
이 포스트에서 중요한 부분은 다음과 같습니다. 이 각각은 별개의 왕복 (round trip) 과정입니다. 질문하고, 답변하고, 끝납니다. 이러한 구조에서는 다음 질문이 당신의 서버가 실행 중인 동일한 복사본(copy)에 도착한다는 보장이 전혀 없습니다.
더 진행하기 전에, 한 가지 솔직하게 말씀드려야 할 것이 있습니다. Anzuelo는 제가 이 문제에 관심을 갖게 된 이유이지만, 동시에 제가 지금 보여드리고 있는 대상은 아닙니다. Anzuelo는 아직 MCP를 지원하지 않습니다. 서버도, 도구(tools)도, 호출하는 사람도 없습니다. 그래서 저는 목록을 검색하고 페이지를 넘기는 기능 외에는 아무것도 하지 않는 아주 작은 MCP 서버를 구축했습니다. 실제로 신경 써야 할 데이터를 기반으로 구축하기 전에, 이 실패가 발생하는 과정을 작은 규모에서 관찰하고 싶었기 때문입니다. 이어지는 내용은 서버가 당신의 검색 내역을 기억해 주는 '이전 방식'과, 모든 호출이 필요한 정보를 직접 들고 오는 '새로운 방식'에 대한 이야기입니다. 저는 제가 틀렸음을 깨달았던 세 가지 지점을 포함하여, 실제로 일이 일어난 순서대로 작성하고 있습니다.
현재 실행 중인 Anzuelo와 같은 시스템에서, 다른 사람들의 클라이언트가 이를 호출할 수 있기 위해 필요한 상태 사이의 간극은 실제로 다음과 같은 모습입니다:
[
이에 대한 더 자세한 버전을 원하신다면, 이전에 MCP가 어떤 문제를 해결하고 도구 호출 (tool calls)이 실제로 어떻게 작동하는지를 다룬 쉬운 입문 글을 작성했습니다. 이 포스트는 거기서부터 시작하여 변화하고 있는 부분으로 바로 들어갑니다. (Anzuelo의 플랫폼별 검색기 아키텍처 (searcher-per-platform architecture)는 MCP가 개입되지 않은 상황에서도 제가 이러한 종류의 상태 관리 (state-management) 문제에 처음 맞닥뜨렸던 곳입니다.)
만약 당신이 엔트리 레벨 (entry-level) 개발자 역할을 목표로 하는 커리어 전환자라면, 이 글은 단순히 트렌드를 쫓는 것과는 무관한 이유로 당신의 시간을 투자할 가치가 있습니다. 상태 (state)를 추론하고 두 시스템 사이의 깔끔한 계약 (contract)을 설계하는 것은 면접관들이 항상 테스트해 온 부분이며, MCP는 현재 이를 연습할 수 있는 잘 문서화된 장소입니다.
하나의 프로세스가 둘이 될 때 나타나는 실패
이야기를 시작하기 전에 한 가지만 더 말씀드리겠습니다. 서버가 바빠지면, 서버의 복사본을 여러 개 동시에 실행하여 트래픽을 분산시킵니다. Kubernetes 사용자들은 각 복사본을 포드 (pod)라고 부르는데, 단어가 짧으니 저도 이 용어를 사용하겠습니다. 이를 "어떤 머신 위에 떠 있는 내 서버의 실행 중인 복사본 하나"라고 이해하시면 됩니다.
로드 밸런서 (load balancer) 뒤에서 실행되는 동일한 MCP 서버의 두 개의 동일한 복사본을 상상해 보세요. 이를 포드 A와 포드 B라고 부르겠습니다. 클라이언트가 start_search를 보내면 요청이 포드 A에 도달하고, 포드 A는 검색 진행 상황을 자신의 메모리 (memory)에 저장합니다. 클라이언트의 다음 호출인 get_next_page는 동전 던지기 확률로 포드 B에 도달할 수 있습니다. 포드 B는 이 검색에 대해 들어본 적이 없습니다. 포드가 충돌 (crash)하는 것은 아닙니다. 포드는 클라이언트에게 활성화된 검색이 없다고 정중하게 알려주는데, 이는 성공적인 응답의 탈을 쓴 막다른 길 (dead end)입니다.
이것은 가설이 아닙니다. 7월 28일 직전까지 사람들이 작성했던 가장 흔한 실패 사례였습니다. 그리고 이 문제는 스펙 (spec)이 출시되기도 전에 해결 가능했습니다. 왜냐하면 해결책이 사실 MCP에 관한 것이 아니기 때문입니다. 그것은 특정 클라이언트가 필요로 하는 무언가를 특정 프로세스가 기억할 것이라고 믿지 않는 것에 관한 문제입니다.
2026-07-28이 실제로 바꾼 것
이 릴리스 이전에는 MCP 대화가 'hello'와 함께 시작되었습니다. 클라이언트와 서버는 initialize라고 불리는 메시지를 주고받으며, 서로 어떤 버전을 사용하는지, 그리고 각자가 무엇을 할 수 있는지에 대해 합의했습니다. 그 이후부터 서버는 이후의 모든 호출마다 밑단에서 스레드(thread)를 계속 실행했습니다. 그 스레드를 세션(session)이라고 불렀습니다.
7-28 버전은 이 'hello'와 스레드를 제거합니다. 이제 모든 요청은 자체적인 버전, 호출자가 누구인지에 대한 정보, 그리고 클라이언트가 무엇을 할 수 있는지에 대한 자체 목록을 담아서 나타납니다. 한 번 합의하고 기억해 두는 방식은 더 이상 존재하지 않습니다.
이는 매 호출마다 더 많은 부하(baggage)를 싣게 된다는 의미입니다. 하지만 이를 통해 얻는 이점은, 어떤 요청도 이전 요청과 동일한 서버 복사본에 도달해야 할 필요가 없다는 것입니다. 로드 밸런서(load balancer)가 트래픽을 원하는 곳 어디로든 보낼 수 있습니다. 또한 서버는 연결을 계속 열어두지 않는 환경에서도 실행될 수 있는데, 이는 저렴한 실행 환경의 대부분이 그러합니다.
이와 함께 세 가지 다른 변경 사항이 도입되었습니다:
- 인증(Signing in)이 더 표준화되었습니다. 인증(Auth) 방식이 이제 OAuth가 일반적으로 설정되는 방식과 일치하므로, 서버는 새로운 방식을 발명하는 대신 기업이 이미 사용 중인 어떤 ID 시스템(identity system)에도 연결할 수 있습니다.
- 이제 프로토콜 자체를 변경하지 않고도 새로운 기능을 애드온(add-ons) 형태로 출시할 수 있습니다. 이번 릴리스와 함께 두 가지 기능이 도착했습니다. MCP Apps는 서버가 일반 텍스트 대신 작은 대화형 인터페이스를 반환할 수 있게 해줍니다. Tasks는 완료하는 데 시간이 걸리는 작업을 다룹니다.
- 모든 기능에 약속(promise)이 부여되었습니다. 어떤 기능이 사용 중단(deprecated)으로 표시되면, 최소 12개월 동안은 스펙(spec)에 유지된 후에 제거될 수 있습니다.
여기서 눈여겨볼 만한 것은 Tasks입니다. 작업이 오래 걸리는 경우, 서버는 태스크 핸들(task handle)로 응답하고, 클라이언트는 그 핸들을 사용하여 다시 돌아와 진행 상황을 묻습니다. 이는 제가 아래에서 search_id와 커서(cursor)를 사용하여 수행하는 방식과 동일합니다. 클라이언트는 작은 무언가를 보유하고 있다가 다음 호출 시 이를 다시 전달합니다.
이미 서버를 실행 중이라면, 7월 28일에 서버가 망가지는 일은 없습니다. 7-28 버전을 지원하는 새로운 클라이언트는 이전 버전의 서버에 도달하면 기존의 'hello' 방식으로 폴백(fall back)합니다.
제가 첫 번째 초안에서 실수했기 때문에 주의하고 싶은 점이 하나 있습니다. 스펙(spec)은 프로토콜이 여러분을 위해 유지해주던 세션(session)을 제거했습니다. 하지만 제가 제 서버 내에 유지하고 있던 딕셔너리(dictionary)를 제거한 것은 아닙니다. 어떤 스펙도 그것을 제거할 수는 없습니다. 그것은 항상 제가 직접 해결해야 할 몫이었습니다. 7-28 버전이 하는 일은 그 '지팡이(crutch)'를 치워버리는 것입니다.
전체 릴리스 노트는 MCP 블로그에서 확인할 수 있으며, AAIF는 제가 여기서 다루는 것보다 더 깊이 있게 다루는 마이그레이션 가이드(migration guide)를 게시했습니다.
이전 방식: 상태가 서버의 메모리에 상주함
여기 교차 호출(cross-call) 상태가 하나의 프로세스에 머물러 있는, 순진한(naive) 방식으로 작성된 아주 작은 멘션 트래커(mention-tracker) 서버가 있습니다. 이것은 2025-11-25 스펙이 구축하라고 지시한 방식이 아닙니다. 노트북에서 하나의 프로세스, 하나의 클라이언트를 사용하며 아무런 문제가 발생하지 않았을 때 손쉽게 선택하게 되는 방식입니다. 이 서버는 키워드를 찾아 모의 멘션(mock mentions) 목록을 검색하고 결과를 페이지 단위로 넘겨볼 수 있게 해줍니다.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("mention-tracker")
...
get_next_page()가 인자를 전혀 받지 않는다는 점에 주목하세요. 이것이 작동하는 이유는 바로 이 프로세스가 마지막 커서(cursor)를 기억하고 있기 때문입니다. 커서는 북마크와 같으며, 결과 중 어디까지 읽었는지를 나타내는 숫자입니다. 여기서의 상태(state)는 세션 상태(session state)보다 더 나쁜 상황이며, 이후에 와이어 호출(wire calls)을 확인하고 나면 왜 그런지 다시 설명하겠습니다. 다음은 이 데모가 stdio를 통해 전송하는 실제 JSON-RPC 교환 내용입니다. JSON-RPC는 MCP가 사용하는 메시지 형식일 뿐입니다: 함수 이름, 몇 가지 인자, 그리고 응답으로 구성됩니다. 그리고 stdio는 클라이언트와 서버가 터미널을 통해, 즉 하나의 프로세스에서 다른 하나의 프로세스로 대화하고 있음을 의미하며, 이것이 여러분이 노트북에서 이를 실행하는 방식입니다. 따라서 이것은 단일 프로세스와 대화하는 하나의 클라이언트이며, Pod A와 Pod B로의 라우팅(routing) 상황을 재현하고 있지는 않습니다. 하지만 이것은 그 라우팅을 위험하게 만드는 요소를 보여줍니다:
start_search {"keyword": "mcp"} ->
{
"meta": null,
...
get_next_page {} ->
{
"meta": null,
...
두 번째 호출을 보세요: "arguments": {}". 여기에는 검색을 식별하는 것이 아무것도 없습니다. 이 코드가 응답할 수 있는 유일한 이유는 이 프로세스가 첫 번째 호출에서 커서(cursor)를 여전히 가지고 있기 때문입니다. start_search를 제공하지 않은 프로세스를 가리키게 하면, 페이지를 넘길 결과가 아무것도 없다는 것을 알 수 있습니다.
놀라운 점 1: 세션 상태보다 더 심각했다
이전 서버를 작성하면서 발견했는데, 로드 밸런서 이야기보다 저를 더 괴롭혔습니다. _search_state["current"]는 세션이나 호출자에게 범위가 지정되어 있지 않습니다. 이것은 전체 프로세스에 대한 하나의 슬롯이며, 리터럴 문자열 "current"로 키가 지정됩니다. 세션 고정(Session-pinning)은 로드 밸런서가 동일한 클라이언트를 동일한 복제본으로 계속 보내는 것을 의미합니다. 적어도 이는 요청이 당신의 검색에 대해 알고 있는 파드(pod)에 계속 도달하도록 보장해 줍니다. 하지만 이것조차 제공하지 못합니다: 같은 프로세스에 접근하는 두 클라이언트가 슬롯을 공유하기 때문에, start_search를 마지막으로 호출한 사람이 승리하고 다른 사람의 커서는 조용히 다른 사람의 결과 집합으로 이동합니다. 로드 밸런서 이야기는 당신이 알아차리는 실패입니다. 이것은 당신이 알아차리지 못하는 실패입니다.
새로운 방식: 상태가 호출과 함께 이동하다
그래서 저는 서버를 재구축했고, 가장 먼저 해결해야 할 것은 클라이언트가 호출 간에 무엇을 가지고 이동할지였습니다.
놀라운 점 2: 핸들(handle)이 무엇인지 결정해야 했다
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기