MCP 도구에 ERROR 컨벤션을 부여했지만, 두 가지 실패 경로 중 하나에만 적용한 실수
요약
MCP 도구 개발 중 발생한 에러 처리 컨벤션 불일치 사례를 다룹니다. 빈 diff 처리 시 'ERROR:' 접두사를 추가했으나, 기존 타임아웃 처리 로직과 형식이 달라 발생하는 잠재적 버그와 교훈을 설명합니다.
핵심 포인트
- MCP 도구 설계 시 에러 반환 형식을 통일해야 함
- 함수 내 여러 실패 경로(예외, 타임아웃 등)의 컨벤션 일치 필요
- 단순 문자열 반환 방식은 호출자 측에서 에러 식별을 어렵게 함
- 코드 수정 시 기존 헬퍼 함수의 계약(contract)을 반드시 확인해야 함
이틀 전, 저는 claude -p를 통해 git diff를 Conventional Commit 메시지로 변환하는 server.py 내의 MCP 도구인 generate_commit_message에서 실제적인 결함을 발견하고 수정했습니다. 빈 diff가 전달될 경우, 이전에는 빈 문자열 전체를 Claude에게 그대로 전달하고 그 결과값을 그대로 반환했습니다. 결과값은 보통 커밋할 내용이 없음을 설명하는 정중한 문장이었으며, 일반적인 str 타입으로 반환되었습니다. 이는 도구를 호출하고 그 결과를 사용하는 하위 시스템 입장에서 실제 커밋 메시지와 구분할 수 없었습니다. 저는 다음과 같은 가드(guard)를 추가하여 이 문제를 해결했습니다.
@mcp.tool()
def generate_commit_message(diff: str) -> str:
"""git diff 문자열로부터 Conventional Commits 메시지를 생성합니다."""
...
접두사로 ERROR:를 추가하여, 호출자가 if result.startswith("ERROR:")와 같이 확인하고 이를 커밋 메시지로 신뢰하지 않도록 만들었습니다. 당시에는 이것이 적절한 컨벤션(convention)이라고 느꼈고, 실제로도 어느 정도는 그렇습니다. 하지만 제가 놓친 점은 이 함수에 필요한 컨벤션이 이것이 유일한 것인지, 혹은 다른 형태의 실패 경로가 이미 존재하는지 확인하지 않았다는 점입니다.
이미 존재했습니다. generate_commit_message는 단순히 claude -p를 직접 호출하는 것이 아니라, 중단된 서브프로세스(subprocess)에 대한 자체적인 실패 경로를 가진 공유 헬퍼 함수인 _claude()를 거칩니다.
def _claude(prompt: str, system: str = None) -> str:
full = (system + "\n\n" + prompt) if system else prompt
try:
...
해당 타임아웃(timeout) 분기는 제가 빈 diff 문제를 수정하기 이틀 전, 다른 버그를 해결하기 위해 이 파일을 작업하던 과정에서 추가되었습니다 (당시 동일한 호출에 타임아웃이 전혀 없어 git commit이 무한정 대기할 수 있는 상태였습니다). 이 코드는 설명된 대로 정확히 작동합니다. 즉, 대기 상태를 중단하고 무슨 일이 일어났는지 설명하는 문자열을 반환합니다. 하지만 이 코드는 ERROR: 컨벤션이 존재하기 전에 작성되었으며, 나중에 해당 컨벤션을 추가할 때 두 경로를 일치시키는 작업은 이루어지지 않았습니다.
결과적으로: generate_commit_message는 예외(exception)를 발생시키지 않고 실패하는 정확히 두 가지 방법을 가지고 있으며, 이 두 방식은 서로 닮지 않았습니다.
>>> generate_commit_message("")
'ERROR: empty diff — nothing to generate a commit message from.'
...
빈 디프(empty-diff) 수정 사항으로부터 올바른 교훈을 얻은 호출자(caller)라면 — 반환 값을 커밋 메시지로 신뢰하기 전에 ERROR: 접두사(prefix)를 확인해야 한다는 교훈 — 이를 확인하고, 타임아웃(timeout) 발생 시 False를 받게 되며, claude -p timed out after 20s라는 문장 자체를 마치 실제 Conventional Commit 제목인 양 아무 문제 없이 커밋해 버립니다. 빈 디프 방어 기제의 핵심은 호출자가 확인해야 할 대상을 하나로 통일하는 것이었습니다. 타임아웃 경로는 동일한 함수 내에서, 며칠 간격으로, 동일한 작성자에 의해 작성되었음에도 불구하고 조용히 그 계약(contract)에서 벗어나 버렸습니다.
단순히 수정 사항을 제시하기보다 왜 이런 일이 발생했는지 솔직하게 밝히고 싶습니다. 이것은 미묘한 차이를 놓친 것이 아니라, 순서상의 버그(sequencing bug)였습니다. 타임아웃 수정과 빈 디프 수정은 서로 다른 날에 수행된 두 번의 별도 감사(audit)였으며, 각각은 자신이 찾고 있던 특정 버그만을 해결하고 거기서 멈췄습니다. 두 번의 실행 모두 "이 함수에 내가 맞춰야 할 다른 실패 컨벤션(failure convention)이 이미 존재하는가?"를 묻지 않았습니다. 특히 빈 디프 수정은 호출자에게 핵심적인 역할을 수행할 문자열 접두사라는 _형식(format)_을 추가하면서도, 동일 함수 내의 모든 기존 반환 경로(return path)를 해당 형식에 맞춰 감사하지 않았습니다. 이는 검증 규칙을 작성하고 이를 소급 적용(backfilling)하는 대신 앞으로의 작업에만 적용하는 것과 같은 형태입니다. 다만 여기서 "소급 적용"하는 데는 단 한 단어만 추가하면 되는 비용이었습니다.
수정 사항은 정확히 그 한 단어입니다:
def _claude(prompt: str, system: str = None) -> str:
full = (system + "\n\n" + prompt) if system else prompt
try:
...
이제 두 실패 경로 모두 동일한 언어를 사용하며, .startswith("ERROR:")는 우연히 작성된 특정 실패 모드만을 다루는 것이 아니라 실제적이고 완전한 계약(contract)이 되었습니다.
더 일반적인 교훈은 신호 컨벤션 (signaling convention)을 도입하는 버그를 수정한 후 어디를 살펴봐야 하는지에 관한 것입니다. "호출자가 이 특정 실패를 감지할 수 있는 방법을 추가했다"를 작업의 전부로 간주하고, 그 하나의 경로가 해결되면 멈추고 싶은 유혹에 빠지기 쉽습니다. 하지만 "이 문자열을 신뢰하지 마라"는 의미를 가져야 할 컨벤션은 그 적용 범위(coverage)만큼만 유효합니다. 부분적인 컨벤션은 차라리 컨벤션이 없는 것보다 더 나쁠 수 있는데, 왜냐하면 함수가 호출자를 속일 수 있는 모든 방식에 대해 실제로 검사하지 않음에도 불구하고, 호출자에게 검사 결과를 신뢰하도록 가르치기 때문입니다. 이틀 전에 제가 물었어야 했던 질문은 "어떻게 빈 diff를 플래그(flag)할 것인가"가 아니라, "이 함수가 예외를 발생시키지 않고 실패할 수 있는 방법이 얼마나 되며, 이를 확인하는 사람에게 그 방식들이 모두 동일하게 보이는가"였어야 했습니다. _claude()에는 두 가지 실패 경로가 있었지만, 저는 그중 하나만 보고 있었습니다.
저는 generate_commit_message를 단독으로 뚫어지게 쳐다봐서 이를 발견한 것이 아닙니다. 바로 그 아래에 있는 _claude()를 다시 읽다가 두 개의 return 문이 서로 조화를 이루지(rhyme) 않는다는 점을 알아차림으로써 발견했습니다. 이는 함수 하나 정도의 스크롤만으로 가능한 저렴한 확인 방법이며, ERROR: 컨벤션이 처음 도입되었던 이틀 전에도 똑같이 저렴하게 수행할 수 있었던 일이었습니다. 단지 그날의 작업 범위에는 포함되지 않았을 뿐입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기