Smash Story: 내 테스트 스위트보다 더 뛰어난 디버깅을 해낸 데모 스크립트
요약
단위 테스트가 모두 통과했음에도 불구하고 실제 운영 환경에서 발생한 API 오류와 그 원인을 분석한 디버깅 사례입니다. 검증 레이어가 실제 API의 계약(contract)이 아닌 잘못된 정보를 기준으로 동작하여 발생한 문제를 다룹니다.
핵심 포인트
- 단위 테스트 통과가 실제 운영 환경의 성공을 보장하지 않음
- 검증 레이어와 실제 API 명세 간의 불일치 위험성
- 모킹(Mocking)된 테스트가 놓칠 수 있는 외부 API의 변경 사항
- 실제 워크플로우를 반영한 데모 스크립트의 디버깅 가치
이 글은 DEV Summer Bug Smash를 위한 Smash Stories 제출물입니다. "모든 테스트 통과"와 "실제로 작동함" 사이의 간극, 그리고 그 간극을 메운 뜻밖의 영웅에 관한 디버깅 이야기입니다.
설정 (The setup)
이 프로젝트는 Google의 gemini-3.1-flash-lite-image 모델을 래핑(wrap)하는 작은 MCP (Model Context Protocol) 서버입니다. 이 서버는 이미지 생성 및 상태 유지(stateful) 이미지 편집 기능을 MCP를 사용하는 어떤 에이전트라도 호출할 수 있는 4가지 도구(tools)로 노출합니다. Claude Code, Google ADK 에이전트, 그리고 Rust CLI 모두 동일한 약 300줄의 Python 서버를 사용합니다. (전체 아키텍처 설명은 여기에서 확인할 수 있습니다.)
개발자가 통상적으로 신뢰하는 모든 지표상으로는 건강한 상태였습니다:
- ✅ 유닛 테스트(unit tests) 10/10 통과
- ✅ ruff + mypy 클린
- ✅ 며칠 동안 AI 에이전트를 통해 성공적으로 사용됨
- ✅ Docker 이미지로 배포됨
그러다 제가 데모 스크립트를 작성했습니다. 그리고 그것은 실행 1분도 채 되지 않아 운영 환경(production)의 버그를 찾아냈습니다.
충돌 (The smash)
demo.sh는 스택을 실시간으로 탐색합니다: 도구를 발견하고, 이미지를 생성한 다음, 상태 유지 편집을 수행합니다. 데모 비용을 낮게 유지하기 위해, 2단계에서는 서버 문서에 명시된 가장 낮은 품질 등급을 요청했습니다:
cargo run --quiet -- generate "a tiny robot chef cooking ramen" 16:9 minimal
첫 번째 실행:
🔴 Image generation failed: Error code: 400 - {'error': {'message':
"'minimal' is not a supported thinking level for this model.
Allowed values are: low, high.", 'code': 'invalid_request'}}
잠시만요. 서버 자체 검증(validation) 단계에서는 이를 전송하기 전에 minimal을 _승인_했습니다. 해당 검증 코드는 다음과 같습니다:
# server.py — 배포된 상태
SUPPORTED_THINKING_LEVELS = {"minimal", "low", "medium", "high"}
...
허용된 값은 4개입니다. 하지만 실제 API는 두 개(low와 high)만 수용합니다. 그리고 기본값(default)을 보세요 — medium입니다. 이것이 바로 정말로 Smash(충돌)할 만한 발견입니다:
thinking_level을 명시적으로 오버라이드(override)하지 않은 모든 라이브 호출은 확실하게 HTTP 400 오류를 발생시켰습니다. 검증 레이어(validation layer)는 API의 계약(contract)을 검증하고 있었던 것이 아니라, 그 계약에 대한 오래된 기억을 검증하고 있었던 것입니다.
왜 10개의 통과된 테스트(green tests)가 이를 알아채지 못했는가
단위 테스트(unit tests)가 그래야 하듯, 테스트 스위트는 Gemini 클라이언트를 모킹(mock)합니다:
@patch("server._get_client")
def test_generate_image_success(self, mock_get_client):
mock_client.interactions.create.return_value = mock_interaction
...
이 모킹(mock)은 실제 API가 거부하는 입력을 포함하여, 어떠한 입력에 대해서도 성공을 반환합니다. 테스트는 "서버가 medium을 충실히 전달한다"는 점을 정확히 증명했습니다. 유효하지 않은 값을 충실히 전달하는 것 역시 버그입니다. 다만 모킹 경계(mock boundary) 내부에서는 보이지 않을 뿐입니다.
이 문제가 배포되기 위해서는 두 가지 조건이 일치해야 했습니다:
- 로컬 허용 목록(allowlist)이 원격에서 관리되는 계약을 복제했습니다.
SUPPORTED_THINKING_LEVELS는 오직 API만이 소유하는 사실을 캐싱(cached)한 복사본이었습니다. 캐싱된 복사본은 어긋나기 마련입니다. - 이전의 모든 라이브 호출자가 우연히 기본값을 오버라이드(override)했습니다. 에이전트들은 품질을 위해 계속해서
high를 요청했습니다. 따라서 고장 난 기본값과 두 개의 유령 값(phantom values)은 한 번도 실행되지 않았습니다.f(x)가 백 번 호출된다고 해서f()에 대해 알 수 있는 것은 아무것도 없습니다.
수정 사항
두 줄의 프로덕션 코드, 그리고 실제로 규율이 필요한 부분 — 즉, 재발 방지를 위해 발견된 내용을 고정하는 작업입니다:
-SUPPORTED_THINKING_LEVELS = {"minimal", "low", "medium", "high"}
+SUPPORTED_THINKING_LEVELS = {"low", "high"}
...
# 새로운 회귀 테스트(regression test): 라이브 API는 이 모델에 대해 low/high만 허용합니다;
# 이제 medium은 읽기 가능한 에러와 함께 로컬에서 거부되어야 합니다.
result = generate_image(prompt="test", thinking_level="medium")
...
그다음 전수 조사(세 개의 도구 시그니처(tool signatures), 독스트링(docstrings), 서버의 자기 기술적(self-describing) get_help, 잘못된 값을 반복했던 모든 문서)를 수행하고, 이 버그를 가져가는 모든 사람에게 배포되고 있었던 게시된 Docker 이미지를 다시 빌드하여 푸시(push)했습니다.
Before / after
| Before | After | |
|---|---|---|
| 기본 파라미터를 사용한 라이브 호출 (Live call with default params) | 매번 HTTP 400 발생 | 🟢 이미지 저장됨 |
| ... | ||
첫 번째 실패부터 Docker Hub에 수정된 이미지가 올라가기까지 걸린 시간: 약 10분 — 실패한 도구 호출(tool call)이 스택 트레이스(stack trace) 대신 읽을 수 있는 텍스트(Allowed values are: low, high)로 반환되었기 때문입니다. 해결책을 명시하는 에러 메시지는 디버깅의 절반을 차지합니다. |
배운 점 (What I learned)
- 모킹된 테스트(Mocked tests)는 코드를 검증하지만, 계약(contract)을 검증할 수는 없습니다. 루프 안에 저렴한 라이브 스모크 테스트(smoke call)를 하나 유지하세요. 제 경우에는 이제 데모 스크립트 자체에 포함되어 있습니다 (
DEMO_FAST=1 ./demo.sh). - 기본값(defaults)을 구체적으로 테스트하세요. 기본값은 아무도 명시적으로 전달하지 않는 값이기 때문에 아무도 실행하지 않습니다. 즉, 계약 드리프트(contract drift)가 가장 오래 숨어 있을 수 있는 곳입니다.
- 원격에서 관리되는 값에 대한 로컬 허용 목록(allowlist)은 드리프트 시한폭탄입니다. 에이전트 측의 에러 메시지를 개선하기 위해 사전 검증(pre-validate)을 수행한다면(그럴 가치가 있습니다!), API가 거부하는 것을 직접 관찰한(observed) 값들에 대해 회귀 테스트(regression test)를 고정하세요.
- 데모 스크립트는 당신이 작성할 수 있는 가장 저렴한 엔드 투 엔드(end-to-end) 테스트입니다. 실제 자격 증명(credentials), 실제 API, 실제 해피 패스(happy path) — 바로 모킹(mocks)이 도달할 수 없는 계층입니다. 제 데모 스크립트는 관객이 보기도 전, 첫 번째 실행에서 이미 제 값을 다 했습니다.
Google AI의 최적 활용 (Best Use of Google AI)
전체 프로젝트는 처음부터 끝까지 Google AI를 기반으로 구축되었습니다:
- 서버는 Interactions API를 통해 **
gemini-3.1-flash-lite-image**를 래핑(wrap)합니다. 상태 유지 세션(store=True+previous_interaction_id)이 멀티턴(multi-turn) 이미지 편집을 가능하게 하는 핵심입니다. 데모의 편집 단계는 불과 몇 초 전에 생성된 바로 그 이미지에 네온 RAMEN 간판을 추가하며, 픽셀 수준의 연속성을 유지합니다. - 세 명의 소비자(consumers) 중 하나는 Google ADK 에이전트(
gemini-2.5-flash기반의LlmAgent)로,MCPToolset을 통해 MCP를 거쳐 서버의 도구들을 가져옵니다. 즉, Gemini가 Gemini를 호출하는 과정에서 그 사이에 버그 수정 사항이 위치하게 됩니다. - 버그 자체는 라이브 Gemini API와의 계약 불일치(contract mismatch)였으며, 수정 사항은 이를 통해 검증되었습니다. 400 에러의 정확하고 실행 가능한 메시지(
Allowed values are: low, high) 덕분에 이 문제는 10분 만에 해결(smash)될 수 있었습니다.
링크
- 🐳 수정된 서버 이미지:
xbill9/nb2lite-mcp - 📖 상세 사후 분석(post-mortem): My Demo Script Found a Production Bug on Its First Run
- 🏗️ 아키텍처: Build One AI Tool Server, Call It From Three Different Agents
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기