
MCP 에이전트를 위한 관측 가능성(Observability) 프록시를 구축하며 실제로 배운 것들
요약
MCP(Model Context Protocol) 에이전트의 불투명성을 해결하기 위해 OpenTelemetry와 SigNoz를 활용한 관측 가능성(Observability) 프록시 구축 경험을 공유합니다. stdio 스트림을 가로채는 방식으로 코드 변경 없이 에이전트의 도구 호출 루프, 중단된 호출, 도구 드리프트 문제를 모니터링하는 방법을 다룹니다.
핵심 포인트
- MCP 에이전트의 내부 동작(도구 호출, 타이밍, 에러)을 파악하기 위한 관측성 필요성
- OpenTelemetry와 SigNoz를 활용한 투명한 프록시 아키텍처 설계
- 에이전트의 무한 루프, 타임아웃, 도구 목록 변경(Drift) 문제 해결
- SDK 수정 없이 stdio 스트림을 가로채는 방식의 구현 전략
저는 몇 달 동안 MCP 도구를 사용하는 AI 에이전트를 사용해 왔습니다. 그러던 중 어느 시점에 불편한 점을 발견했습니다. 에이전트 내부에서 어떤 일이 일어나고 있는지 전혀 알 수 없다는 것이었습니다.
에이전트가 도구를 호출합니다. 성공했을까요? 얼마나 걸렸을까요? 에이전트가 응답을 파싱하지 못해서 동일한 도구를 6번이나 호출한 걸까요? 저는 정말로 알 수 없었습니다. 유일한 출력은 최종 답변이거나 모호한 실패뿐이었습니다. 그 사이의 모든 과정은 보이지 않았습니다.
이러한 불투명성은 이론적인 문제가 아니라 실제적인 문제처럼 느껴졌습니다. 그래서 저는 OpenTelemetry와 SigNoz를 사용하여 이를 해결할 수 있는 무언가를 만들기로 결심했습니다. 이것은 제가 무엇을 만들었는지, 처음 세 번의 시도에서 무엇을 잘못했는지, 그리고 마침내 무엇이 작동했는지에 대한 기록입니다.
실제 문제 (구체적인 사례)
MCP는 stdio를 통해 간단한 JSON-RPC 2.0 프로토콜을 사용합니다. 에이전트는 {"method": "tools/call", "params": {"name": "search", "arguments": {...}}}를 보내고 서버가 응답합니다. 그게 전부입니다. 로그에 요청 ID(request IDs)도 없고, 타이밍도 없으며, 에러율(error rate)도 없고, 세션(session) 개념도 없습니다.
테스트 과정에서 세 가지 실패 모드가 계속 나타났습니다:
루프(Loops). 에이전트가 동일한 쿼리로 search를 연속 5번 호출했습니다. 데모가 느려지는 것을 우연히 지켜보고 있었기에 겨우 알아차렸을 뿐입니다. 알림도, 로그 기록도, 아무것도 없었습니다. 에이전트는 자기 강화 루프(self-reinforcing loop)에 빠져 있었고, MCP 레이어는 이에 대해 아무런 의견이 없었습니다.
중단된 호출(Hung calls). slow_tool은 약 20초가 소요됩니다. 에이전트는 그냥 기다리기만 합니다. 타임아웃(timeout) 이벤트도 없고, 트레이스(trace)도 없습니다. 터미널을 충분히 오래 응시하지 않는 한 무언가 잘못되었다는 것을 알 수 없습니다.
조용한 드리프트(Silent drift). 테스트를 위해 작성한 가짜 MCP 서버는 런타임에 새로운 도구를 추가할 수 있습니다(저는 이를 bonus_tool이라고 불렀습니다). 일단 tools/list에 나타나면 에이전트는 그것을 사용하려고 시도하기 시작합니다. 하지만 서버의 도구 목록이 언제 변경되었는지에 대한 기록은 없었습니다. 그냥... 일어난 일입니다.
이것들은 억지로 만들어낸 예외적인 사례들이 아닙니다. LLM 출력 형식이 약간 변경될 때 루프가 발생합니다. 다운스트림 API의 성능이 저하될 때 중단된 호출이 발생합니다. 서버가 무중단 업데이트(zero-downtime update)를 수행할 때마다 드리프트가 발생합니다. 이것들은 실제 상황입니다.
아키텍처: 코드 변경 없는 단일 명령
핵심 아이디어는 다음과 같습니다. 사용자에게 에이전트나 서버에 SDK를 추가하라고 요청하지 마세요. 그저 stdio 스트림을 가로채기만 하면 됩니다.
AI Agent
│ stdin/stdout (JSON-RPC 2.0)
▼
...
프록시를 통해 MCP 서버를 실행합니다:
# 이전
node my-mcp-server.js
...
프록시는 서버를 자식 프로세스(child process)로 생성하고, 양방향 stdio를 연결하며, 줄바꿈으로 구분된 모든 JSON 메시지를 탭(tap)합니다. 이는 투명한 중개자 역할을 합니다. 모든 메시지는 수정되지 않은 채 통과하지만, 관찰자(observer)는 그 모든 것을 볼 수 있습니다.
각 tools/call 요청에 대해, 저는 OTel 스팬(span)을 시작합니다:
const span = tracer.startSpan(`mcp.tool.call:${toolName}`, {
attributes: {
'mcp.tool.name': toolName,
...
응답이 도착하면 스팬이 종료됩니다. 지속 시간(duration)은 자동으로 캡처됩니다.
제가 거의 실수할 뻔했던 부분: 저는 arguments 원본을 스팬 속성(span attribute)으로 로그에 남길 뻔했습니다. 이는 좋지 않은 생각입니다. 도구 인자(tool arguments)에는 종종 사용자 쿼리, 파일 경로 또는 자격 증명(credentials)이 포함되어 있기 때문입니다. 인자를 SHA-256으로 해싱하면, 실제 내용이 무엇인지 노출하지 않으면서도 "이것은 4번 반복된 동일한 호출이다"라는 것을 상관관계(correlate) 지을 수 있습니다.
세 가지 탐지기(Detector) 구축하기
루프 탐지기 (Loop Detector)
const key = `${session.id}:${toolName}:${argsHash}`;
const calls = callHistory.get(key) ?? [];
calls.push(Date.now());
...
제가 결정한 임계값(threshold)은 다음과 같습니다: 60초 이내에 3번의 동일한 호출이 발생하는 경우입니다. 너무 엄격하면 오탐(false positive)이 발생하고, 너무 느슨하면 피해가 발생한 후에야 알림을 받게 됩니다. 3/60 설정은 테스트 과정에서 오탐 없이 모든 실제 루프를 잡아냈습니다.
멈춤 호출 탐지기 (Hung-Call Detector)
여기서 핵심 기술은 호출이 해결(resolve)된 후가 아니라, 호출이 진행 중인 동안(while in flight) 알림을 보내야 한다는 점입니다. 이로 인해 스팬 종료 시점에 지속 시간을 측정하는 방식은 제외되었습니다. 대신, 호출이 시작될 때 setTimeout을 설정하고, 타이머가 실행되기 전에 응답이 도착하면 이를 취소하는 방식을 사용했습니다:
const timer = setTimeout(() => {
emitAnomaly('mcp.anomaly.hung_call', {
'tool.name': toolName,
...
hung_call_resolved 이벤트가 중요하다는 사실을 알게 되었습니다. 이 이벤트는 단순히 호출이 느렸다는 것뿐만 아니라, 해당 호출이 결국 성공했는지 여부를 알려줍니다. 18초 동안 멈춰 있다가 성공적으로 반환된 호출은, 멈춰 있다가 에러가 발생한 호출과는 매우 다릅니다. 두 경우 모두 초기 경고(alert)를 발생시키지만, 해결(resolution) 이벤트가 이 둘을 구분해 줍니다.
드리프트 탐지기 (Drift Detector)
const prev = lastToolList.get(session.id) ?? [];
const next = response.tools.map(t => t.name);
const added = next.filter(n => !prev.includes(n));
...
이 기능은 간단하지만, 적절한 비교 지점을 찾는 데 시간이 좀 걸렸습니다. 전역(globally)이 아닌 세션별(per-session)로 차이(diff)를 구해야 합니다. 서로 다른 세션은 정당하게 서로 다른 서버 버전에 연결될 수 있기 때문입니다. 만약 전역적으로 차이를 구한다면 노이즈가 발생하게 됩니다.
Gemini AI 진단 추가 — 그리고 내가 저지를 뻔한 실수
탐지기들이 정상적으로 작동하자, 쉬운 영어로 된 근본 원인(root cause) 요약을 생성하기 위해 Gemini AI를 추가했습니다. 제가 처음 작성한 구현 방식은 이상 징후 로그(anomaly log)를 발행하기 전에 Gemini 호출을 기다리는(await) 방식이었습니다. 그것은 잘못된 방식이었습니다.
Gemini는 때때로 500~800ms가 소요됩니다. 프록시는 모든 도구 호출(tool call) 응답의 핫 패스(hot path)에 위치합니다. 만약 제가 AI 호출을 기다린다면(await), 모든 이상 징후에 대해 에이전트의 지연 시간(latency)을 추가하게 됩니다. 에이전트는 왜 느려졌는지에는 관심이 없습니다. 에이전트에게 중요한 것은 응답을 받는 것입니다.
해결책: 실행 후 방치(fire and forget).
// 이 코드는 실행된 후 즉시 다음으로 넘어갑니다 — 기다리지(await) 않습니다.
generateRootCause(anomalyType, context).then(summary => {
// AI 진단이 포함된 별도의 로그 이벤트를 발행합니다.
...
이상 징후 로그는 즉시 발행됩니다. AI 진단은 500ms 후에 별도의 상관관계가 있는 로그 이벤트로 도착합니다. 두 이벤트 모두 동일한 trace_id와 span_id를 가지고 있으므로, SigNoz에서 트레이스(trace)로 필터링하면 함께 나타납니다.
다음은 멈춘 호출(hung call)에 대해 제가 실제로 받은 Gemini 출력 결과입니다 (SigNoz Logs Explorer에서 직접 가져온 root_cause_ai_summary 속성입니다):
"Session 893fffd4는 8개의 경량 작업이 빠르게 성공적으로 수행된 직후, 15,000ms 임계값을 초과하여 slow_tool에서 중단된 호출(hung call)을 경험했습니다. 이 갑작스러운 지연 시간 급증(latency spike)은 처리되지 않은 업스트림 타임아웃(upstream timeout), 데이터베이스 잠금(database lock) 또는 데드락(deadlock)을 강력하게 시사합니다. 실행이 중단된 지점을 확인하기 위해 호출 ID(call ID) 9에 대한 다운스트림 서버 로그를 확인하고, 서킷 브레이커(circuit breaker) 구현을 고려하십시오.""
이것은 정말 유용합니다. 세션 ID, 호출 ID, 그리고 구체적인 제안을 포함하고 있습니다. 제가 원했던 것이 바로 이것입니다. 단순히 "오류가 발생했습니다"가 아니라, "아마 이런 일이 발생했을 것이며, 어떻게 조치해야 합니다"라고 말해주는 것 말입니다.
SigNoz: 실제로 사용한 것들
Traces Explorer (트레이스 탐색기). service.name = mcp-observer로 필터링하면 모든 도구 호출 스팬(tool call span)이 표시됩니다. mcp.tool.call:slow_tool을 클릭하면 15초의 지속 시간이 즉시 나타납니다. 스팬 속성(span attributes) 패널에는 세션 ID와 인자 해시(argument hash)가 표시됩니다.
Logs Explorer (로그 탐색기). body LIKE 'mcp.anomaly.%' 필터를 사용하면 모든 이상 징후(anomaly) 이벤트가 표시됩니다. 각 행은 클릭 가능하며, 사이드바를 통해 root_cause_summary 및 root_cause_ai_summary를 포함한 전체 구조화된 속성(structured attributes)을 확인할 수 있습니다. 사이드바에 있는 트레이스 링크를 클릭하면 이상 징후를 유발한 정확한 스팬으로 바로 이동합니다.
주의할 점 하나: 로그 레코드의 body 필드는 단순히 이벤트 이름(예: mcp.anomaly.hung_call)일 뿐입니다. 전체 진단 텍스트는 attributes_string → root_cause_ai_summary에 들어 있습니다. 잘못된 필드에서 텍스트를 찾느라 생각보다 많은 시간을 허비했습니다.
Trace ↔ Log 상관관계 (Correlation). 이 부분이 가장 많은 작업(wiring)을 필요로 했습니다. trace_id와 span_id가 로그 레코드의 최상위 컬럼으로 나타나게 하려면(SigNoz가 "트레이스로 이동" 버튼을 위해 이를 사용함), 스팬의 활성 컨텍스트(active context) 내에서 로그를 방출(emit)해야 합니다:
context.with(trace.setSpan(context.active(), span), () => {
anomalyLogger.emit({ body: eventName, attributes: {...} });
});
컨텍스트 외부에서 이벤트를 발생시키면(emit), 로그 레코드에 트레이스 링크(trace link)가 생성되지 않습니다. 이는 OTel (OpenTelemetry) 문서에서도 명확히 드러나지 않는 부분이며, 저는 SigNoz에서 로그 레코드를 조사하던 중 trace_id가 비어 있는 것을 발견하고서야 이 사실을 알게 되었습니다.
이 작업을 시작하려는 사람에게 해주고 싶은 말
하나의 탐지기(detector)부터 시작하세요. 저는 세 가지를 한꺼번에 구축하려다 세션 상태(session state)를 꼬이게 만들었습니다. 루프 탐지기(loop detector) 하나만으로도 완전하고 테스트 가능한 단위가 됩니다. 이를 SigNoz까지 엔드 투 엔드(end-to-end)로 먼저 작동시킨 다음, 나머지를 추가하세요.
인자(arguments)를 SHA-256으로 해싱하세요. 도구의 원시 인자(raw tool arguments)를 그대로 로그에 남기지 마세요. 그 안에 사용자 데이터가 포함되어 있다는 사실을 깨닫게 되면 후회하게 될 것입니다.
처음부터 비차단형(Non-blocking) AI 강화(enrichment)를 적용하세요. 나중에 리팩터링(refactor)을 해야만 하는 막다른 상황에 처하지 않도록 설계하세요. 어떤 핫 패스(hot path)에서도 AI 호출에는 '발사 후 망각(Fire-and-forget)' 패턴이 올바른 방식입니다.
대시보드를 구축하기 전에 실제 스팬(span) 이름을 확인하세요. 저는 제가 임의로 만든 스팬 이름인 name = 'execute_tool'을 필터링하는 대시보드 패널을 만드는 데 시간을 허비했습니다. 제 프록시는 실제로 mcp.tool.call:search, mcp.tool.call:slow_tool 등을 발생시킵니다. SigNoz Logs에서 다음 쿼리를 실행해 보니 즉시 진실을 알 수 있었습니다.
SELECT DISTINCT name FROM signoz_traces
WHERE serviceName = 'mcp-observer'
무엇인가를 그 위에 구축하기 전에, 실제로 무엇을 발생시키고 있는지 항상 검증하세요.
결론
MCP는 관측 가능성(observability)에 관한 이야기가 본질적으로 비어 있을 정도로 충분히 새로운 기술입니다. 이는 관점에 따라 문제일 수도 있고 기회일 수도 있습니다. 저는 기회를 선택했습니다.
프록시는 잘 작동합니다. 모든 이상 징후(anomaly)가 발생하고, 모든 AI 진단 결과가 SigNoz에 기록되며, 트레이스-로그 상관관계(trace-log correlation) 덕분에 세션을 디버깅하는 일이 grep 명령어를 사용하는 대신 클릭 한 번으로 해결되는 문제가 되었습니다.
MCP 도구를 사용하여 구축 중이며 에이전트가 실제로 무엇을 하고 있는지 확인하고 싶다면, 코드는 https://github.com/Arjun-3105/mcp-observer에서 확인할 수 있습니다. npm run demo를 실행하면 약 90초 안에 세 가지 유형의 이상 징후가 모두 발생하는 것을 볼 수 있습니다.
단 하나의 명령어로, 에이전트나 서버의 코드 변경 없이 완전한 가시성(visibility)을 확보하세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기
