-
로컬 챗봇 시리즈 #9 — MCP 외부 서버 장애를 graceful하게 흡수하는 디자인: '채팅이 안 막히는 게 우선'IT 2026. 5. 9. 22:00
들어가며 — 외부 도구 서버는 챗봇이 통제할 수 없다
2024년 말 Anthropic이 발표한 MCP(Model Context Protocol)는 "도구 추가" 부담을 거의 없앴다. Filesystem, GitHub, Brave Search, Memory 같은 표준 서버를 등록만 하면 바로 챗봇 도구로 합류한다. 봇의
mcp_servers: ["filesystem", "github"]한 줄이 도구 5-10개 추가와 같다.그런데 이 좋아 보이는 디자인에는 한 가지 함정이 있다. 외부 서버는 챗봇이 통제하지 못한다. 인터넷이 끊기면, 원격 MCP가 다운되면, stdio MCP의 npx 프로세스가 부팅에 실패하면 — 그 모든 실패가 챗봇으로 전파되면 안 된다. 이번 글은 "채팅이 외부 의존에 끌려가지 않게 하는" 한 줄짜리 디자인 결정을 풀어쓴다.
1. 두 디자인 비교 — exception vs graceful
MCP 도구를 fetch하는 함수는 단순하다.
MultiServerMCPClient.get_tools()를 한 번 호출하면 끝이다. 그런데 실패 처리에서 두 갈래로 갈린다. 그림으로 비교:이 그림이 보여주는 두 디자인의 결정적 차이 — 왼쪽(A)은 외부 의존성의 실패가 챗봇 핵심 흐름까지 전파된다. ConnectionError가 load_tools에서 시작해 handle_agent_chat까지 거슬러 올라가 사용자에게 빈 응답으로 도달한다. 사용자 입장에서는 "GitHub MCP 안 써도 되는 일반 코딩 질문인데도 답을 못 받는 상황"이 된다. 외부 의존성 1개의 일시 장애가 모든 채팅을 막아버린 것이다. 오른쪽(B)은 같은 ConnectionError가 load_tools 안에서 멈춘다 — log.warning으로 운영자에게 신호를 보내고, 호출자에게는 "도구 0개 fetch했어요"라는 정상 응답(빈 리스트)을 돌려준다. 호출자는 그것을 받아 평소 흐름대로 진행 — CORE 도구만으로 답한다. 외부 서버 다운이 사용자에게 보이지 않게 흡수된다.
채택은 B다. 코드는 거짓말처럼 단순하다.
# mcp_client.py async def load_tools(server_names: list[str]) -> list: """활성 서버에서 LangChain Tool 객체 fetch. 실패는 흡수.""" if not server_names: return [] try: client = MultiServerMCPClient(build_client_config(server_names)) return list(await client.get_tools() or []) except Exception as e: log.warning("MCP 도구 로드 실패: %s", e) return []이 6줄짜리 함수의 디자인 비결은 두 가지다. 첫째는
except Exception의 광범위함이다. 보통 좋은 Python 스타일은 구체적인 예외(ConnectionError, TimeoutError 등)만 잡으라고 권한다. 그러나 여기서는 의도적으로 모든 예외를 잡는다. 외부 MCP 서버가 던질 수 있는 예외 종류가 너무 다양하고(네트워크, 인증, 프로토콜 버전 불일치, 프로세스 부팅 실패 등) 모두 같은 의미다 — "이 서버를 못 쓴다"는 신호다. 광범위한 catch가 의도다. 둘째는 실패 시 빈 리스트를 반환한다는 점이다. 호출자가 None 체크 없이 그대로 list로 사용할 수 있게 한다. 이게 다음 섹션에서 다룰 "graceful의 핵심"이다.핵심은
except Exception의 광범위함과log.warning + return []의 단순함이다. 외부 의존 실패가 호출자의 정상 흐름에 끼어들지 못한다. 호출자(에이전트)는 "MCP 도구가 0개구나, 그럼 CORE 도구로 답하자"로 자연스럽게 이어간다.
2. graceful 디자인의 비밀 — "성공도 실패도 같은 타입"
이 패턴이 동작하는 이유 — 성공 케이스("MCP 도구 5개")와 실패 케이스("도구 0개")가 같은 반환 타입(
list)이다. 호출자 코드는 두 케이스를 분기할 필요 없이 그대로 처리한다. 그림으로 보면:이 그림이 보여주는 핵심 디자인 패턴은 "성공과 실패가 같은 형태의 데이터로 표현된다"는 점이다. 위쪽 두 박스를 비교해 보면 — 성공 시
load_tools()가 도구 5개의 리스트를 반환하고, 실패 시 빈 리스트를 반환한다. 둘 다 list 타입이다. 아래쪽 박스(에이전트)는tools.extend(extra_tools)한 줄로 처리한다 — 빈 리스트면 extend가 no-op이고, 비어있지 않으면 도구가 추가된다. 호출자 코드에 if/else 분기가 없다. 이게 graceful의 진짜 가치다 — 단순히 "예외를 안 던진다"가 아니라 "정상 흐름과 실패 흐름의 코드 경로가 같다"는 점이다.이게 "graceful"의 핵심이다 — 단순히 try/except로 묶는 게 아니라, "실패 시 반환할 값이 호출자에게도 의미 있는 빈 값"이 되도록 데이터 구조를 설계하는 것이다. 빈 리스트는 "도구 없음"이라는 명확한 의미를 가진다. None을 반환하면 호출자가 매번 None 체크해야 하는데, 빈 리스트는 그럴 필요 없다.
3. 트레이드오프 — graceful이 가린 것들
3-1. 사용자가 외부 서버 장애를 즉각 모른다 — 운영 가시성의 비용
이 디자인의 가장 큰 함정이다. MCP 서버가 다운되면 사용자 응답에는 아무 표시가 없다. 봇이 "GitHub MCP 활성"으로 설정되어 있어 사용자가 "내 PR 목록 알려줘"라고 물으면 — 평소에는 GitHub MCP 도구가 호출되어 PR 목록이 나오지만, 서버가 다운된 시점에는 도구가 안 합류해서 모델이 "현재 PR 목록을 가져올 도구가 없습니다"라고 답하거나 자체 지식으로 환각한다. 사용자는 "왜 이 봇이 갑자기 PR을 못 찾지?"라고 느낄 뿐이다.
로그에는
log.warning("MCP 도구 로드 실패")가 남지만 사용자는 그것을 못 본다. 이게 트레이드오프 — 채팅 흐름은 안 막히지만 운영 가시성이 떨어진다. 외부 서버 장애가 누적되어도 사용자 불만이 누적될 때야 발견된다.완화책은 두 가지다. 첫째는, 응답에 작게 안내 메시지를 끼우는 것이다. 예를 들어 봇이 활성화한 MCP 서버 중 일부가 실패했으면 응답 끝에 작은 글씨로 "([github] MCP 서버 일시 장애)"라고 표시한다. 사용자가 즉시 인지하고 운영자에게 알릴 수 있다. 둘째는 별도 헬스체크 endpoint로 운영자가 주기적으로 모니터링하는 방법이다. 챗봇이 5분마다 등록된 모든 MCP 서버에 dummy 요청을 보내 상태를 체크하고, 실패한 서버 목록을 admin 페이지에 표시한다. 두 완화책 모두 인프라 추가 작업이라 1인 사용자 챗봇에서는 단순 log.warning에 의존하고, 다중 사용자 환경으로 옮기면 두 번째 완화책이 거의 의무가 된다.
3-2. 매 채팅 요청마다 MCP 도구 fetch 비용 — 캐싱이 없는 단순함의 대가
load_tools()는 매 요청마다 호출된다. 캐시는 없다. 첫 호출이 ~100-500ms 추가되고, stdio MCP 서버는 프로세스 부팅 비용까지 더해진다. 봇이 활성한 MCP 서버 2개라면 합산 1초 가까이 응답이 늦어질 수 있다.왜 캐싱을 안 하는가? 캐싱을 하려면 캐시 무효화 정책이 필요하다 — MCP 서버가 재시작되거나, 새 도구가 추가되거나, 도구 시그니처가 변경되거나, 인증이 만료되는 시점에 캐시를 새로 빌드해야 한다. 외부 서버의 상태 변화를 챗봇이 감지하기 어렵다. 캐시 TTL을 짧게(예: 5분) 잡으면 그동안의 변경이 안 반영되고, 길게 잡으면 오래된 도구 메타로 잘못된 호출이 일어난다. "캐시 무효화는 컴퓨터 과학의 두 어려운 문제 중 하나"라는 격언대로, 외부 의존성의 캐싱은 더 어렵다.
그래서 현재는 매번 fetch한다. 응답 지연이 누적되어 사용자 불만이 생기면 그때 보수적인 캐싱(예: 같은 봇 내에서 60초 TTL)을 추가할 것이다. 운영해보니 의외로 사용자 불만이 없다 — MCP 도구 합류는 평상 채팅의 vLLM 추론 자체가 5-10초 걸리는 것에 비해 상대적으로 작은 비용이라 체감되지 않는다.
3-3. MCP 도구가 모델 컨텍스트를 늘린다 — 도구 paradox 재발
외부 MCP 서버 한 개가 5-10개 도구를 노출하면 시스템 프롬프트의 도구 메타가 그만큼 부풀어 오른다. 시리즈 #7의 "도구 paradox"가 다시 머리를 든다 — MCP 활성화로 도구가 갑자기 15-20개가 되면 모델이 헷갈린다. RAG 호출률이 다시 떨어지고, 모델이 외부 MCP 도구와 내부 도구 중 무엇을 부를지 망설인다.
구체적인 시나리오 — Coder 봇이 search_knowledge_vault, web_search, search_attachment 3개의 CORE 도구로 정확도 95%를 유지하고 있었다고 하자. 거기에 GitHub MCP 서버를 활성화하면 issue_read, pr_read, search_code 등 8개 도구가 추가로 합류한다. 도구 풀이 11개가 되면서 모델 망설임이 시작된다 — "이 코딩 질문에 search_knowledge_vault를 부를까, GitHub의 search_code를 부를까". 정확도가 80% 정도로 떨어진다. MCP 합류의 가치(GitHub PR 자동 조회 등)가 정확도 손실보다 큰지 매번 평가가 필요하다.
이 문제를 회피하는 방법은 봇 단위로 좁히는 것이다. 한 봇이 한 MCP 서버만 활성화하는 식이다. "Coder" 봇은 CORE 도구만 + GitHub MCP를 묶고, "지식금고 검색가" 봇은 CORE 1개 + MCP 0개로 둔다. 봇별로 도구 풀을 쪼갠 디자인의 가치가 여기서 또 나온다. MCP를 활성화할 때마다 "이 봇의 도구 풀이 도구 paradox 영역으로 들어가지 않는지" 확인하는 습관이 운영 노하우가 된다.
4. 마무리
"외부 서버 한 개의 다운이 모든 채팅을 막지 않는다"는 일반 분산 시스템의 격리 원칙을 챗봇에 적용한 게 이번 묶음의 한 줄이다. 코드는
except Exception: return []한 줄이지만, 그 디자인의 영향은 운영 환경의 안정성에 누적된다.다른 부수 — MCP 서버 등록은 단순 JSON, langchain-mcp-adapters로 LangChain Tool 변환, stdio/streamable_http 두 transport, MCP 도구는 봇의 화이트리스트 우회 — 는 표준 패턴이다.
다음 편은 Artifact 사이드 패널이다. Claude.ai의 그 분리 패널을 정규식 한 줄과 sandboxed iframe으로 흉내낸 이야기, 그 도중 만난 [hidden] CSS 버그를 다룬다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
Whisper의 빔 서치를 살리는 한 줄 — beam_size 1과 5의 차이 (0) 2026.05.10 Whisper Small에서 Turbo로 — 아이 발음을 위한 STT 모델 선택 (0) 2026.05.10 로컬 챗봇 시리즈 #12 (완) — 정책을 데이터로 표현하기: jobs.conf 한 줄이 모든 GPU 동거 정책을 결정한다 (0) 2026.05.09 로컬 챗봇 시리즈 #11 — Esc 한 키가 깨끗해야 한다: UI 임시 상태의 우선순위 스택 디자인 (0) 2026.05.09 로컬 챗봇 시리즈 #10 — [hidden] 속성이 안 먹는 한 시간: HTML5의 작은 속성과 컴포넌트 CSS의 충돌 (0) 2026.05.09 로컬 챗봇 시리즈 #8 — 봇은 도구 풀을 좁히는 장치다: '지식금고 검색가' 한 도구 봇이 가장 효과적인 이유 (0) 2026.05.09 로컬 챗봇 시리즈 #7 — 도구 11개가 모이면 모델이 헷갈리기 시작한다: 풀 격리와 _safe_tool 안전판 (0) 2026.05.09 로컬 챗봇 시리즈 #6 — LLM을 우회하는 슬래시 커맨드: 작은 모델의 메타 판단 한계를 사용자가 보완하는 채널 (0) 2026.05.08 로컬 챗봇 시리즈 #5 — 다른 프로젝트의 venv를 subprocess로 빌려쓰기: 의존성 추가를 거부하는 영리함 (0) 2026.05.08 로컬 챗봇 시리즈 #4 — Vision 32B에서 7B로, 그리고 포기까지 — 두 vLLM 동거 시행착오 (0) 2026.05.08