LLM API 에러를 일반적인 HTTP 에러처럼 취급하지 마세요
요약
LLM API 에러를 일반적인 HTTP 에러와 동일하게 처리하면 비용 증가와 디버깅의 어려움을 초래할 수 있습니다. 429 에러라도 단순 속도 제한인지, 할당량 소진인지 구분하여 차별화된 대응 전략을 세워야 합니다.
핵심 포인트
- LLM API 에러는 일반적인 REST API 에러와 운영상 의미가 다름
- 429 에러를 일시적 제한과 할당량 소진으로 구분하여 처리해야 함
- 단순 재시도는 지연 시간 증가 및 비용 상승의 원인이 될 수 있음
- 에러 유형에 따른 맞춤형 핸들러 설계가 필수적임
대부분의 백엔드 엔지니어들은 이미 HTTP 에러를 처리하는 방법을 알고 있습니다.
400은 요청이 잘못되었음을 의미합니다.
401은 인증(auth)에 실패했음을 의미합니다.
429는 속도 제한(rate limited)을 의미합니다.
500은 업스트림(upstream)에서 무언가 고장 났음을 의미합니다.
몇 번 재시도(retry)하고, 지수 백오프(exponential backoff)를 추가하고, 응답 본문(response body)을 로그에 남긴 뒤 다음으로 넘어갑니다.
이 방식은 많은 API에서 잘 작동합니다.
하지만 LLM API에서는 제대로 작동하지 않습니다.
LLM 제공업체들은 일반적인 HTTP 상태 코드를 사용할 수 있지만, 이러한 에러 뒤에 숨겨진 운영상의 의미는 일반적인 REST 실패와 충분히 다르기 때문에, 이를 평범하게 취급하면 앱이 더 느려지고, 비용이 더 많이 들며, 디버깅하기 어려워질 수 있습니다.
내가 반복했던 실수
초기에 저는 다른 모든 외부 API를 처리하는 것과 동일한 방식으로 LLM 실패를 처리했습니다:
if (response.status === 429 || response.status >= 500) {
retryWithBackoff();
}
단순합니다. 익숙합니다. 하지만 위험합니다.
그 로직은 당신의 앱이 답해야 할 실제 질문을 놓치고 있습니다:
어떤 종류의 LLM 실패가 발생했으며, 제품(product)이 다음에 무엇을 해야 하는가?
왜냐하면 LLM API 실패는 단순히 "하나의 HTTP 요청이 실패한 것"인 경우가 드물기 때문입니다.
이것은 다음과 같은 것들을 중단시킬 수 있습니다:
- 사용자 대상 채팅 응답 (user-facing chat response)
- 백그라운드 에이전트 실행 (background agent run)
- 문서 생성 작업 (document generation job)
- 도구 호출 워크플로우 (tool-calling workflow)
- 배치 평가 파이프라인 (batch evaluation pipeline)
- 구조화된 JSON 생성 단계 (structured JSON generation step)
그리고 각각은 서로 다른 처리가 필요합니다.
모든 429가 같은 의미는 아닙니다
일반적인 API에서 429 Too Many Requests는 보통 다음을 의미합니다:
속도를 늦추고 나중에 다시 시도하세요.
LLM API에서 429는 여러 가지 다른 의미를 가질 수 있습니다.
일시적인 속도 제한(rate limit)일 수 있습니다:
{
"error": {
"message": "Rate limit reached",
...
이 경우에는 백오프(backoff)를 적용한 재시도가 도움이 될 수 있습니다.
하지만 할당량 소진(quota exhaustion)을 의미할 수도 있습니다:
{
"error": {
"message": "You exceeded your current quota",
...
이 경우 재시도하는 것은 도움이 되지 않습니다. 그저 지연 시간(latency)을 늘리고, 시끄러운 로그를 만들며, 더 나쁜 사용자 경험을 초래할 뿐입니다.
또한 모델별 압박(model-specific pressure)일 수도 있습니다. 동일한 제공업체의 다른 모델이나 다른 제공업체의 모델은 잘 작동하는 반면, 특정 모델만 과부하 상태일 수 있습니다.
따라서 당신의 핸들러(handler)는 다음을 구분해야 합니다:
- 일시적인 속도 제한 (temporary rate limit)
- 하드 할당량 소진 (hard quota exhaustion)
- 모델 수준의 용량 문제 (model-level capacity issue)
- 결제 또는 계정 문제 (billing or account issue)
이들은 모두 동일한 재시도 경로 (retry path)를 거쳐서는 안 됩니다.
재시도는 피해를 증폭시킬 수 있습니다
재시도는 시스템을 더 신뢰할 수 있게 만들고 있다고 생각하면서, 실제로는 LLM 시스템을 악화시키는 가장 쉬운 방법 중 하나입니다.
다음 상황을 상상해 보세요:
- 사용자가 앱에 보고서 생성을 요청합니다.
- 백엔드 (backend)가 LLM을 호출합니다.
- 요청이 30초 후에 타임아웃 (timeout) 됩니다.
- 코드가 세 번 재시도합니다.
합리적으로 들릴 수도 있습니다.
하지만 첫 번째 요청은 여전히 제공업체 측에서 실행 중일 수 있습니다. 두 번째 요청은 중복된 답변을 생성할 수 있습니다. 세 번째 요청은 또 다른 속도 제한 (rate limit)에 걸릴 수 있습니다. 사용자는 더 오래 기다려야 합니다. 토큰 비용은 올라갑니다. 이제 로그에는 한 번의 사용자 작업에 대해 네 번의 시도가 기록됩니다.
단순한 완성형 (completions) 작업의 경우, 이는 짜증스러운 일입니다.
에이전트 워크플로 (agent workflows)의 경우, 이는 위험할 수 있습니다.
만약 LLM 호출이 도구 (tools)를 트리거하거나, 이메일을 보내거나, 티켓을 업데이트하거나, 데이터베이스에 쓰거나, 외부 시스템을 호출할 수 있다면, 맹목적인 재시도는 중복된 부수 효과 (side effects)를 만들어낼 수 있습니다.
당신의 재시도 정책 (retry policy)은 어떤 종류의 작업을 재시도하고 있는지 알아야 합니다.
type LlmOperationType =
| "simple_completion"
| "streaming_chat"
...
이것은 여전히 단순화된 형태이지만, 핵심 아이디어는 중요합니다:
재시도 정책은 단순히 HTTP 상태 코드 (status code)가 아니라, LLM 작업 (task)을 이해해야 합니다.
컨텍스트 길이 에러는 제품 에러입니다
컨텍스트 길이 (context length) 에러는 종종 400 에러로 반환됩니다.
일반적인 API에서 400은 종종 개발자가 잘못된 형식의 요청을 보냈음을 의미합니다.
LLM 앱에서 컨텍스트 길이 에러는 종종 제품 수준의 실패를 의미합니다.
이는 당신의 앱이 프롬프트 예산 (prompt budget)을 적절히 관리하지 못했음을 의미합니다.
가능한 해결책은 다음과 같습니다:
- 이전 메시지 요약 (summarize)
- 검색된 문서 트리밍 (trim)
- 도구 출력 크기 축소
- 더 큰 컨텍스트 모델로 전환
- 사용자에게 작업을 좁혀달라고 요청
- 작업을 여러 번의 호출로 분할
가장 최악의 응답은 제공업체의 가공되지 않은 에러 (raw provider error)를 사용자에게 그대로 보여주는 것입니다.
더 나은 제품 응답은 다음과 같은 형태입니다:
이 요청은 한 번에 처리하기에 너무 큽니다. 먼저 소스 자료를 요약한 다음, 이어서 진행할 수 있습니다.
시스템 수준에서, 컨텍스트 에러 (context errors)는 일반적인 잘못된 요청 (bad requests)과는 별도로 분류되어야 합니다.
function classifyLlmError(status: number, message: string) {
const text = message.toLowerCase();
...
이것을 올바르게 분류하고 나면, 앱의 나머지 부분들이 적절하게 대응할 수 있습니다.
스트리밍 실패 (Streaming failures)는 부분적인 제품 상태입니다
스트리밍 (Streaming)은 에러 처리 (error handling)를 더 복잡하게 만듭니다.
일반적인 요청의 경우, API는 성공하거나 실패합니다.
스트리밍의 경우, 모델이 답변의 80%를 생성한 후 연결이 끊어질 수 있습니다.
이제 어떻게 해야 할까요?
다음 사항들을 결정해야 합니다:
- 부분적인 답변을 보여줘야 하는가?
- UI에서 이를 미완성 상태로 표시해야 하는가?
- 사용자에게 이어가기 옵션을 제공해야 하는가?
- 백엔드에서 처음부터 다시 시도 (retry)해야 하는가?
- 재시도 시 부분적인 출력물을 포함해야 하는가?
- 부분적인 구조화된 출력 (structured output)을 폐기해야 하는가?
채팅 제품의 경우, 부분적인 출력이 여전히 유용할 수 있습니다.
코드 생성 (code generation)의 경우, 부분적인 출력은 오해를 불러일으킬 수 있습니다.
JSON 생성의 경우, 부분적인 출력은 유효하지 않을 수 있으며 직접 소비해서는 안 됩니다.
저는 스트림 상태 (stream state)를 명시적으로 추적하는 것을 선호합니다:
type StreamState = {
requestId: string;
startedAt: number;
...
그러면 앱은 다음과 같이 서로 다른 결정을 내릴 수 있습니다:
- 토큰 (tokens)이 하나도 수신되지 않았다면, 자동으로 재시도
- 일부 토큰이 수신되었다면, 미완성 표시를 보여줌
- JSON이 예상되었다면, 출력을 폐기하거나 복구
- 도구 (tools)가 관여되었다면, 중단하고 계속하기 전에 확인을 요청
스트리밍 연결 끊김은 단순한 네트워크 실패가 아닙니다.
그것은 부분적인 제품 상태 (partial product state)입니다.
모델 폴백 (Model fallback)은 단순히 다른 URL로 재시도하는 것이 아닙니다
일반적인 신뢰성 패턴은 다음과 같습니다:
모델 A가 실패하면, 모델 B를 시도한다.
이 방법이 작동할 수도 있지만, 안전한 폴백 규칙 (fallback rules)을 정의했을 때만 가능합니다.
모델마다 다음과 같은 점들이 다를 수 있습니다:
- 컨텍스트 윈도우 (context windows)
- 가격 책정 (pricing)
- 도구 호출 지원 (tool calling support)
- JSON 신뢰성 (JSON reliability)
- 지연 시간 프로필 (latency profiles)
- 거절 동작 (refusal behavior)
- 출력 스타일 (output style)
- 추론 품질 (reasoning quality)
만약 더 강력한 모델에서 더 작은 모델로 조용히 폴백 (fallback)한다면, 요청은 "성공"할지 모르지만 제품의 품질은 소리 없이 떨어질 수 있습니다.
더 나은 폴백 전략은 작업 클래스 (task classes)에서 시작됩니다.
const fallbackModels: Record<string, string[]> = {
high_reasoning: [
"gpt-4.1",
...
폴백은 작업이 무엇을 필요로 하는지에 따라 달라져야 합니다.
예를 들어:
- 요약 (summarization)은 대개 폴백을 허용할 수 있습니다.
- 법률 또는 금융 분석 (legal or financial analysis)은 허용되지 않을 수 있습니다.
- 도구 호출 에이전트 (tool-calling agents)는 호환 가능한 도구 지원을 갖춘 모델이 필요합니다.
- 구조화된 출력 워크플로 (structured output workflows)는 스키마 (schemas)를 안정적으로 따르는 모델이 필요합니다.
- 사용자가 선택한 모델은 조용히 교체되어서는 안 됩니다.
만약 OpenAI 호환 라우팅 레이어 (routing layer)를 사용한다면, 이 지점에서 도움을 받을 수 있습니다. 코드베이스 전반에 제공자별 규칙을 흩뿌리는 대신, 폴백 및 라우팅 동작을 중앙 집중화할 수 있습니다.
이것이 제가 멀티 모델 앱을 위해 TokenBay와 같은 게이트웨이 (gateway)를 사용하는 이유 중 하나입니다. API 인터페이스는 익숙하게 유지하면서도, 모델 선택, 폴백, 그리고 라우팅을 명시적으로 처리할 수 있기 때문입니다.
나중에 실제로 필요하게 될 필드들을 로그로 남기세요
일반적인 API의 경우 상태 코드 (status code), 엔드포인트 (endpoint), 지연 시간 (latency), 그리고 응답 본문 (response body)만으로 충분할 수 있습니다.
LLM API의 경우, 그것만으로는 대개 충분하지 않습니다.
유용한 필드에는 다음이 포함됩니다:
{
"provider": "openai",
"model": "gpt-4.1-mini",
...
이러한 필드들은 장애 발생 시 여러분이 궁금해할 질문들에 답하는 데 도움이 됩니다:
- 장애가 특정 제공자 (provider)에 집중되어 있는가?
- 특정 모델이 대부분의 컨텍스트 에러 (context errors)를 일으키고 있는가?
- 재시도 (retries)가 도움이 되고 있는가, 아니면 단순히 지연 시간 (latency)만 늘리고 있는가?
- 스트리밍 실패 (streaming failures)가 출력이 시작된 후에 발생하는가?
- 백그라운드 작업 (background jobs)이 할당량 (quota)을 너무 많이 소비하고 있는가?
- 폴백 (fallbacks)이 제공자의 서비스 중단 (outage)을 숨기고 있는가?
- 구조화된 출력 실패 (structured output failures)가 특정 모델과 연관되어 있는가?
이러한 데이터가 없다면, 결국 스크린샷, 일화, 그리고 막연한 느낌 (vibes)에 의존하여 LLM 신뢰성을 디버깅하게 됩니다.
저도 그렇게 해본 적이 있습니다. 전혀 즐겁지 않았습니다.
내부 LLM 에러 분류 체계 (taxonomy) 구축하기
모든 곳에서 가공되지 않은 HTTP 상태 코드 (status codes)를 처리하는 대신, 자체적인 내부 에러 카테고리를 만드세요.
type LlmErrorCategory =
| "auth_error"
| "rate_limited"
...
그런 다음, 제공자(provider)별 에러를 여러분만의 카테고리로 매핑하세요.
이렇게 하면 제품의 나머지 부분에서 다음과 같은 사항을 결정할 수 있는 일관된 방법을 갖게 됩니다:
- 재시도 (retry) 여부
- 폴백 (fallback) 여부
- 사용자에게 무엇을 보여줄지
- 무엇을 로그 (log)에 남길지
- 팀에 알림 (alert)을 보낼지 여부
- 워크플로 (workflow)를 일시 중지할지 여부
목표는 HTTP를 숨기는 것이 아닙니다.
목표는 HTTP 상태 코드가 LLM 앱에 필요한 모든 운영 컨텍스트 (operational context)를 포함하고 있는 것처럼 가장하는 것을 멈추는 것입니다.
간단한 처리 매트릭스 (handling matrix)
다음은 실용적인 시작점입니다.
| 에러 카테고리 | 재시도? | 폴백? | 제품 동작 |
|---|---|---|---|
auth_error | 아니요 | 아니요 | 관리자에게 API 키 확인 요청 |
| ... |
매트릭스는 제품마다 다르겠지만, 대략적인 버전이라도 다음과 같은 코드보다는 훨씬 낫습니다:
if (status >= 500 || status === 429) {
retry();
}
마지막 생각
LLM API는 HTTP를 사용하지만, LLM 실패는 단순한 HTTP 실패가 아닙니다.
429는 속도를 늦추라는 의미일 수도 있고, 할당량 (quota)을 업그레이드하거나, 모델을 전환하거나, 작업을 대기열 (queue)에 넣거나, 혹은 재시도를 완전히 중단하라는 의미일 수도 있습니다.
400은 앱이 컨텍스트 (context) 관리에 실패했다는 의미일 수 있습니다.
스트리밍 연결 끊김 (streaming disconnect)은 사용자가 이미 답변의 절반을 받았음을 의미할 수 있습니다.
폴백 (fallback)은 요청을 살릴 수도 있지만, 조용히 품질을 저하시킬 수도 있습니다.
LLM을 기반으로 진지한 서비스를 구축하고 있다면, LLM 특유의 실패 모드 (failure modes)를 이해하는 에러 레이어 (error layer)를 만드세요.
사용자는 깨진 상태를 덜 보게 될 것입니다.
로그는 추론하기 더 쉬워질 것입니다.
그리고 여러분의 재시도 로직이 제공자의 작은 문제를 비용이 많이 드는 운영 사고 (production incidents)로 악화시키는 일을 멈추게 될 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기