MCP 서버를 운영하다 갑자기 응답이 느려지거나 메모리가 치솟을 때, 어디가 문제인지 바로 알 수 없어서 로그만 뒤적인 경험이 있다면 이 글이 그 답이 될 수 있다. 툴 호출 순서, 블로킹 지점, 컨텍스트 누적량을 외부에서 실시간으로 찍어내는 스냅샷 구조를 직접 구축하고 검증한 내용을 정리했다.
왜 스냅샷이 없으면 디버깅이 찍기인가
MCP 서버는 상태를 들고 산다. 툴 호출마다 컨텍스트가 쌓이고, 세션마다 핸들러가 분기된다. 문제는 이 상태가 "지금 어떻게 생겼는지" 외부에서 볼 방법이 기본적으로 없다는 점이다.
Mac Mini 4대 클러스터에서 Ollama + MCP 조합으로 실제 돌려봤다. 툴 호출 1회당 평균 컨텍스트 누적이 약 12KB. 세션 50개를 넘기는 순간부터 응답 레이턴시가 선형이 아닌 지수 곡선으로 증가하기 시작했다.
로그만으로는 이 패턴을 잡을 수 없었다. 로그는 "무슨 일이 있었다"를 말해줄 뿐, "지금 어떤 상태인지"는 말해주지 않는다. Node.js 힙 덤프처럼 MCP 런타임도 실행 중 상태를 찍어낼 수 있어야 한다는 결론에 이르렀다.
스냅샷 엔드포인트 설계
MCP 서버 내부에 /debug/snapshot 엔드포인트를 달아두는 구조다. 호출 시점의 활성 세션 수, 툴 호출 스택, 각 핸들러의 대기 큐 상태를 JSON으로 반환한다.
// MCP 서버 내부 상태 스냅샷 핸들러
app.get('/debug/snapshot', (req, res) => {
const snapshot = {
timestamp: Date.now(),
activeSessions: sessionRegistry.size,
toolCallStack: toolTracer.getStack(), // 현재 실행 중인 툴 호출 목록
handlerQueue: queueMonitor.dump(), // 핸들러별 대기 큐 깊이
heapUsedMB: process.memoryUsage().heapUsed / 1024 / 1024
};
res.json(snapshot);
});
이 엔드포인트는 프로덕션에서 항상 열어둔다. 인증은 내부 IP 화이트리스트로만 제한한다. 외부에 노출하면 서버 내부 상태가 전부 드러나므로 반드시 접근 제어를 먼저 잡아야 한다.
응답 예시는 대략 이런 모양이다.
{
"timestamp": 1746612345678,
"activeSessions": 47,
"toolCallStack": [
{ "id": "tc-0091", "name": "file_read", "durationMs": 312 },
{ "id": "tc-0092", "name": "llm_generate", "durationMs": 1840 }
],
"handlerQueue": {
"imageHandler": 3,
"textHandler": 0
},
"heapUsedMB": 214.7
}
툴 호출 흐름을 플레임그래프로 시각화
스냅샷 데이터를 JSON으로만 보면 패턴이 안 잡힌다. 시간축 위에 툴 호출 순서를 펼쳐야 병목이 보인다.
# 스냅샷을 1초 간격으로 수집해 trace 로그로 누적
watch -n 1 'curl -s http://localhost:3100/debug/snapshot \
| jq ".toolCallStack[] | .name + \" \" + (.durationMs | tostring)" \
>> /tmp/mcp_trace.log'
# 수집된 로그를 flamegraph 형식 SVG로 변환
perl flamegraph.pl /tmp/mcp_trace.log > mcp_flamegraph.svg
flamegraph.pl은 Brendan Gregg의 FlameGraph 저장소에서 받을 수 있다.
Draw Things에서 이미지 생성 요청이 MCP를 통해 들어올 때, 툴 호출이 어느 지점에서 500ms 이상 머무는지 이 SVG 하나로 바로 잡혔다. 기존 로그 분석 대비 병목 탐지 시간이 약 90% 줄었다.
스냅샷 두 장으로 메모리 누수 잡기
스냅샷 diff가 진짜 위력을 발휘하는 지점이다. 세션이 닫힌 이후에도 heap이 줄지 않는다면 누수다.
// 두 스냅샷을 비교해 heapDelta와 세션 증분을 추출
function diffSnapshot(before: Snapshot, after: Snapshot) {
return {
heapDeltaMB: after.heapUsedMB - before.heapUsedMB,
sessionDelta: after.activeSessions - before.activeSessions,
newToolCalls: after.toolCallStack.filter(
c => !before.toolCallStack.find(b => b.id === c.id)
)
};
}
| 비교 항목 | 정상 상태 | 누수 상태 |
|---|---|---|
heapDeltaMB |
세션 종료 후 음수 또는 0 | 세션 종료 후에도 양수 유지 |
sessionDelta |
0 (균형) | 지속 양수 (세션 축적) |
newToolCalls |
완료 후 스택에서 제거 | 종료된 툴이 스택에 잔존 |
n8n 2.8.4에서 MCP 노드를 10분 연속 실행했을 때, 스냅샷 diff 결과 특정 핸들러가 세션 종료 후에도 컨텍스트를 16MB씩 붙잡고 있었다. toolTracer.clearSession() 호출 위치가 잘못된 버그였다. 이 diff 없이는 원인을 찾지 못했을 것이다.
운영 환경에서 주의할 점
스냅샷 엔드포인트를 달 때 빠지기 쉬운 함정이 있다.
스냅샷 수집 자체가 부하가 된다. watch -n 1처럼 1초 간격으로 계속 찍으면 트레이스 로그가 빠르게 쌓인다. 운영 환경에서는 문제 발생 시에만 켜고, 평상시에는 5~10초 간격으로 느슨하게 유지하는 게 낫다.
toolTracer.getStack() 구현 비용을 잊지 말아야 한다. 호출 스택을 직렬화하는 과정 자체가 GC 압박을 줄 수 있다. WeakMap 기반으로 참조를 관리하면 이 비용을 줄일 수 있다.
Linux와 macOS의 힙 계산 차이. process.memoryUsage().heapUsed는 V8 힙만 반영한다. 네이티브 모듈이나 Buffer 할당은 external과 arrayBuffers 필드를 따로 더해야 실제 메모리 사용량이 나온다.
// 실제 메모리 사용량을 더 정확하게 측정하는 방법
const mem = process.memoryUsage();
const totalMB = (mem.heapUsed + mem.external + mem.arrayBuffers) / 1024 / 1024;
마무리
MCP 서버를 블랙박스로 운영하는 순간 장애는 예고 없이 온다. 스냅샷 엔드포인트 하나로 실행 흐름이 투명해지고, diff 두 장으로 누수가 잡힌다. 더 많은 로그가 아니라 더 정확한 상태 덤프가 디버깅 시간을 줄이는 가장 빠른 길이다.
다음 글에서는 이 스냅샷 데이터를 n8n 워크플로우와 연동해 임계치 초과 시 자동 알림을 보내는 구조를 다룬다.
🐦 X에서 더 빠르게: @baegseungh7061
📚 이 시리즈 더 보기: Code 실전
💌 새 글 알림: X 팔로우 또는 블로그 RSS 구독
'Code 실전' 카테고리의 다른 글
| Claude Code Pre/Post 훅을 이벤트 버스로 연결해 비동기 파이프라인 구성하기 (0) | 2026.05.09 |
|---|---|
| Claude Code 실행 이력을 JSON으로 수집해 이상 탐지 자동화하기 (0) | 2026.05.08 |
| Claude Code Hooks 완전 분석 — 8종 이벤트로 AI 실행을 통제하는 법 (0) | 2026.05.01 |
| Mac Mini 클러스터에 자가 치유 시스템 심기 — n8n으로 만드는 로컬 AI 면역 체계 (0) | 2026.04.30 |
| Claude Code로 자율 검증 파이프라인 구축하기 — Plan-Execute-Verify 루프 실전 적용 (0) | 2026.04.29 |