본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 02. 04:32

MCP 서버 사후 분석: 컨텍스트(Context) vs 프로토콜(Protocol)

요약

MCP 서버 운영 시 코드와 도구 설명을 동기화하고, 실제 운영 환경과 유사한 데이터 크기로 테스트해야 함을 강조합니다. MCP는 단순 프로토콜 번역기가 아닌 컨텍스트 번역기 역할을 수행하므로 데이터 크기 관리가 필수적입니다.

핵심 포인트

  • 코드와 도구 설명(tool-description) 변경은 반드시 동시에 이루어져야 함
  • 도구 호출당 결과 바이트 크기를 로그로 남겨 오버플로 위험 관리
  • 합성 데이터가 아닌 실제 운영 환경과 유사한 데이터로 스모크 테스트 수행
  • MCP는 프로토콜이 아닌 컨텍스트 번역기로서의 성격을 가짐

만약 REST API 앞에 MCP 서버를 노출시키고 있다면, 저희의 경험을 통해 전달할 가치가 있는 두 가지 사항이 있습니다.

  1. 코드 변경과 도구 설명(tool-description) 변경은 반드시 동시에 이루어져야 합니다. 설명을 업데이트하지 않고 리스트 응답의 형태를 변경하면, 명시적인 오버플로(overflow) 실패를 암묵적인 실패로 바꾸게 됩니다. 즉, 에이전트(agent)가 얇은 레코드에서 멈춰버려 get_bug가 존재한다는 사실을 인지하지 못하고, 불완전한 데이터로 답변하게 됩니다.
  2. 도구 호출(tool call)당 result_size_bytes를 로그로 남기고, 합성된 피스처(synthetic fixtures)가 아닌 실제 운영 환경과 유사한 데이터로 스모크 테스트(smoke-test)를 수행하십시오. 응답이 타입(type)상으로는 올바르더라도 크기 면에서는 틀릴 수 있으며, 개발용 피스처는 저희를 운영 환경에서 무너뜨렸던 상위 99퍼센타일(99th-percentile)의 비용을 숨깁니다.

이 두 가지 모두 MCP 서버는 프로토콜 번역기(protocol translator)가 아니라 컨텍스트 번역기(context translator)라는, 이제는 익숙해진 개념의 측면들입니다. 이는 소비자가 UI 클라이언트에서 LLM으로 바뀐 BFF / 오버페칭 패턴(overfetching pattern)과 같습니다. 이 원칙 자체는 새로운 것이 아닙니다. 새로울 수 있는 것은 코드 변경과 도구 설명 변경을 구체적으로 쌍으로 맞추는 것과, 모든 호출에서 출력 바이트 크기를 기록하는 습관입니다.

아래의 이야기는 — list_bugs(limit=3)가 세 개의 레코드에 대해 61,621 바이트를 반환했고, 에이전트 하네스(agent harness)가 오버플로를 디스크에 저장한 뒤 파일에서 필드 값을 그레핑(grepping)하여, 질문 하나에 네 번의 도구 호출이 발생했던 — 저희 백엔드에서 발생한 한 가지 사건입니다. 이는 벤치마크가 아닌 예시를 위한 것입니다.

사건 발생

해당 첫 번째 호출에 대해 저희 서버가 작성한 JSONL 로그 라인입니다:

{
  "tool": "list_bugs",
  "args": { "limit": 3 },
...

세 개의 레코드. 레코드당 약 20.5 KB가 에이전트에게 그대로 반환되었습니다. 서버의 관점에서는 깔끔한 성공이었습니다.

에이전트의 관점에서는 그렇지 않았습니다:

[list_bugs]
  OUT: Error: result (61,621 characters across 236 lines)
       exceeds maximum allowed tokens. Output saved to disk.
...

5행의 테이블을 생성하기 위해 에이전트는 다음과 같은 과정을 거쳐야 했습니다: 오버플로 한계(overflow ceiling)에 도달하고, 서브 에이전트(sub-agent)를 생성하고, 저장된 파일을 읽고, 필드를 grep으로 검색한 뒤, 다시 재조립해야 했습니다. 원래 작업에 더해 4번의 도구 호출(tool calls)과 오케스트레이션 오버헤드(orchestration overhead)가 추가되었습니다. 단 3개의 레코드만 처리하는 상황에서도, limit: 20 설정은 합리적인 예산을 훨씬 초과하여 실패했을 것입니다.

진단에 앞서 주목할 만한 관찰 사항이 하나 있습니다. 위의 복구 시퀀스는 하네스(harness)에 따라 특화되어 있으며, 동일한 벤더 내에서도 그 차이가 극명할 수 있습니다. 사고 이후, 우리는 원래 사례와 일치하도록 약 80 KB를 반환하는 도구를 사용하여 네 가지 MCP 클라이언트 — Claude Code, Claude Desktop, Goose (Block), Cursor — 를 대상으로 통제된 오버플로 테스트를 수행했으며, 두 가지 뚜렷한 동작을 확인했습니다. 오직 Claude Code만이 문자 수(character count)에 따라 도구 결과에 제한을 두었고, 위에서 설명한 '디스크 저장 복구 단계(save-to-disk recovery dance)'로 진입했습니다. 동일한 벤더의 제품인 Claude Desktop을 포함한 나머지 세 가지 클라이언트는 그러한 제한(gate)이 없었습니다. 각 클라이언트는 전체 페이로드(payload)를 하부 모델(underlying model)에 그대로 전달했고, 모델은 이를 읽고 정확하게 답변했습니다. 우리가 우려했던 '조용한 성능 저하(silent degradation)' — 즉, 명확한 실패 대신 원시 주입(raw-injection)으로 인해 확신에 찬 오답을 내놓는 현상 — 은 제한이 없는 세 가지 클라이언트 중 어느 곳에서도 80 KB 크기에서는 나타나지 않았습니다. 우리는 모델 자체의 컨텍스트 예산(context budget)에 실제로 압박을 줄 만큼 큰 크기에서는 이러한 현상이 나타날 것으로 예상하지만, 이번 테스트에서는 그 부분까지는 확인하지 않았습니다.

독자라면 이 발견이 서두에 언급한 사고의 극적인 요소를 부분적으로 약화시킨다고 정당하게 지적할 수 있습니다. 테스트한 네 가지 클라이언트 중 세 곳은 오버플로 없이 우리의 80 KB를 흡수했을 것이므로, 해당 실패는 다소 클라이언트 특이적(client-specific)이었기 때문입니다. 이는 사실이며, 그렇다고 해서 프로젝션 수정(projection fix)의 필요성이 약해지는 것은 아닙니다. 하네스가 불평 없이 전체 페이로드를 흡수하더라도, 에이전트는 결과가 컨텍스트(context)에 남아 있는 매 턴마다 해당 토큰의 비용을 지불해야 합니다. 수정의 목적은 컨텍스트 예산을 효율적으로 유지하는 것이며, 오버플로 방지는 그에 따른 하류 결과(downstream consequence)이지 일차적인 동기가 아닙니다.

발생 원인

GET /api/v1/reports?limit=3 호출이 요약본을 반환하지 않습니다. 대신 모든 행의 모든 필드를 포함한 전체 레코드(full records)를 반환합니다. 전체 설명, 캡처된 브라우저/네트워크 텔레메트리 (telemetry), 스택 트레이스 (stack trace), 그리고 리플레이 메타데이터 (replay metadata)를 포함하는 실제 운영 환경의 레코드는 각각 약 20 KB에 달하며, 세 개를 합치면 60 KB가 넘었습니다.

MCP 서버의 핸들러(handler)는 당연한 처리를 수행했습니다:

const data = await client.request('GET', path, { params });
return { data, resultCount: data?.data?.length ?? 0 };

완벽하게 올바른 REST 프록시 (proxy)입니다. 바로 그 점 때문에 문제가 발생했습니다.

이 불일치는 구조적인 문제입니다. REST 목록 엔드포인트 (list endpoints)는 왕복 횟수 (round-trips)를 최소화하고 클라이언트 측에서 조인 (join)하려는 UI를 위해 설계되었습니다. 이러한 소비자에게 얇은 레코드 (thin-record) 응답은 오히려 성능이 부족한 것으로 간주될 것입니다. 반면 에이전트 (agent) 지향 도구들은 정반대의 압박을 받습니다. 에이전트는 반환된 모든 바이트를 자신의 컨텍스트 윈도우 (context window)로 읽어 들이며, 비용을 두 번 지불합니다. 즉, 금전적 비용과 주의력 (attention) 비용입니다. 또한 에이전트는 인간처럼 훑어볼 (skim) 수 없습니다. Postman을 사용하는 개발자는 JSON을 시각적으로 스캔하며 무관한 필드를 무시할 수 있지만, 에이전트는 무엇이 관련 있는지 결정하기 전에 전체 페이로드 (payload)를 소비해야만 합니다.

따라서 REST 엔드포인트인 list_bugs와 MCP 도구인 list_bugs는 동일한 작업처럼 보이지만, 실제로는 그렇지 않습니다.

해결책

영향력이 큰 순서대로 정리한 세 가지 변경 사항입니다.

1. 목록 모드(list-mode) 결과를 얇은 레코드로 투영하기

가장 큰 원인입니다. 목록 모드 도구에서 반환하기 전에 무거운 필드들을 제거하십시오. 트리아지 (triage, 우선순위 분류)를 수행하는 에이전트는 어떤 레코드에 더 세심한 주의를 기울일지 결정하기 위해 네트워크 로그가 필요하지 않습니다. 에이전트에게 필요한 것은 ID, 제목, 상태, 우선순위, 타임스탬프 (timestamps), 그리고 프로젝트 핸들 (project handle)입니다. 만약 특정 항목이 추가 조사를 할 가치가 있다면, 에이전트는 get_bug를 호출하여 해당 단일 레코드에 대해서만 전체 페이로드 비용을 지불하면 됩니다.

const LIST_FIELDS = [
  'id', 'title', 'status', 'priority',
  'created_at', 'updated_at', 'project_id',
...

보정(Calibration)을 위해 설명하자면: 우리의 운영 환경과 유사한 레코드(records)를 기준으로, 이 투영(projection)은 각 레코드를 약 20 KB(설명, 콘솔 배열, 네트워크 로그, 리플레이 메타데이터를 포함한 전체 페이로드)에서 약 280 바이트(허용 목록에 있는 필드만 포함)로 줄입니다. 레코드당 감소량은 귀하의 특정 레코드에서 삭제된 필드가 얼마나 무거운지에 따라 전적으로 결정됩니다. 즉, 원리는 동일하게 적용되지만 크기 차이(size delta)는 동일하지 않습니다.

계약(contract)을 명시적으로 만들기 위해 코드 변경과 함께 도구 설명(tool description)도 업데이트되었습니다: "얇은 레코드(thin records)를 반환합니다 — 전체 콘텐츠를 보려면 get_bug를 후속 호출하십시오.". 이 문자열은 단순한 문서가 아닙니다. 이는 에이전트(agent)가 어떤 도구를 호출할지 결정할 때 사용하는 주요 자연어 신호(natural-language signal)입니다. 도구 이름과 입력 스키마(input schema)도 기여하지만, list_bugsget_bug가 모두 가능한 후보일 때 이를 구별해 주는 것은 바로 설명(description)입니다. "전체 세부 정보를 반환합니다"라고 적힌 설명은 에이전트가 목록 모드(list-mode)와 상세 모드(detail-mode)를 서로 교체 가능한 것으로 취급하도록 학습시킵니다. 설명 업데이트 없이 응답 형태(response-shape)만 변경했다면, 오버플로(overflow) 실패 대신 더 나쁜 실패 모드를 겪었을 것입니다. 즉, 에이전트가 얇은 레코드에서 멈춰 서서 get_bug가 적절한 다음 호출임을 인식하지 못하고, 불완전한 정보로 답변하며, 남은 세부 정보를 요청하지 않게 되는 상황 말입니다. 코드 변경과 설명 변경은 반드시 함께 이루어져야 합니다.

이 과정에 숨겨진 트레이드오프(trade-off): 얇은 투영(thin projection)은 추가적인 왕복(round-trips)을 강제합니다. 만약 에이전트가 20개의 후보를 분류(triage)하고 그중 5개를 상세히 조사하기로 결정한다면, 비용은 5번의 추가적인 get_bug 호출이 됩니다. 이는 정확히 REST 목록 엔드포인트(list endpoint)가 피하기 위해 설계되었던 N+1 문제입니다. 이 트레이드오프는 두 소비자(consumer) 사이에서 반전됩니다. UI는 지연 시간(latency)에 제한을 받지만(왕복 횟수가 지배적이며, 바이트 크기는 거의 비용이 들지 않음), 에이전트는 예산(budget)에 제한을 받습니다(토큰 비용이 지배적이며, 순차적인 2~3 KB 호출은 수용 가능함).

2. 검색 결과용 제한된 발췌본 (Bounded excerpts)

2. 검색 결과용 제한된 발췌본 (Bounded excerpts)

검색 결과 역시 다른 형태로 같은 문제를 가지고 있었습니다. 인텔리전스 서비스는 각 히트(hit) 안에 전체 설명을 반환했습니다. 우리는 이 히트들을 동일한 얇은 집합(thin set)으로 투영한 다음, 제한된 발췌본(bounded excerpt)을 합성했습니다:

const EXCERPT_MAX = 240;

function makeExcerpt(s: string): string {
...

공백 문자 축소(whitespace collapse)는 보이는 것보다 더 중요합니다. 실제 설명은 들여쓰기가 있는 여러 단락으로 구성되어 있는데, 단순히 자르는 방식으로는 `

`과 낭비되는 바이트로 가득 찬 발췌본이 생성됩니다. 올바른 순서는 텍스트를 먼저 평탄화(flatten)한 다음, 단어 경계에서 잘라내는 것입니다.

검색에만 특정한 제한 사항이 있습니다: 이것은 문자열 시작 부분 자르기(head-of-string truncation)입니다. 만약 쿼리가 3000자로 된 설명의 1500번째 문자에서 일치한다면, 발췌본은 처음 240자이며 해당 일치 내용을 포함하지 않습니다. 즉, 에이전트는 자신이 왜 히트인지 보여주지 못하는 발췌본을 받은 히트를 받게 됩니다. 쿼리 인식 스니펫(일치 위치를 파악하여 그 주변에 발췌본을 중앙 집중화)이 검색 히트에 대한 올바른 답변입니다. 우리는 아직 이것을 구현하지 않았습니다.

3. 간결한 JSON (Compact JSON)

미적인 개선이지만 비용은 적게 듭니다. 디스패치 레이어(dispatch layer)가 JSON.stringify(data, null, 2)를 호출하고 있었는데, 이는 두 칸 들여쓰기로 예쁘게 출력된 방식입니다. 들여쓰기는 인간 독자에게는 그렇지 않지만 LLM 소비자에게는 공짜가 아닙니다. 공백 문자도 토큰으로 구성되며, 이들은 의미에 기여하지 못하면서 예산을 소모하는 토큰입니다. 두 번째 인수를 제거하고, 직렬화된 출력에 줄 바꿈이 포함되어 있지 않음을 단언하는 회귀 테스트를 추가하세요 (간결한 방식 대 예쁜 방식 시그니처는 그 한 바이트에 대해 명확합니다).

이것이 프로덕션에서만 표면화된 이유

이것이 로컬 개발 중 어느 시점에서도 발생하지 않고 첫 번째 프로덕션 호출에서 나타난 이유는, 우리의 개발 더미(dev fixtures)가 얇은 레코드(thin records)를 포함하고 있었기 때문입니다. 두 문장의 합성 설명이었고, 캡처된 콘솔도 네트워크 로그도 없었습니다. 더미 내의

만약 우리가 스스로가 첫 번째 사용자였다면 — 즉, 개발 스택(dev stack)에서 MCP 서버를 실행해 보았다면 — list_bugs(limit=3)는 약 3 KB를 반환했을 것입니다. 관리 가능한 수준이었겠죠. 경고를 보낼 만한 것도 아니었습니다. 우리는 동일한 코드를 변경 없이 배포했을 것이고, 작업을 완료했다고 간주한 뒤 고객이 운영 환경(production)에서 해당 실패 모드(failure mode)를 발견하기만을 기다렸을 것입니다.

일반적인 형태는 다음과 같습니다: 개발 데이터는 상위 1%의 비용(99th-percentile costs)을 숨깁니다. 운영 환경에서는 제한적이지만 원칙적으로는 제한이 없는 것들(자유 형식의 설명, 업로드된 파일, 캡처된 주변 텔레메트리(ambient telemetry))은 테스트용 고정 데이터(fixtures)에서는 거의 무게가 느껴지지 않지만, 실제 사용 시에는 거의 모든 것을 차지하는 경향이 있습니다. 타입 체크(Type checking), 단위 테스트(unit tests), 심지어 모킹된 업스트림(mocked upstreams)을 대상으로 하는 통합 테스트(integration tests)조차도 이 문제를 드러내지 못합니다. 형태(shape)는 올바르지만, 크기(size)가 잘못되었기 때문입니다.

이로부터 도출되는 두 가지 프로세스 변화는 다음과 같습니다:

  1. 합성 고정 데이터(synthetic fixtures)가 아닌, 운영 환경과 유사한 형태의 데이터로 스모크 테스트(smoke tests)를 실행하십시오. 테스트 스위트를 통해 익명화된 실제 페이로드(payloads)를 재현하거나, 작업을 완료했다고 선언하기 전에 로컬 빌드를 실제 데이터가 포함된 작은 스테이징 인스턴스(staging instance)로 연결하십시오.
  2. 성공과 실패뿐만 아니라 바이트 크기(byte sizes)를 기록하십시오. 우리가 이 문제를 이렇게 빨리 감지할 수 있었던 유일한 이유는 행동 로거(behavioural logger)가 모든 도구 호출(tool call) 시 result_size_bytes를 기록했기 때문입니다. 에이전트(agent) 입장에서 61,621은 오류가 아니었습니다. 호출은 성공했으니까요. 문제를 일으킨 것은 크기였으며, 크기는 이를 위해 계측(instrument)을 해두지 않으면 보이지 않습니다.

계측 패턴 자체는 매우 간단합니다 — 호출당 { tool, args_size_bytes, result_size_bytes, duration_ms, error_class }, JSONL 추가 전용(append-only), 일 단위 로테이션(daily-rotated) 방식이며, 여러분이 구축하는 모든 MCP 서버에 복사해 넣을 가치가 있습니다. 타입 시그니처(Type signatures)는 잘못된 형태(wrong-shape)의 버그를 잡아낼 것입니다. 하지만 행동 로그(Behavioural logs)는 잘못된 크기(wrong-size)의 버그가 저렴한 비용으로 수정될 수 있을 만큼 충분히 일찍 드러나는 유일한 곳입니다.

코드는 apex-bridge/bugspotter-mcp에서 확인할 수 있으며, MIT 라이선스입니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0