MCP 404 오류가 회로 차단기(Circuit Breaker)를 작동시켜 유효한 읽기 요청을 누락시키는 현상
요약
에이전트가 MCP 도구 호출 시 발생하는 정상적인 404 응답을 오류로 오인하여, 런타임의 회로 차단기(Circuit Breaker)가 작동해 유효한 요청까지 차단하는 현상을 분석합니다. 모델의 추측성 병렬 호출이 연속된 404를 유발하여 도구 사용 불능 상태를 만드는 메커니즘을 설명합니다.
핵심 포인트
- 404 응답이 데이터 부재를 알리는 정상적인 정보 전달형 응답임에도 에이전트 런타임은 이를 실패로 기록함
- 소형 모델의 추측성 병렬 호출이 짧은 시간 내에 다수의 404를 발생시킴
- 연속된 실패로 인해 회로 차단기가 작동하면 이후의 유효한 도구 호출까지 모두 차단됨
- HTTP 클라이언트의 raise_for_status() 처리가 에이전트 안정성에 미치는 영향
에이전트가 간헐적으로 도구(tool)를 "잃어버리는" 현상이 발생합니다. 센서 값을 열 번은 정상적으로 읽다가, 상위 서버(upstream server)의 자체 UI에서는 데이터가 반환되는 것을 눈으로 확인할 수 있는 경로임에도 불구하고 값이 사용할 수 없다고 단정적으로 주장합니다. 세션을 재시작하면 잠시 동안은 다시 정상 작동합니다. 원인은 도구도 아니고 서버도 아닙니다. HTTP 클라이언트의 raise_for_status()가 지극히 정상적인 404 ("not published") 응답을 도구의 _실패(failure)_로 변환하고, 이러한 오류가 일시적으로 급증하면서 에이전트 런타임(agent runtime)의 **연속 실패 회로 차단기(consecutive-failure circuit breaker)**를 작동시켜, 그 뒤에 대기 중인 유효한(valid) 호출들을 억제해 버리는 것이 원인입니다.
이것은 "부재(absent)\
raise_for_status()는 모든 4xx/5xx 오류에 대해 HTTPStatusError를 발생시킵니다. get_value를 호출하는 MCP 도구(tool)는 해당 예외(exception)가 전파되도록 내버려 두며, 결과적으로 런타임(runtime)은 해당 도구 호출을 실패로 기록합니다. 이는 합리적으로 보이지만, 무엇이 404를 발생시키는지, 그리고 얼마나 자주 발생하는지를 살펴보기 전까지만 그렇습니다.
진단 (Diagnosis)
두 가지 사실이 충돌합니다.
사실 1: 여기서 404는 "이 경로가 게시되지 않음"을 의미하며, 이는 정상입니다. 전자 나침반이 없는 보트는 navigation.headingMagnetic을 게시하지 않습니다. 정박 중인 선박은 navigation.courseOverGroundTrue를 게시하지 않습니다 (움직이지 않을 때는 코스(course)가 없기 때문입니다). 서버의 REST API는 이러한 요청에 대해 404로 응답합니다. 이것은 오류 상태가 아니라, API가 _"해당 정보가 없습니다"_라고 말하는 방식입니다. 수많은 HTTP API에서도 마찬가지입니다. 존재하지 않는 리소스에 대한 404는 결함이 아니라, 예상된 정보 전달형 응답입니다.
사실 2: 에이전트(agent)는 설계상 404를 폭발적으로(in bursts) 생성합니다. 소형 모델(이 사례에서는 로컬의 ~30B 모델)에게 "코스와 속도는?"이라고 물으면, 모델은 정확한 경로를 알지 못하므로 _추측(guesses)_을 하며 병렬 호출을 확산(fanning out)시킵니다:
read_sensor("navigation.headingTrue") -> 404 (나침반 없음)
read_sensor("navigation.headingMagnetic") -> 404 (나침반 없음)
read_sensor("sensors.depth") -> 404 (잘못된 네임스페이스)
...
세 번의 추측이 404를 발생시키면, 그 바로 뒤에 올바른 경로가 대기열에 들어갑니다. 단순한(naive) 클라이언트의 경우, 이 세 번의 404는 세 번의 예외 발생으로 이어지며, 이는 곧 **연속된 세 번의 도구 실패(tool failures)**로 기록됩니다.
이제 런타임(runtime)을 살펴봅시다. 에이전트 런타임은 도구별로 **회로 차단기 (circuit breaker)**를 실행합니다. 동일한 도구에서 N번의 연속된 실패가 발생하면, 차단기가 열리고(opens), 런타임은 해당 도구에 대한 호출을 중단하고 냉각 기간(cooldown)을 갖는 대신 즉시 "도구 사용 불가(tool unavailable)"를 반환합니다. 널리 인용되는 기본값은 N = 3입니다 (agent loops에서의 반복적인 도구 실패에 대한 circuit breaker; MCP circuit breaker 패턴). 우리의 설정도 정확히 3이었습니다.
따라서 다음과 같은 상황이 발생합니다: 세 번의 추측된 경로에 의한 404 오류가 차단기를 작동시키고, 차단기가 열리면, 그 뒤에 대기 중이던 유효한 courseOverGroundTrue 읽기 요청은 결코 전송되지 않습니다. 에이전트는 내내 데이터를 제공하고 있었던 경로에 대해 "도구 사용 불가"라는 응답을 받게 됩니다. 모델의 잘못된 추측이 *트리거(trigger)*가 되지만, 404에 대해 raise_for_status()를 호출하는 것이 이 상황을 무해한 것이 아닌 치명적인(fatal) 상태로 만듭니다.
심지어 이는 서버 충돌(server crash)처럼 위장하기도 합니다. 상류(upstream) 액세스 로그를 보면, 서버는 마지막 404 오류 전까지는 요청에 기쁘게 응답하다가 — 그 이후로는 아무것도 보지 못합니다. 이후의 요청들은 클라이언트 측에서 차단기가 열렸기 때문에 클라이언트를 떠나지도 못했기 때문입니다. 유령 같은 상류 서비스 중단을 쫓아다니지 마세요. 대신 런타임의 실패 카운터(failure counter)를 확인하십시오.
우리가 시도한 것 (그리고 실패한 이유)
시도 1 — 모든 non-2xx 응답에 대해 raise (시작점)
resp = await self._http.get(url)
resp.raise_for_status() # 500과 마찬가지로 404에서 HTTPStatusError를 발생시킴
return resp.json()
이것이 버그의 원인입니다. raise_for_status()는 "리소스가 없음" (404)과 "서버가 고장 남" (500)을 구분하지 않습니다. 모든 추측된 경로는 도구 실패(tool failure)가 됩니다. 세 번 연속 발생 → 차단기 열림 → 유효한 읽기 요청 누락. 근본적인 계약(contract)이 잘못되었습니다. 우리는 *부재(absence)*를 *실패(failure)*로 보고하고 있습니다.
시도 2 — 단순히 차단기 임계값(threshold) 높이기
3이 너무 적다면, 값을 높입니다:
# agent runtime config
tool_failures:
same_tool_failure: 8 # 기존에는 3이었음
이것은 아무것도 해결하지 못하며, 단지 진입 장벽(price of admission)만 높일 뿐입니다. 더 수다스러운 모델이나, 더 많은 추측으로 확산되는 질문은 여전히 8번의 실패를 넘어설 것입니다. 설상가상으로, 이제 당신은 실제 장애(real outages)에 대한 차단기(breaker)의 성능을 무디게 만들었습니다. 상위 시스템(upstream)이 실제로 다운되었을 때, 런타임은 이제 스스로를 보호하기 전에 8번의 실패한 호출을 소모하게 됩니다. 당신은 오탐(false-positive) 문제를 느린 정탐(true-positive) 문제로 맞바꾼 것입니다. 잘못된 것은 차단기가 아니라, 차단기에 입력되는 실패 _분류(classification)_입니다.
시도 3 — 실패한 호출 재시도 (retry)
"일시적인(transient)" 실패가 다시 시도될 수 있도록 호출을 재시도 로직으로 감쌉니다:
for attempt in range(3):
resp = await self._http.get(url)
if resp.status_code < 400:
...
404는 일시적이지 않습니다. 0ms 시점에 게시되지 않은 경로는 600ms 시점에도 여전히 게시되지 않은 상태입니다. 이 방식이 하는 일은 단지 세 번의 빠른 404 오류를 아홉 번의 느린 오류로 바꾸는 것뿐입니다. 이는 차단기를 향한 더 많은 실패와 지연 시간(latency)을 초래하며, 차단기는 여전히 작동(open)하게 됩니다. 재시도는 타임아웃(timeout)이나 5xx 오류에는 적합한 도구이지만, "리소스가 존재하지 않음"에는 잘못된 도구입니다.
시도 4 — 프롬프트(prompt)에서 수정
모델이 추측을 멈출 수 있도록 정확한 경로를 알려줍니다:
코스(course)를 요청받으면, navigation.courseOverGroundTrue를 읽으세요.
헤딩(heading)을 요청받으면, navigation.headingTrue를 읽으세요.
절대 sensors.depth를 읽지 마세요; environment.depth.belowTransducer를 사용하세요.
경로 힌트는 추측을 줄여주며, 가치가 있는 방법입니다. 하지만 이는 모델에 의존적이고 취약하며, 도구 계층(tool layer)을 안전하게 만들지는 못합니다. 다른 모델을 사용하거나, 질문이 바뀌거나, 힌트 목록에서 누락된 경로가 나타나면, 다시 404 폭주로 인해 차단기가 작동하는 상황으로 돌아가게 됩니다. 차단기 안전성(Breaker-safety)은 다음 모델 릴리스에서 무시될 수도 있는 프롬프트가 아니라, 결정론적인(deterministic) 도구 계층에 존재해야 합니다.
해결책
404를 에러가 아닌 **null 값이 포함된 결과(null-valued result)**로 취급하세요. 예외를 발생시키는 대신 {"value": None}을 반환하십시오. 그리고 실제 결함(5xx, 연결 오류, 타임아웃)에 대해서는 계속해서 예외를 발생시키십시오. 왜냐하면 그것들이 바로 차단기가 감지하고 대응해야 하는 대상이기 때문입니다.
async def get_value(self, path: str) -> dict:
"""Upstream API로부터 값 객체(value object)를 가져옵니다.
...
한 가지 방법이 있습니다. raise_for_status()를 호출하기 전에 404를 확인하여 null 결과를 반환하고, raise_for_status()가 정말로 문제가 발생한 케이스들을 처리하도록 두는 것입니다. 이렇게 하면 차단기(breaker)는 여전히 실제 업스트림 장애(연속적인 500 오류 등)로부터 시스템을 보호하지만(500 오류가 지속되면 여전히 차단기가 작동합니다), "게시되지 않은" 경로에 대한 추측성 요청 폭풍(guess-storm)은 이제 value=None으로서 무해하게 흘러가게 됩니다.
부재(absence)가 유효한 답변이 될 수 있는 모든 엔드포인트(경로 탐색을 위한 트리 워크(tree-walk), 하위 리소스 가져오기 등)에도 동일한 규칙을 적용하십시오:
async def get_subtree(self, root: str) -> dict:
resp = await self._http.get(f"{self.base_url}/api/.../{root}")
if resp.status_code == 404:
...
여기에는 조용한 보너스가 있습니다. 존재하지만 null인 값(예: 정지 중인 코스(course), API가 null로 반환함)과 부재하는 경로(404)가 이제 동일한 형태인 value=None으로 나타난다는 점입니다. 에이전트는 하나는 null을 반환하고 다른 하나는 예외를 던지는 상황 대신, 추론할 수 있는 하나의 일관된 "사용 불가능(unavailable)" 개념을 갖게 됩니다.
이것이 중요한 이유 / 주의사항 (gotchas)
이는 SignalK를 넘어 "부재"가 정당한 결과가 될 수 있는 HTTP API 상의 모든 에이전트/MCP 도구로 일반화됩니다. 패턴은 항상 동일합니다:
- HTTP 경계에서 "부재 (absent)"와 "고장 (broken)"을 구분하십시오. 404(그리고 종종 빈 200 응답)는 데이터가 그곳에 없음을 의미하며, 이는 실패가 아닌 하나의 답변입니다. 5xx, 연결 거부(connection refused), 타임아웃(timeouts)은 시스템이 고장 났음을 의미하며, 이것들이 바로 실패이며 회로 차단기(Circuit Breaker)가 감지해야 할 대상입니다. 무분별한
raise_for_status()를 사용하여 이 둘을 하나로 뭉뚱그리는 것이 근본적인 실수입니다. - 회로 차단기는 잘못 분류된 오류를 도구 상실의 연쇄 반응으로 증폭시킵니다. 회로 차단기는 승수(multiplier) 역할을 합니다. 하나의 정상적인 응답을 실패로 잘못 분류하면, 회로 차단기는 냉각 시간(cooldown window) 동안 발생하는 수많은 정상 호출을 모두 실패로 전환해 버립니다. "404 발생(raises)"의 폭발 반경(blast radius)은 단 하나의 잘못된 읽기가 아니라, 그 뒤에 따르는 모든 정상적인 읽기입니다.
- 에이전트는 추측합니다; "찾을 수 없음"이 폭발적으로 발생할 것이라고 가정하십시오. 경로(path) 또는 ID 기반 API를 구동하는 모든 모델은 잘못된 키를 탐색할 것이며, 특히 크기가 작은 로컬 모델(local models)일수록 더욱 그렇습니다. 이를 병리적인 현상으로 취급하지 마십시오. 추측 비용을 낮출 수 있도록 *부재 (absence)*를 실패하지 않는 일급 객체(first-class) 결과로 취급하십시오.
- 이 문제를 덮기 위해 회로 차단기의 성능을 무디게 만들지 마십시오. 임계값(threshold)을 높이거나 404에 대해 재시도(retry)하는 것 모두, 회로 차단기가 본연의 임무(실제 장애 감지)를 수행하는 능력을 저하시키면서 잘못된 작동(false trip)을 지연시킬 뿐입니다. 회로 차단기를 고치지 말고 분류(classification)를 고치십시오.
- 상류(upstream)의 충돌처럼 보이지만 실제로는 그렇지 않습니다. 클라이언트 측에서 회로 차단기가 열리면(open), 상류의 액세스 로그(access log)는 그저 조용해질 뿐입니다. 이후의 요청들이 에이전트를 떠나지 않기 때문입니다. 완벽하게 정상적인 서버를 디버깅하러 가기 전에 런타임의 실패 카운터(failure counter)를 먼저 확인하십시오.
상태 코드(status code)가 예외를 발생시켜야 하는지에 대한 한 줄 테스트: 정상적인 시스템이, 올바르게 사용되었을 때, 과연 이것을 반환할 수 있는가? 리소스 누락에 따른 404의 경우, 대답은 "예"입니다. 따라서 예외를 발생시켜서는 안 됩니다.
결론 (Close)
이 내용은 전동 차터 카타마란(all-electric charter catamaran)을 위한 AI Ops 레이어를 구축하는 과정에서 도출되었습니다. 이는 SignalK 해양 데이터 서버를 통해 MCP 도구를 구동하는 로컬 LLM을 사용하는 프로젝트였습니다. SignalK를 래핑(wrap)하는 도구는 오픈 소스이며, 404 처리 기능도 포함되어 있습니다: github.com/sailingnaturali/signalk-mcp.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기