데모 스크립트가 첫 실행에서 운영 환경의 버그를 발견하다: 짧은 사후 분석 (Post-Mortem)
요약
데모 스크립트 실행 중 실제 운영 환경에서 발생한 API 오류를 통해 로컬 검증 로직의 결함을 발견한 사례를 다룹니다. 테스트 스위트가 통과했음에도 불구하고 실제 모델 API의 제약 사항과 로컬 검증 계층 간의 불일치로 인해 발생한 문제를 분석합니다.
핵심 포인트
- 유닛 테스트와 모킹이 실제 API의 제약 조건을 완벽히 반영하지 못할 수 있음
- 로컬 검증 계층이 실제 API 계약(Contract)과 일치하는지 확인하는 것이 중요함
- 기본값(default) 설정이 실제 환경에서 오류를 유발할 수 있는 위험 요소임을 인지해야 함
- 데모 스크립트와 같은 실전 실행이 숨겨진 버그를 찾는 유용한 수단이 될 수 있음
프로젝트를 뽐내기 위해 데모 스크립트를 만들었습니다. 그런데 2단계에서 실패했습니다. 그리고 그 과정에서, 모든 테스트 스위트(test suite)가 통과(green)되었음에도 불구하고 코드베이스에 숨어 보이지 않았던 실제 버그를 찾아냈습니다.
이 글은 어떤 일이 일어났는지, 왜 테스트가 이를 놓쳤는지, 그리고 원격 API(remote API) 검증에 대해 무엇을 배웠는지에 대한 짧은 기술 보고서입니다. 총 사고 비용은 약 10분이었지만, 기록할 가치가 있는 교훈을 얻었습니다.
문맥 (Context)
이 프로젝트는 Google의 gemini-3.1-flash-lite-image 모델을 래핑(wrap)한 MCP (Model Context Protocol) 서버입니다. 이미지 생성 및 상태 유지 편집(stateful editing) 기능이 네 가지 도구(tools)로 노출되어 있으며, 어떤 MCP 클라이언트(Claude Code, Google ADK 에이전트, Rust CLI 등)든 이를 호출할 수 있습니다. 아키텍처에 대해서는 별도로 작성해 두었으며, 이 포스트는 오직 버그에 대해서만 다룹니다.
관련된 세부 사항은 다음과 같습니다: 서버는 API를 호출하기 전에 도구 인자(tool arguments)를 _로컬(locally)_에서 검증합니다. 그 인자 중 하나는 모델의 지연 시간 대 품질(latency-vs-quality)을 조절하는 thinking_level입니다.
# server.py — 원래 작성된 코드
SUPPORTED_THINKING_LEVELS = {"minimal", "low", "medium", "high"}
...
허용된 값은 네 가지이며, 기본값은 medium입니다. 모든 것이 모킹(mocked)된 10개의 유닛 테스트(unit tests)는 통과했습니다. 린트(Lint)도 통과했습니다. 서버는 에이전트를 통해 데모되었고 잘 작동했습니다. 그대로 배포하면 되겠죠?
사고 (The incident)
저는 스택을 실시간으로 훑어보는 demo.sh를 작성했습니다: 도구 목록 나열, 이미지 생성, 그리고 상태 유지 편집을 수행합니다. 데모 비용을 낮게 유지하기 위해 가장 낮은(lowest) 사고 수준(thinking level)을 선택했습니다.
cargo run --quiet -- generate "a tiny robot chef cooking ramen" 16:9 minimal
첫 번째 실행, 2단계:
🔴 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'}}
실제 API는 이 모델에 대해 정확히 두 가지 사고 수준인 low와 high만 허용합니다. 네 가지가 아닙니다.
위의 코드와 대조해 보면 실제 버그가 눈에 확 들어옵니다. 그리고 이는 단순히 데모 플래그(demo flag)가 잘못된 것보다 훨씬 심각한 문제입니다.
서버의 **기본값(default)**은
medium이었습니다.thinking_level을 명시적으로 재정의(override)하지 않은 모든 라이브 호출은 확실하게 HTTP 400 에러를 발생시켰습니다.
로컬 검증 계층(validation layer)은 API가 거부할 값들을 기쁘게 승인하고 있었고, 정작 중요한 것은 아무것도 거부하지 않고 있었습니다. 이는 계약(contract)을 검증하는 것이 아니라, 계약에 대한 _기억(memory)_을 검증하고 있었던 것입니다.
10개의 통과된 테스트가 이를 놓친 이유
테스트 스위트(test suite)는 API 클라이언트를 모킹(mock)합니다:
@patch("server._get_client")
def test_generate_image_success(self, mock_get_client):
mock_client.interactions.create.return_value = mock_interaction
...
이것은 좋은 테스트입니다. 인자 처리(argument handling), base64 디코딩, 파일 명명, 에러 형성(error shaping) 등 서버 자체의 로직을 검증하기 때문입니다. 모킹된 테스트는 네트워크로부터 격리(isolate)하는 역할을 하며, 실제로 그렇게 수행합니다.
하지만 그 격리는 양날의 검입니다. 모킹된 테스트는 원격 계약(remote contract)이 변경되었다는 사실(또는 당신이 생각했던 것과 다르다는 사실)을 결코 감지할 수 없습니다. 모킹은 실제 API가 거부할 요청에 대해서도 당신이 지정한 대로 무엇이든 반환합니다. 제 테스트 스위트는 사실상 "서버가 medium을 API로 올바르게 전달한다"라고 단언(assert)하고 있었고, 서버는 실제로 그렇게 했습니다. 잘못된 값을 올바르게 전달하는 것 역시 버그이지만, 단지 모킹 경계(mock boundary) 내부에서는 보이지 않을 뿐입니다.
이 문제가 운영 환경(production)까지 도달하기 위해서는 두 가지 상황이 맞물려야 했습니다:
- 허용 목록(allowlist)이 원격 계약을 복제했습니다.
SUPPORTED_THINKING_LEVELS는 API가 소유한 사실에 대한 로컬 복사본입니다. 로컬 복사본은 문서가 틀렸거나, 모델이 변경되었거나, 혹은 다른 모델을 위해 세트가 작성되었기 때문에 발생하는 차이(drift)로 인해 어긋나기 마련입니다. - 이전의 모든 라이브 테스트가 우연히 유효한 값을 전달했습니다. 도구(tools)를 호출하는 에이전트(Agents)들은 주로
high(품질)를 선택하는 경향이 있었고, 이로 인해 잘못된 기본값과 두 개의 유령 값(phantom values)이 가려졌습니다.
해결책
기계적으로는 지루한 작업이며, 이것이 핵심입니다. 진짜 어려운 부분은 _알아내는 것_이었으니까요:
-SUPPORTED_THINKING_LEVELS = {"minimal", "low", "medium", "high"}
+SUPPORTED_THINKING_LEVELS = {"low", "high"}
...
여기에 실제로 시간이 소요되는 전수 조사(sweep)가 포함됩니다: 세 개의 도구 시그니처(tool signatures)와 독스트링(docstrings), 서버의 자기 기술적(self-describing) get_help 텍스트, README, 두 개의 기사, API 치트 시트 문서 — 그리고 새로운 지식을 고정하는 회귀 테스트 (regression test):
# 라이브 API는 이 모델에 대해 low/high만 허용합니다. medium은 거부되어야 합니다.
result = generate_image(prompt="test", thinking_level="medium")
self.assertIn("Unsupported thinking level 'medium'", result)
그다음 실제 환경에 대한 검증: 문제가 되었던 바로 그 케이스인 순수 기본 파라미터(default parameters)를 사용한 라이브 호출 — 결과는 🟢 Image successfully saved!를 반환했습니다. 그리고 서버가 Docker 이미지로 배포되기 때문에, 게시된 이미지에도 버그가 포함되어 있었으므로 재빌드(rebuild) 및 푸시(push)를 진행했습니다.
이 모든 과정에서 하나의 설계 결정이 제 역할을 다했습니다: 서버의 도구들은 절대 예외를 발생시키지(raise) 않습니다. 대신 모든 것을 포착(catch)하여 사람이 읽을 수 있는 🔴 ... 문자열을 반환합니다. 덕분에 400 에러가 MCP 세션을 파괴하는 스택 트레이스(stack trace) 대신, 에이전트(또는 데모 스크립트, 혹은 저)가 읽고 조치할 수 있는 읽기 쉬운 텍스트로 돌아왔습니다.
교훈 (Lessons)
1. 모킹된 테스트(Mocked tests)는 코드를 검증합니다. 계약(contract)을 검증할 수는 없습니다. 최소한 하나 이상의 실제 API를 건드리는 테스트가 필요합니다 — 아주 저렴한 스모크 테스트(smoke call) 하나라도 말이죠. 제 경우에는 이제 데모 스크립트와 /verify-stack 루틴에 포함되어 있습니다: _기본 파라미터(default parameters)_로 아주 작은 이미지 하나를 생성하는 방식입니다. 왜냐하면 기본값은 아무도 명시적으로 전달하지 않기에 아무도 테스트하지 않는 값이기 때문입니다.
2. 원격에서 관리되는 값에 대한 로컬 허용 목록(allowlist)은 드리프트(drift) 시한폭탄입니다. 만약 사전 검증(pre-validate)을 반드시 해야 한다면 (왕복 400 에러보다 에이전트에게 더 빠르고 명확한 에러를 제공하긴 합니다), 해당 목록을 타인의 진실을 담은 캐시(cache)로 취급하십시오: 해당 값이 어디서 왔는지 주석을 달고, API가 거부하는 것을 관찰한(observed) 값들에 대해 회귀 테스트를 고정(pin)하십시오.
3. 기본값(defaults)을 구체적으로 테스트하십시오. 데모 전까지 모든 라이브 상호작용에서 버그가 살아남았던 이유는 사람과 에이전트가 계속해서 고장 난 기본값을 덮어쓰고(overriding) 있었기 때문입니다. f(x)가 백 번 호출된다고 해서 f()에 대해 알 수 있는 것은 아무것도 없습니다.
4. 데모 스크립트는 당신이 작성할 수 있는 가장 저렴한 엔드 투 엔드 테스트 (end-to-end test)입니다. 이는 실제 자격 증명 (credentials)을 사용하여 실제 API를 대상으로 실제 사용자가 거치는 해피 패스 (happy path)를 실행합니다. 이는 정확히 유닛 테스트 (unit tests)가 도달할 수 없는 계층입니다. 저의 데모는 그 어떤 관객이 발견하기 전, 첫 실행에서 운영 환경의 버그를 찾아냈습니다. 데모가 필요하다고 생각하기 전에 작성하세요. API를 마주하는 코드가 변경될 때마다 패스트 모드 (DEMO_FAST=1)로 실행하세요.
5. 에이전트가 읽을 수 있는 에러를 반환하세요. 읽을 수 있는 텍스트로서 실패하는 도구 호출 (tool calls)은 대화를 지속시켜 줍니다. 호출하는 LLM이 허용되는 값은 다음과 같습니다: low, high를 보고 스스로 올바르게 재시도할 수 있습니다. 바로 이 특성 덕분에 이 버그는 디버깅 세션이 아닌 10분 만의 수정으로 끝날 수 있었습니다.
타임라인 (Timeline)
| T+0 | demo.sh 첫 실행: 2단계에서 HTTP 400 오류 발생 |
| ... | |
| 참고로, 데모는 현재 아주 잘 작동합니다. 상태 유지 편집 (stateful edit)은 픽셀 단위의 연속성 (pixel-for-pixel continuity)을 유지한 채, 새로운 네온 RAMEN 간판이 달린 동일한 사이버펑크 주방을 생성해 냅니다. 하지만 버그 리포트가 더 멋진 이야기가 되었습니다. |
당신만의 "데모가 찾아냈다"는 이야기가 있나요? 댓글로 들려주세요. 👇
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기