Bedrock 비용을 세 배로 늘리는 재시도 루프 (The Retry Loop That's Tripling Your Bedrock Bill)
요약
MCP(Model Context Protocol) 환경에서 도구 에러가 HTTP 200 응답으로 반환될 때 에이전트가 무한 재시도 루프에 빠져 Bedrock 비용이 급증하는 문제를 다룹니다. v0.6.1 업데이트를 통해 동일한 실패가 반복될 경우 이를 감지하고 하나의 이벤트로 통합하는 'Thrash detection' 기능을 소개합니다.
핵심 포인트
- 도구 에러가 성공적인 JSON-RPC 응답으로 반환되어 에이전트가 재시도를 반복함
- 재시도 시 누적되는 컨텍스트로 인해 Bedrock 호출 비용이 기하급수적으로 증가
- v0.6.1의 Thrash detection 기능이 반복되는 실패 루프를 감지하고 통합
- mcp.loop.detected 이벤트를 통해 낭비된 토큰과 근본 원인 스팬을 식별 가능
6개의 녹색 스팬, 하나의 고장 난 도구
비용 귀속(cost attribution) 기능을 출시하고 몇 주 후, 나는 건강해 보이지만 전혀 말이 되지 않는 트레이스(trace)를 뚫어지게 쳐다보고 있었다.
한 세션 내에서 동일한 도구에 대한 호출이 6번 발생했다. 모두 HTTP 200이었고, 모든 스팬(span) 상태도 OK였다. 총 소요 시간은 약 4초였다. 트레이스 상에서는 그 어떤 것도 잘못되었다는 신호를 보내지 않았다.
하지만 그 도구는 6번 모두 실패했다. 매 호출마다 동일한 에러가 발생했다. 에이전트(agent)는 이를 알아차리지 못했다.
내가 이를 발견할 수 있었던 이유는 v0.5.0에서 구축한 비용 귀속 기능이 하나의 논리적 작업에 대해 6번의 InvokeModel 비용을 청구했기 때문이다. 트레이스는 모든 것이 성공했다고 말했지만, 달러(비용)는 그렇지 않았다.
그 간극을 메우는 것이 바로 v0.6.1이다.
에이전트가 계속 재시도하는 이유
MCP에는 두 가지 에러 채널이 있으며, 그중 하나만이 당신이 기대하는 방식으로 작동한다.
프로토콜 에러(Protocol errors) — 알 수 없는 메서드, 잘못된 형식의 요청 등 — 는 JSON-RPC 에러 객체로 반환된다. 이것들은 정상적으로 전파된다.
도구 에러(Tool errors)는 그렇지 않다. 도구가 실행되고 실패할 때, 서버는 결과 내부에 isError 플래그를 포함한 성공적인 JSON-RPC 응답을 반환한다:
HTTP/1.1 200 OK
{
...
아무것도 예외를 던지지(throw) 않고, 아무것도 거부(reject)하지 않는다. 일반적인 인스트루멘테이션(instrumentation)은 전송 상태를 읽고 200을 확인한 뒤 스팬을 OK로 표시한다.
이제 에이전트가 무엇을 보는지 생각해 보자. 에이전트는 해당 에러 텍스트를 정상적인 도구 결과로 돌려받는다. 모델의 관점에서는 아무것도 실패하지 않았다. 단지 자신의 입력에 대한 불만처럼 읽히는 콘텐츠를 받았을 뿐이다. 그래서 에이전트는 합리적인 행동을 한다. 인자(arguments)를 다시 작성하고 다시 시도하는 것이다.
도구는 동일하게 실패한다. 에이전트는 다시 시도한다.
각 재시도마다 누적된 컨텍스트(context)를 Bedrock으로 다시 전송하며, 컨텍스트는 이전의 실패한 결과로 인해 계속 커진다. 따라서 재시도가 반복될수록 비용은 점진적으로 더 비싸진다:
| 시도 (Attempt) | 입력 토큰 (Input tokens) | 출력 토큰 (Output tokens) |
|---|---|---|
| 1 | 8,000 | 300 |
| ... | ||
| 하나의 재시도 루프에 대한 예시적인 토큰 증가량 — 실제 수치는 컨텍스트 크기와 에이전트의 재시도 동작에 따라 완전히 달라집니다. |
결과물은 아무것도 내놓지 못하는 6번의 InvokeModel 청구 호출이 발생했지만, 이를 지목할 수 있는 에러 스팬(error span)은 단 하나도 없었다.
Thrash detection(thrash detection)이 방출하는 것
v0.6.1은 하나의 세션 내에서 동일한 도구(tool)가 **동일한 실패 지문(failure fingerprint)**으로 반복해서 실패하는지 감시합니다. 이것이 임계값을 넘으면, N개의 구별 불가능한 스팬(span)들에 흩어져 있게 두는 대신, 전체 루프를 담은 하나의 이벤트를 방출합니다.
스팬 이벤트(Span event): mcp.loop.detected
mcp.loop.length 6
mcp.loop.wasted_tokens_in 54000
mcp.loop.wasted_tokens_out 1800
...
first_span_id는 새벽 2시에 여러분의 시간을 아껴줄 요소입니다. 이는 루프가 시작된 스팬을 가리키며, 실제 근본 원인(root cause)이 존재하는 곳입니다. 나머지 다섯 개는 그저 메아리일 뿐입니다.
다섯 가지 메트릭(metrics)
mcp.tool.loop.detected Counter
mcp.tool.loop.length Histogram
mcp.tool.loop.wasted_tokens Histogram tokens
...
v0.5.0과 동일한 역할 분담입니다. 카운터(counter)는 알림(alerting)과 추세선(trend lines)을 제공하고, 스팬 이벤트는 알림이 발생했을 때 상세 분석(drill-down)을 가능하게 합니다.
그룹화(grouping)를 가능하게 하는 것은 지문(fingerprint)입니다
탐지는 v0.4.0의 실패 지문 생성(failure fingerprinting) 방식을 기반으로 하며, 이 부분이 노이즈가 아닌 유용한 기능이 되게 만드는 핵심입니다.
가공되지 않은 에러 문자열(Raw error strings)은 그룹화되지 않습니다. 동일하게 끊긴 연결이라도 매번 다른 메시지를 생성합니다. IP가 다르고, 요청 ID(request ID)가 다르고, 경로(path)가 다르기 때문입니다. 따라서 지문은 UUID, 경로, 숫자, 16진수 문자열(hex strings)을 제거하는 파이프라인을 통해 에러를 정규화(normalise)한 뒤, 그 결과를 해시(hash)하고 16자리의 16진수로 자릅니다.
connection refused: 10.0.0.5:5432 → a3f8c21d94b06e77
connection refused: 10.0.0.7:5432 → a3f8c21d94b06e77
timeout after 30000ms on req_88a1 → 6d10b4e7c2f3a915
...
부수적인 세부 사항에 관계없이 근본 원인이 같다면 지문도 같습니다. 여섯 개의 서로 다른 메시지를 가진 여섯 번의 실패는 하나의 탐지된 루프로 통합됩니다. 이것이 올바른 해석인데, 왜냐하면 그것은 실제로 하나의 문제이기 때문입니다.
이것이 또한 v0.6.1이 v0.4.0 및 v0.5.0 이전에 출시될 수 없었던 이유이기도 합니다. 루프 이벤트(loop event)는 실패들이 동일하다는 것을 알기 위해 지문(fingerprint)이 필요하며, 해당 루프에 비용이 얼마나 들었는지 말하기 위해 비용 할당(cost attribution)이 필요합니다. 두 기능 중 어느 것도 단독으로는 이를 생성할 수 없었습니다.
Bedrock 기반 MCP 서버 계측 (Instrumenting)
새로운 설정은 필요 없습니다. 이미 라이브러리를 실행 중이라면, 스래싱 탐지(thrash detection)는 기본적으로 활성화되어 있습니다:
import { instrumentMcpServer } from "opentel-mcp";
const server = instrumentMcpServer(mcpServer, {
...
60초 이내에 동일한 지문(fingerprint)으로 세 번 실패하는 모든 도구는 자동으로 플래그(flag)가 지정됩니다.
다음은 다운스트림(downstream) 테이블이 사라질 경우 스래싱(thrash)이 발생하는 Bedrock 도구의 예시입니다:
import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime";
import { instrumentMcpServer } from "opentel-mcp";
...
기저에 있는 테이블의 이름을 변경하면, 에이전트는 포기하기 전까지 이를 4~6회 재시도하며, 각 시도마다 Bedrock 비용을 청구합니다. v0.6.1을 사용하면 총 비용이 포함된 하나의 mcp.loop.detected 이벤트를 받게 됩니다.
설정 (Configuration)
모든 필드는 코드 내에서 또는 환경 변수(environment variable)를 통해 재정의할 수 있으므로, 재배포 없이도 환경별로 조정이 가능합니다:
| 옵션 | 환경 변수 | 기본값 | 설명 |
|---|---|---|---|
enabled | OTEL_MCP_THRASH_ENABLED | true | 탐지를 완전히 비활성화합니다 |
| ... |
유효하지 않은 환경 변수 값은 조용히 기본값으로 되돌아갑니다. 오류를 발생시키지 않습니다.
설명할 가치가 있는 네 가지 설계 결정
고카디널리티(High-cardinality) 속성은 절대 메트릭 레이블(metric labels)에 포함되지 않습니다
지문(fingerprints)은 경계가 없습니다. 모든 새로운 버그는 영구적으로 새로운 지문이 됩니다. 세션 ID(Session IDs)는 더 심각합니다. 이 중 어느 하나라도 메트릭 차원(metric dimension)에 넣으면, 버그 및 세션당 영구적으로 새로운 시계열(time series)이 생성됩니다. CloudWatch에서는 비용이 빠르게 증가하며, 어떤 백엔드에서도 결국 시스템이 무너지게 됩니다.
따라서 모든 메트릭(metric)은 gen_ai.tool.name만 포함합니다. 지문(fingerprint)과 세션 ID(session ID)는 스팬 이벤트(span event)에 저장되는데, 이곳은 높은 카디널리티(high cardinality)를 수용하기에 안전하며, 나중에 상세 분석을 할 때 어차피 모든 세부 정보를 얻을 수 있기 때문입니다. 허용 목록(allowlist)은 관례가 아닌 코드 수준에서 강제됩니다. 왜냐하면 "여기에는 속성을 추가하지 말 것"이라는 기억에 의존하는 방식은 미래의 자신과 마주했을 때 살아남을 수 있는 전략이 아니기 때문입니다.
추적 저장소는 제한적이며, 타이머는 없습니다
루프 탐지(Loop detection)는 최근의 실패 사례를 기억해야 하므로 상태(state)를 유지해야 합니다. stdio 전송(transport) 방식의 MCP 서버는 프로세스의 수명 동안 실행되며, 때로는 몇 주 동안 지속되기도 합니다. 여기서 제한이 없는 맵(map)을 사용한다면, 가장 오래 실행되는 배포 환경에서만 나타나는 느린 메모리 누수(memory leak)가 될 것이며, 그런 환경은 바로 여러분이 가장 디버깅하고 싶지 않은 환경일 것입니다.
그래서 TTL(Time To Live)이 적용된 LRU(Least Recently Used) 방식을 사용합니다. 키(key)의 개수를 제한하고, 읽기 시점에 지연 만료(lazy expiry)를 적용하며, 쓰기 시점에 분할 상환 방식의 스윕(amortised sweep)을 수행합니다. 의도적으로 setInterval은 사용하지 않았습니다. 활성화된 타이머는 Node 이벤트 루프(event loop)를 계속 실행 상태로 유지하여 서버가 깔끔하게 종료되는 것을 방해하며, 이는 그 자체로 미묘한 버그가 됩니다.
세션은 추측하여 병합하지 않습니다
이 설정은 잘못 구성될 가능성이 가장 높으므로 명시적으로 다룰 가치가 있습니다.
루프 탐지에는 세션 경계(session boundary)가 필요합니다. 두 클라이언트의 실패를 하나의 버킷(bucket)으로 병합하면 유령 루프(phantom loop)가 발생합니다. 즉, 서로 관련 없는 세 명의 클라이언트가 각각 한 번씩 실패한 것이, 한 명의 클라이언트가 세 번 실패한 것과 동일하게 보이게 됩니다.
세션 지향적 전송(Session-oriented transports) 방식은 실제 세션 ID를 제공합니다. 반면 stdio는 제공하지 않으며, 대신 프로세스 수명 동안 정확히 하나의 연결만 존재합니다. 결정 순서는 다음과 같습니다: 실제 세션 ID가 항상 우선하며, 해당 서버를 세션 인식(session-aware) 상태로 영구적으로 표시합니다. 서버가 세션 ID를 제공하는 것이 한 번이라도 관찰되면, 이후 세션 ID가 없는 호출이 들어오더라도 병합하는 대신 건너뜁니다. 생성된 폴백(fallback)은 전송 방식이 구조적으로 단일 연결임이 확인되었을 때, 또는 assumeSingleSession을 통해 명시적으로 옵트인(opt in)했을 때만 사용됩니다.
그렇지 않은 경우, 추측하는 대신 탐지를 조용히 건너뜁니다. 건너뛰는 것은 데이터 누락을 발생시키지만, 추측하는 것은 잘못된 데이터를 발생시킵니다. 잘못된 데이터가 더 나쁩니다.
관찰할 뿐, 개입하지 않습니다.
v0.5.0의 예산 플래그 (budget flags)와 동일한 원리입니다. 루프를 감지하면 속성 (attributes)을 설정하고 메트릭 (metrics)을 방출합니다. 요청을 취소하거나, 연결을 끊거나, 다음 호출을 거부하지는 않습니다.
에이전트 실행을 중단할 수 있는 계측 라이브러리 (instrumentation library)는 아무도 예측하지 못한 방식으로 운영 환경 (production)을 다운시킬 수 있는 라이브러리입니다. 루프 차단 (Loop-breaking)은 에이전트 프레임워크 (agent framework)나 AI 게이트웨이 (AI gateway)에 속해야 합니다. 그곳에서는 루프 차단이 요청 경로 (request path)의 의도된 일부이며, MCP 서버를 재배포하지 않고도 비활성화할 수 있기 때문입니다.
수집기 (collector) 없이 확인하기
지금 당장 무언가가 과도하게 작동 (thrashing)하고 있는지 알고 싶을 뿐이라면, OpenTelemetry를 전혀 사용하지 않는 인프로세스 액세서 (in-process accessor)가 있습니다:
console.log(server.getThrashSummary());
// {
...
어디로도 데이터가 전송되지 않습니다. 헬스 체크 (health check) 핸들러에서 호출해도 안전하며, 백엔드를 구축하기 전에도 작동합니다.
주의할 점 한 가지: activeLoops와 topOffenders는 제한된 저장소 (bounded store)에 현재 있는 내용만 반영하므로, 실제로 발생했더라도 제거(evicted)되거나 만료된 루프는 나타나지 않습니다. 누적 합계 (cumulative totals)는 이 두 경우 모두 유지되며 "이 프로세스가 시작된 이후 얼마나 낭비했는가"에 대한 답을 제공합니다. 회계 처리를 위해서는 범인 목록 (offender list)이 아닌 합계를 읽으십시오.
자주 묻는 질문
지연 시간 (latency)이 추가되나요? 감지는 실패 경로 (failure path)에서만 해시 맵 (hash-map) 조회와 카운터 증가를 수행합니다. 성공적인 호출은 단일 clear 작업을 수행합니다. 네트워크 호출이나 비동기 작업 (async work)은 없습니다.
동일한 도구가 서로 다른 이유로 정당하게 실패한다면 어떻게 되나요? 서로 다른 이유는 서로 다른 핑거프린트 (fingerprints)를 생성하므로, 그룹화되지 않으며 루프가 발생하지 않습니다. 이것이 의도된 동작입니다. 도구가 세 가지 서로 다른 방식으로 실패하는 것은 도구가 동일한 방식으로 세 번 실패하는 것과는 다른 문제입니다.
핑거프린팅 (fingerprinting)이 활성화되지 않은 상태에서도 작동하나요? 아니요. 감지는 핑거프린트를 기준으로 이루어지므로, fingerprinting: false인 경우 조용히 절대 실행되지 않습니다. 두 기능 모두 기본적으로 활성화되어 있습니다.
스키마 검증 오류(schema validation errors)는 어떤가요? 이러한 오류는 isError: true로 전달되는 대신 JSON-RPC 오류 채널을 통해 전달되므로, 별도의 탐지 경로를 가지며 여기서는 다루지 않습니다.
- 지문(fingerprint)에서 프로토콜 수준의 실패(protocol-level failures)와 실행 실패(execution failures)를 분리하여, 스키마 오류(schema error)와 업스트림 장애(upstream outage)를 다르게 읽을 수 있도록 함
- 도구 스키마 드리프트(Tool schema drift) 탐지 —
tools/list스키마를 해싱(hashing)하고 조용한 변경 사항을 플래그(flagging) 처리 - 위에서 언급한 관찰 생존 계약(observation liveness contract)
- 비용 인식 샘플링(Cost-aware sampling)을 통해, 저렴한 트레이스(traces)는 탈락하더라도 비용이 많이 드는 트레이스는 샘플링 결정에서 살아남도록 함
사용해 보기
npm install opentel-mcp
- npm: https://www.npmjs.com/package/opentel-mcp
- GitHub: https://github.com/Thirumalaiboobathi/opentel-mcp
- Docs: https://opentel-mcp-site.pages.dev/
AWS에서 MCP 서버를 실행 중이라면, 오늘 확인해 볼 가치가 있는 것은 도구 실패(tool failures)가 실제로 트레이스(traces)에 도달하고 있는지 여부입니다. 제 경우에는 도달하지 않았습니다. 여러분의 재시도 패턴(retry patterns)이 어떤 모습인지 알고 싶습니다. 사람들이 보고하는 예외 상황(edge cases)들이 다음 릴리스를 형성하기 때문입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기