나에게 금전적 손실을 입힌 5가지 Claude API 오류와 이를 방지하는 방법
요약
Claude API 사용 중 발생할 수 있는 재시도 폭풍, 무한 도구 루프 등 비용 손실을 유발하는 5가지 오류 사례를 분석합니다. 이를 방지하기 위한 지수 백오프, 지터(Jitter), 멱등성 키 활용 및 서킷 브레이커 구축 등 실무적인 방어 기제를 제안합니다.
핵심 포인트
- 지수 백오프와 지터를 사용하여 재시도 폭풍 방지
- 멱등성 키를 활용해 중복 요청 및 비용 청구 차단
- 무한 루프 방지를 위한 엄격한 반복 횟수 제한 설정
- 예상치 못한 비용 폭증을 막는 일일 호출 카운터 도입
-
재시도 폭풍(Retry storms)으로 인해 타임아웃 1건이 90초 만에 340건의 중복 호출로 이어져 비용이 청구됨
-
무한 도구 루프(Infinite tool loop)가 새벽 2시에 인지하기 전까지 1,200회 반복됨
-
부분적인 스트림 정리(Partial stream cleanup) 문제로 인해 절반만 작성된 DB 쓰기가 발생하여 레코드가 손상됨
-
서킷 브레이커(Circuit breaker)와 엄격한 반복 횟수 제한(Hard iteration cap)을 통해 모든 오류 클래스를 포착함
다섯 가지 Claude API 오류는 제가 방어 기제를 구축하기 전까지 제 계정의 잔액을 조용히 갉아먹었습니다. 이 오류들은 그 어떤 것도 요란한 충돌(Crash)을 일으키지 않았습니다. 그저 제가 잠든 사이 계속해서 비용을 청구했을 뿐입니다. 무엇이 고장 났는지, 비용이 얼마나 들었는지, 그리고 현재 제가 모든 프로젝트에서 실행하고 있는 방지책(Traps)은 무엇인지 정확히 알려드리겠습니다.
90초 만에 340번의 비용을 청구한 재시도 폭풍(Retry Storm)
제가 저지른 가장 비싼 실수는 순진한 재시도 로직(Naive retry logic)이었습니다. 단 하나의 요청이 타임아웃(Timeout)되었습니다. 제 코드는 타임아웃을 감지하고 재시도했습니다. 재시도 역시 타임아웃이 발생했고, 그래서 다시 재시도했습니다. 90초 만에 저는 하나의 작업을 위해 340개의 요청을 날리게 되었습니다.
문제는 Claude API가 실제로 그 요청들 중 여러 개를 수신하고 처리했다는 점이었습니다. 타임아웃은 Anthropic 측이 아니라 응답을 기다리던 제 쪽에서 발생했습니다. 즉, 저는 제가 확인하지 못한 완료된 작업에 대해 비용을 지불하고, 다시 재시도에 대해서도 비용을 지불하고 있었던 것입니다.
제 첫 번째 버전의 재시도 로직은 무해해 보였습니다. while 루프, 5로 설정된 카운터, 시도 사이의 1초 대기(Sleep)가 전부였습니다. 결함은 대기 시간이 일정했고, 새로운 작업이 시작될 때마다 카운터가 초기화되었다는 점이었습니다. 부하가 걸리면 작업들이 쌓이게 되고, 각 작업은 자신만의 재시도 체인을 생성했습니다. 그렇게 타임아웃 1건이 340건의 호출이 되었습니다.
해결책은 상한선이 있는 지수 백오프(Exponential backoff)와 요청 ID(Request ID)를 사용하는 것이었습니다. 이제 저는 논리적 작업당 고유한 멱등성(Idempotency) 스타일의 키를 생성하며, 첫 번째 요청이 완전히 해결되거나 치명적 오류(Hard-fail)가 발생하기 전까지는 동일한 키에 대해 두 번째 호출을 내보내는 것을 거부합니다. 백오프는 2초에서 시작하여 최대 32초까지 두 배로 늘어나며, 총 5번의 시도 후에 포기합니다.
attempt = 0
delay = 2
...
지터 (Jitter)는 보기보다 훨씬 중요합니다. 지터가 없다면, 실패한 10개의 작업이 모두 정확히 같은 초에 재시도되어 동기화된 폭주 (synchronized stampede)를 일으킵니다. 지터는 작업들을 분산시켜, 복구가 또 다른 스파이크 (spike)를 만드는 대신 부드럽게 이루어지도록 합니다.
또한, 제가 정상적인 상황에서는 절대 도달할 수 없는 임계값을 넘을 경우 전체 프로세스를 강제로 중단시키는 일일 호출 카운터 (daily call counter)를 추가했습니다. 새벽 3시에 무언가 잘못되더라도, 이제 최악의 상황은 큐 (queue)가 중단되는 것이지 네 자릿수 금액의 예상치 못한 비용이 발생하는 것이 아닙니다. 더 넓은 워크플로우 (workflow) 맥락을 알고 싶다면, Claude Blueprint에서 제가 이러한 작업들을 엔드 투 엔드 (end to end)로 어떻게 구조화하는지 설명하고 있습니다.
1,200번의 반복을 수행한 무한 도구 루프 (Infinite Tool Loop)
도구 사용 (Tool use)은 에이전트 (agent)가 제 역할을 하는 지점이기도 하지만, 돈을 가장 빨리 낭비하는 지점이기도 합니다. 저는 Claude에게 파일 검색, 파일 읽기, 결과 쓰기라는 일련의 도구들을 부여했습니다. 에이전트는 두세 개의 도구를 호출한 뒤 작업을 마쳐야 했습니다.
하지만 대신 에이전트는 갇혀버렸습니다. 검색 도구를 호출하고, 결과를 읽고, 약간 다른 쿼리로 다시 검색해야겠다고 결정한 뒤, 그것을 다시 읽고, 이 과정을 반복했습니다. 각 루프는 점점 늘어나는 메시지 히스토리 (message history)가 첨부된 전체 왕복 (round trip) 과정이므로, 각 반복은 이전보다 더 많은 비용이 발생했습니다. 저는 새벽 2시에 1,200번의 반복이 진행된 후에야 이를 발견했습니다.
모델이 고장 난 것은 아니었습니다. 제 프롬프트 (prompt)가 종료 조건 (exit condition)이 없는 탈출구 (escape hatch)를 남겨두었던 것이 문제였습니다. 원하는 것을 찾을 수 없을 때, "다시 검색"은 항상 유효한 다음 단계였기에 에이전트는 항상 그 선택을 했습니다.
저는 세 가지 트랩 (trap)을 겹쳐서 이 문제를 해결했습니다. 첫째, 엄격한 반복 횟수 제한 (hard iteration cap)입니다. 어떤 에이전트 실행도 12회의 도구 사이클 (tool cycles)을 초과할 수 없습니다. 제한에 도달하면 실행은 종료되며, 나중에 검사할 수 있도록 명확한 실패 로그를 남깁니다.
둘째, 반복 탐지기 (repeat detector)입니다. 저는 각 도구 호출의 이름과 인자 (arguments)를 해시 (hash)합니다. 한 번의 실행에서 동일한 해시가 세 번 나타나면, 이를 차단하고 에이전트가 답변을 하거나 실패하도록 강제합니다. "약간 다른 쿼리"를 사용하는 속임수도 여전히 걸러집니다. 공백을 제거하고 소문자로 변환하면 거의 동일한 검색어들은 보통 같은 해시로 정규화 (normalize)되기 때문입니다.
셋째, 실행당 비용 측정기 (cost meter)입니다. 모든 실행에는 누적 토큰 합계 (running token tally)가 수반됩니다. 한도를 초과하면 실행은 도중에 중단됩니다. 저는 예산을 40배나 초과하는 완벽한 답변보다는, 부분적인 답변과 함께 플래그 (flag)를 받는 쪽을 택하겠습니다.
반복 횟수 제한 (iteration cap)만 있었어도 그날 밤의 손실을 막을 수 있었을 것입니다. 나머지 두 가지는 제한 범위 내에 머물면서도 호출을 낭비하게 만드는 더 미묘한 루프 (loops)를 방지합니다. 저는 이 패턴의 프로덕션 버전에 대해 Claude Agent SDK in Production에서 다루었으며, 해당 글에서는 가드 레이어 (guard layers)를 더 자세히 설명합니다.
내 데이터베이스를 오염시킨 부분적 스트림 (Partial Stream)
스트리밍 응답 (Streaming responses)은 스트림이 중간에 끊기기 전까지는 매우 훌륭하게 느껴집니다. 저는 Claude의 출력을 스트리밍하면서 청크 (chunks)가 도착하는 대로 데이터베이스에 기록하고 있었습니다. 효율적으로 보였습니다. 기다리지 않고 진행하면서 바로 기록하는 방식이었으니까요.
그러다 긴 응답의 약 60% 지점에서 연결이 끊겼습니다. 제 코드는 이미 첫 60%를 레코드에 기록한 상태였습니다. 이제 그 레코드에는 문장 중간에서 끝나는 절반짜리 제품 설명이 담기게 되었습니다. 더 나쁜 것은, 제 다운스트림 퍼블리싱 작업 (downstream publishing job)이 기록이 불완전하다는 사실을 인지하지 못해, 깨진 텍스트를 그대로 라이브로 배포해 버렸다는 점입니다.
근본 원인은 스트림이 반드시 완료될 것이라고 가정하고 처리한 데 있었습니다. 스트림은 트랜잭션 (transactions)이 아닙니다. 스트림은 네트워크, 타임아웃 (timeout), 서버 측의 일시적 오류 (server-side hiccup), 또는 제 프로세스가 강제 종료되는 등 어떤 이유로든 어떤 바이트에서든 멈출 수 있습니다.
함정은 부분적인 스트림 출력을 절대 커밋 (commit)하지 않는 것입니다. 이제 저는 전체 스트리밍 응답을 로컬 캐시 (local cache)에 모두 모은 다음, 스트림이 깨끗한 완료 이벤트 (clean completion event)를 보낸 후에만 데이터베이스에 기록합니다. 만약 그 이벤트가 발생하기 전에 스트림에 오류가 발생하면, 지금까지 수집된 모든 것을 버리고 처음부터 전체 작업을 다시 시도합니다.
chunks = []
try:
...
쓰레기 데이터를 배포하는 것에 비하면, 먼저 메모리에 축적하는 비용은 아주 미미합니다. 긴 응답이라 할지라도 수백 킬로바이트 (kilobytes) 내에 충분히 들어가므로, 커밋 전에 이를 유지하는 것은 실제적으로 비용이 들지 않습니다.
또한 모든 게시 (publish) 단계 이전에 완전성 검사 (completeness check)를 추가했습니다. 텍스트는 반드시 문장 종결 부호로 끝나야 하며, 해당 콘텐츠 유형에 따른 최소 길이 게이트 (minimum length gate)를 통과해야 합니다. 200자 미만의 설명은 거의 항상 잘린 스트림 (truncated stream)이므로, 배포되는 대신 플래그가 지정되어 보류됩니다. 이 단일 검사만으로도 전혀 관련 없는 실패 사례를 포함하여, 예상보다 더 많은 부분적 쓰기 (partial writes) 오류를 잡아낼 수 있었습니다.
파서를 충돌시킨 잘못된 형식의 tool_use 블록
이 오류는 저에게 직접적인 비용을 청구하지는 않았습니다. 하지만 제 시간을 앗아갔으며, 1인 스튜디오에게 시간은 곧 비용과 같습니다.
저는 Claude의 모든 tool_use 블록이 유효한 JSON 인자 (arguments)를 포함할 것이라고 가정했습니다. 대부분은 그렇습니다. 하지만 가끔, 특히 복잡하게 중첩된 입력 (nested inputs)의 경우, 모델이 완전히 파싱할 수 없는 인자를 생성하거나, 제가 몇 주 전에 이름을 변경한 도구 이름을 참조하기도 합니다. 제 파서 (parser)는 예외 (exception)를 발생시켰고, 전체 작업이 중단되었습니다. 그리고 작업이 재시도 래퍼 (retry wrapper) 내부에서 중단되었기 때문에, 1절에서 언급한 재시도 폭풍 (retry storm)을 유발했습니다. 두 개의 버그가 서로를 악화시킨 것입니다.
해결책은 모든 tool_use 블록을 신뢰할 수 없는 입력 (untrusted input)으로 취급하는 것이었습니다. 저는 파싱 과정을 try 블록으로 감쌉니다. JSON 파싱에 실패하더라도 시스템이 충돌하지 않습니다. 대신 Claude에게 인자가 잘못된 형식임을 알리는 tool_result를 보내고, 유효한 입력으로 호출을 재시도하도록 요청합니다. 모델은 대부분의 경우 한 번의 추가 턴 (turn) 내에 스스로를 수정합니다.
try:
args = json.loads(block.input)
...
알 수 없는 도구 이름의 경우, 실제로 존재하는 도구 목록을 나열하며 유사한 오류 결과를 반환합니다. 이를 통해 에이전트 (agent)가 유령 도구 (phantom tool)에 갇히는 것을 방지하고, 막다른 길 대신 앞으로 나아갈 수 있는 경로를 제공합니다.
더 큰 교훈은 에러 결과(error results)가 단순히 제 코드의 실패만을 위한 것이 아니라는 점이었습니다. 그것은 하나의 대화 채널입니다. 무엇이 잘못되었는지 모델에게 정확하게 알려주면, 모델은 침묵 속에서 실패하고 맹목적으로 재시도하는 것보다 훨씬 더 잘 스스로를 수정(self-correct)할 수 있습니다. 구조화된 에러 결과(structured error results)를 추가한 이후로, 제 에이전트(agent)는 사람이 개입(human in the loop)하지 않고도 잘못된 도구 호출(tool calls)로부터 복구하여 실행됩니다. 이는 사람이 잠든 새벽 2시에 매우 중요한 요소입니다. 만약 게시된 출력물을 나중에 예약하여 발행한다면, 저는 파싱(parsing) 단계와 게시(posting) 단계를 완전히 분리하기 위해 제 결과물을 Buffer를 통해 처리합니다.
비용이 청구되기 전에 에러를 포착하는 방법
다섯 가지 사고 모두에서 나타난 패턴은 동일했습니다. 에러는 모델이 이상하게 행동한 것이 아니었습니다. 에러는 제 코드가 '해피 패스(happy path, 정상 경로)'를 가정하고, 현실이 그와 다를 때 이를 제어할 상한선(ceiling)을 갖추지 못했다는 점이었습니다.
그래서 저는 이제 예외 없이 모든 프로젝트에서 실행되는 네 가지 상한선을 구축했습니다.
첫째, 모든 API 호출 앞에는 서킷 브레이커(circuit breaker)가 위치합니다. 어떤 종류든 5회 연속 실패하면, 서킷 브레이커가 작동(open)하여 API를 계속 두드리는 대신 60초 동안 새로운 호출을 거부합니다. 이 단 하나의 보호 장치만 있었어도 다섯 가지 사고 중 세 가지는 막을 수 있었을 것입니다. 이는 폭주하는 루프(runaway loop)를 짧은 일시 정지와 로그 한 줄로 변환해 줍니다.
둘째, 작업당 12회의 도구 사이클(tool cycles) 반복 제한과 실행당 토큰 예산(token budget)을 설정하여 루프를 잡아냅니다. 두 제한 중 어느 하나라도 초과하면 깔끔하게 중단되며, 제가 수동으로 검토하는 데드 레터 큐(dead-letter queue)로 들어갑니다. 저는 1,200회 반복된 실행을 발견하는 것보다 아침에 멈춰버린 5개의 작업을 검토하는 쪽을 택하겠습니다.
셋째, 일일 지출 트리프와이어(tripwire, 인계철선)는 총 호출 횟수가 정상적인 운영 범위에서 도달할 수 없는 수치를 넘어서면 전체 워커(worker)를 중단시킵니다. 이것은 투박한 도구이지만, 바로 그것이 핵심입니다. 제가 지켜보고 있지 않을 때는 영리한 것보다 투박한 것이 더 낫습니다.
마지막으로, 구조화된 로깅(structured logging)이 이 모든 것을 하나로 묶어줍니다. 모든 호출은 작업 키(job key), 시도 횟수(attempt number), 토큰 수(token count), 그리고 결과를 기록합니다. 무언가 고장 나면 추측하는 대신 몇 분 안에 타임라인을 재구성할 수 있습니다. 저는 제 모든 상점을 Shopify로 운영하고 있으며, 동일한 로깅 규율을 그 위에 연결하는 모든 통합(integration) 과정에도 적용하고 있습니다.
이 실수들은 그 어떤 튜토리얼보다 더 많은 것을 가르쳐 주었습니다. 각각의 실수는 영구적인 함정이 되었습니다. 그 이후로는 단 한 번도 이 문제들로 인해 화를 낼 일이 없었습니다.
결론 (Bottom Line)
여기 언급된 모든 오류는 제가 '해피 패스 (happy path)'를 신뢰했기 때문에 발생한 비용이었습니다. 타임아웃 (timeout)은 일회성 이벤트가 아니라, 재시도 체인 (retry chain)의 시작입니다. 도구 호출 (tool call)은 마지막 단계임이 보장되지 않으며, 루프 (loop)로 이어지는 초대장입니다. 스트림 (stream)은 트랜잭션 (transaction)이 아니며, 어떤 바이트 (byte)에서도 끊길 수 있습니다. tool_use 블록은 신뢰할 수 있는 입력이 아니라, 검증해야 할 대상입니다.
함정은 간단합니다: 지터 (jitter)를 포함한 지수 백오프 (exponential backoff), 멱등성 키 (idempotency keys), 엄격한 반복 횟수 제한 (hard iteration caps), 반복 감지 (repeat detection), 누적 후 커밋 방식의 스트리밍 (accumulate-then-commit streaming), 구조화된 오류 결과 (structured error results), 서킷 브레이커 (circuit breaker), 그리고 일일 트리프와이어 (daily tripwire)입니다. 이 중 영리한 것은 하나도 없습니다. 모두 지루할 뿐입니다. 하지만 새벽 2시에는 바로 그 지루함이 당신이 원하는 것입니다.
Claude API를 기반으로 에이전트 (agents)를 구축하고 있다면, 기능을 작성하기 전에 천장 (ceilings, 한계치)부터 설정하십시오. 제가 사용하는 전체 구조는 Claude Blueprint에 있으며, 프로덕션 가드 레이어 (production guard layers)에 대한 더 자세한 내용은 Claude Agent SDK in Production에서 확인할 수 있습니다. 함정을 먼저 만드십시오. 버그는 반드시 찾아올 것입니다.
이 기사에는 제휴 링크가 포함되어 있습니다. 이 링크를 통해 가입하시면, 귀하에게 추가 비용 부담 없이 저에게 소정의 수수료가 지급될 수 있습니다. (광고)
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기