
실제 서비스에서 AI가 '가끔 다운되는 것'을 허용하는 설계 — LLM 호출의 신뢰성 엔지니어링(리트라이·타임아웃·폴백) 실전 가이드
요약
LLM API 호출 시 발생하는 불확실한 장애에 대응하기 위한 레질리언스 엔지니어링 실전 가이드를 제공합니다. 리트라이, 타임아웃, 폴백 등 5가지 핵심 방어 전략을 통해 서비스의 신뢰성을 높이는 방법을 다룹니다.
핵심 포인트
- LLM API는 일반 API보다 가동률이 낮고 실패 확률이 높음
- 장애를 전제로 한 리트라이, 타임아웃, 폴백 설계가 필수적임
- 레이트 리밋과 응답 지연 등 LLM 특유의 실패 원인 이해 필요
- 서비스 안정성을 위한 멱등성과 서킷 브레이커 도입 권장
AI 기능을 비교적 뚝딱 만들 수 있는 시대가 되었습니다.
await client.messages.create(...)
처럼 한 줄만 쓰면 요약도, 분류도, 채팅도 돌아옵니다. 데모는 완벽합니다. 사내에서 보여주면 박수가 터져 나옵니다.
하지만 실제 서비스(Production)에 배포하고 며칠 뒤, 어느 날 사용자로부터 문의가 들어옵니다.
"방금 AI 답변 버튼을 눌렀는데 에러가 났어요."
로그를 보면 다음과 같은 것들이 나열되어 있습니다.
429 Too Many Requests
503 Service Unavailable
529 overloaded_error
...
매번 그런 것은 아닙니다. 10번에 1번, 아니 100번에 1번 정도입니다. 하지만 확실하게 다운됩니다.
이런 경험, 있지 않으신가요?
솔직히 말씀드리겠습니다. 이것은 당신의 코드가 서툴러서가 아닙니다. LLM API 호출은 일반적인 API보다 다운되기 쉽습니다. 그리고 로컬에서 몇 번 호출해 보는 것만으로는 그 '가끔 발생하는 실패'를 거의 만나지 못합니다. 그래서 아무런 방어책도 없는 await ...
단판 승부식 코드가 그대로 실제 서비스로 나가버리는 것입니다.
이 기사는 그 '가끔 다운되는 상황'과 어떻게 함께할 것인가에 대한 이야기입니다. 전문적으로는 레질리언스 엔지니어링 (Resilience Engineering = 무너지지 않는 설계) 또는 **폴트 톨러런스 (Fault Tolerance = 장애에 견디는 설계)**라고 불리는 영역이지만, 어려운 용어는 이 기사 안에서 모두 쉽게 풀어 설명하겠습니다.
먼저 결론부터 말씀드립니다.
외부 LLM은 '언젠가 반드시 다운된다'는 전제로 호출한다. 다운되었을 때 몇 번을, 어떻게 기다리고, 언제 포기하며, 어디로 우회할지를 미리 코드에 작성해 둔다.
이것뿐입니다. 이것을 '리트라이 (Retry)', '타임아웃 (Timeout)', '멱등성 (Idempotency)', '폴백 (Fallback)', '서킷 브레이커 (Circuit Breaker)'라는 5가지 방어책으로 나누어, 그대로 복사해서 쓸 수 있는 코드와 함께 쌓아 올리겠습니다.
무지(다운되는 이유를 아예 모르는 상태)에서 시작하여, 내일은 "우리 AI 기능은 방어책이 제대로 들어가 있어"라고 말할 수 있는 상태까지 함께 가봅시다.
방어책을 배우기 전에 적을 알아둡시다. "왜 그렇게 자주 다운되는가?"를 모르면 대책이 와닿지 않기 때문입니다.
우선, 냉혹한 사실부터 말씀드리겠습니다. 클라우드 인프라(서버나 스토리지 등)의 가동률은 대개 99.9% 이상이 당연하지만, LLM 프로바이더의 가동률은 **대개 9999.5%**로 보고되고 있습니다. 이는 클라우드 인프라보다 614배 정도 '다운되기 쉽다'는 뜻입니다.
실제로 OpenAI는 2025년 12월~2026년 3월의 가동률을 **99.76%**로 공개했습니다. 숫자만 보면 높아 보일 수 있지만, 99.76%는 연간 약 16시간 동안 다운되어 있다는 계산이 나옵니다. 하루 꼬박에 가까운 시간이 어딘가에서 멈춰 있다는 것입니다.
그리고 현장의 체감상으로는, LLM API 호출의 1~5%는 실패한다고 합니다. 100번 호출하면 1~5번은 실패합니다. 이것이 '가끔 다운되는 것'의 정체입니다.
왜 이렇게 다운되기 쉬운 걸까요? 이유를 분석하면 다음과 같습니다.
느리다: 일반적인 API는 밀리초(ms) 단위로 응답하지만, LLM은 수 초에서 수십 초가 걸립니다. 오래 기다리는 만큼 중간에 연결이 끊길 확률(Window)도 넓습니다.
레이트 리밋 (Rate Limit)이 엄격하다: "1분 동안 이만큼만 호출할 수 있습니다"라는 상한선이 토큰 수(Token count) 기준으로 꽤 까다롭게 적용됩니다 (나중에 자세히 다룹니다).
과금이 얽혀 있다: 호출할 때마다 비용이 발생합니다. 따라서 '일단 연타하기'는 가장 하지 말아야 할 행동입니다.
혼잡으로 인한 동반 장애: 인기 있는 모델은 전 세계에서 호출되기 때문에 서버가 일시적으로 용량을 초과할 수 있습니다 (이것이 Anthropic의 529 overloaded_error입니다).
장문 스트리밍 (Streaming): 한 글자씩 흘려보내는 (스트리밍) 응답은 중간에 네트워크가 끊기면 "절반만 전달되고 끝"이 납니다.
여기서 중요한 관점의 전환(Reframe)을 하나 하겠습니다.
"다운되지 않게 만드는 것"은 불가능합니다. 100% 막을 수는 없습니다. 그러므로 "다운되더라도 사용자에게 큰 문제가 되지 않도록 만드는 것"을 목표로 합니다.
완벽한 성벽을 쌓는 것이 아니라, 넘어져도 찰과상 정도로 끝날 수 있도록 난간과 울타리를 설치하는 것입니다. 그런 발상입니다.
방어의 첫걸음은 리트라이 (Retry = 다시 한번 시도하기)입니다.
하지만 여기서 갑자기 가장 저지르기 쉬운 실수를 먼저 말씀드리겠습니다.
❌ 무엇이든 무조건 리트라이하지 마라.
왜냐하면, 실패에는 두 가지 종류가 있기 때문입니다.
- retryable (리트라이 가능한 실패): 다시 시도하면 성공할 수도 있는 일시적인 실패. 혼잡, 순간적인 네트워크 단절, 서버의 일시적 오류 등.
- non-retryable (리트라이하면 안 되는 실패): 몇 번을 다시 해도 같은 결과가 나오거나, 오히려 재시도할수록 상황이 악화되는 실패. 요청 방식이 잘못되었거나, 인증이 만료되었거나, 입력이 너무 긴 경우 등.
인증 오류(401)를 10번 리트라이해도 영원히 통과할 수 없습니다. 오히려 무의미하게 요청을 보내 상대방의 Rate Limit (속도 제한) 상황을 악화시킬 뿐입니다. 따라서, HTTP 상태 코드 (서버가 반환하는 3자리 숫자)를 통해 "이것이 리트라이해도 되는 것인가?"를 가장 먼저 판정하는 것이 철칙입니다.
대략적인 구분은 다음과 같습니다.
| 상태 | 의미 | 리트라이 | 한마디 |
|---|---|---|---|
429 | Too Many Requests (Rate Limit) | ✅ 함 | 단, retry-after 헤더의 초(seconds)를 반드시 준수 |
500 | 서버 내부 오류 (Internal Server Error) | ✅ 함 | 일시적인 경우가 많음 |
502 / 503 | 게이트웨이/서비스 이용 불가 (Gateway/Service Unavailable) | ✅ 함 | 일시적인 혼잡 |
529 | overloaded_error (Anthropic, 서버 과부하) | ✅ 함 | 상대방의 문제. 몇 분 내로 해결되는 경우가 많음 |
| 타임아웃/연결 단절 | 네트워크 계열 | ✅ 함 | 단, 멱등성 (Idempotency)에 주의 (후술) |
400 | Bad Request (잘못된 요청) | ❌ 안 함 | 수정해야 할 것은 자신의 코드 |
401 / 403 | 인증/권한 오류 (Unauthorized/Forbidden) | ❌ 안 함 | 키(Key)나 권한을 수정 |
404 | Not Found | ❌ 안 함 | 모델명이나 경로(Path) 오류 |
422 | 입력을 처리할 수 없음 (너무 긴 경우 등) | ❌ 안 함 | 입력을 줄이거나 분할 |
400번대(4xx)는 기본적으로 "당신의 요청이 이상하다" 계열이므로 리트라이 대상에서 제외됩니다. 예외는 429입니다. 이것은 "당신의 잘못은 아니지만 지금은 혼잡하니 잠시 기다려 달라"는 의미이므로 리트라이 대상입니다. 500번대(5xx)는 "서버 측이 일시적으로 쓰러졌다" 계열이므로 기본적으로 리트라이 대상입니다.
이 구분 로직을 먼저 함수로 만들어 둡니다.
# Python: 이 실패가 리트라이 가능한지 한 곳에서 판정함
RETRYABLE_STATUS = {429, 500, 502, 503, 529}
def is_retryable(status_code: int | None, exc: Exception | None = None) -> bool:
...
포인트는 "리트라이를 해도 되는지"에 대한 판단을 한 곳으로 모으는 것입니다. 코드 곳곳에 if status == 429라고 흩뿌려 놓으면 반드시 어딘가에서 누락됩니다. 판정은 여기서 한다는 규칙을 정해두어야 합니다.
"리트라이 가능한 실패"라는 것을 확인했다면, 드디어 리트라이를 합니다. 하지만 여기에도 함정이 있습니다.
❌ 실패하자마자 똑같은 속도로 여러 번 요청하기 —— 이것은 최악의 방법입니다.
전화로 비유해 보겠습니다. 가게에서 "지금은 매우 혼잡합니다"(=429)라고 말하고 있는데, 전화를 끊자마자 바로 다시 거는 상황입니다. 혼잡한데도 계속 끊고 다시 거는 것이죠. 전국의 모든 고객이 이 행동을 일제히 한다면, 가게의 전화기는 계속 울리기만 할 것이고 결국 아무도 연결할 수 없게 됩니다. 이를 **thundering herd (썬더링 허드 = 군중의 폭주)**라고 부릅니다.
따라서 리트라이에는 두 가지 기법을 도입합니다.
- 지수 백오프 (exponential backoff): 대기 시간을 매번 배수로 늘립니다. 1초 → 2초 → 4초 → 8초…. 실패가 계속될수록 상대방을 배려하여 간격을 넓힙니다.
- 지터 (jitter = 변동성): 대기 시간에 무작위적인 편차를 더합니다. 모두가 "정확히 2초 뒤"에 재시도하면 결국 다시 일제 접속이 발생하므로, 의도적으로 분산시킵니다.
이 지터를 적용하는 방식에 대해서는 사실 AWS가 오래전부터 "어떤 방식이 가장 효과적인가"를 실험하여 결론을 내린 바 있습니다. AWS의 "Exponential Backoff And Jitter"라는 유명한 글에 따르면, Full Jitter (풀 지터) 방식이 종합적으로 가장 좋다고 합니다. 클라이언트의 재시도 횟수가 최소화되고, 완료까지 걸리는 시간도 짧아집니다. 식은 다음과 같습니다.
대기 시간 = random(0, min(cap, base × 2^리트라이 횟수))
base (기준 대기 시간, 예: 0.5초)와 cap
(cap (대기 상한, 예: 30초))를 정하고, 그 범위 내의 임의의 값을 뽑는다. 이것뿐이다.
그리고, 또 하나 매우 중요한 규칙이 있다.
retry-after 헤더가 반환되면, 자체적인 백오프 (backoff) 계산보다 그것을 우선한다.
OpenAI도 Anthropic도 429 에러 발생 시 "앞으로 몇 초 더 기다려"라는 retry-after 헤더를 반환해 준다. 서버가 "이만큼만 기다리면 여유가 생길 거야"라고 알려주는 것이므로, 멋대로 계산하기보다 순순히 그에 따르는 것이 가장 빠르고 정확하다. 성급하게 재시도해 봤자 어차피 거절당할 뿐이다.
모든 것을 포함한 수동 리트라이 (retry) 코드는 다음과 같다. 라이브러리 없이도 이 30줄이면 프로덕션 품질이 된다.
import random
import time
import httpx
...
"직접 작성하는 것이 조금 불안하다..."라고 생각하는 분들은 검증된 라이브러리에 맡기는 것도 방법이다. Python이라면 tenacity가 표준이다. OpenAI의 공식 쿡북 (Cookbook)에서도 소개되고 있다.
from tenacity import retry, wait_random_exponential, stop_after_attempt, retry_if_exception_type
import openai
@retry(
...
wait_random_exponential이 바로 "지수 백오프 (exponential backoff) + 지터 (jitter)"를 수행해 주는 부분이다. 편리하다.
참고로, OpenAI나 Anthropic의 공식 SDK는 기본적으로 몇 차례의 리트라이를 내장하고 있다. 따라서 "순수 SDK만으로도 어느 정도는 보호되고 있다"는 것은 사실이다. 다만, 리트라이 횟수, 어떤 실패를 대상으로 할지, 전체 예산을 어떻게 가져갈지는 자신의 애플리케이션 상황에 맞춰 제어하고 싶을 것이다. 그렇기에 이 부분을 이해하고 직접 설계할 수 있다는 점은 매우 큰 강점이다.
리트라이 다음은 타임아웃 (timeout = 시간 초과로 인한 중단)이다. 이것은 사소해 보이지만 매우 중요하다.
무엇이 무서운가 하면, "실패"가 아니라 "언제까지나 응답이 돌아오지 않는" 상태다.
LLM의 응답이 지연되어 서버가 침묵한 채로 유지되는 상황. 타임아웃을 설정하지 않으면 당신의 코드는 그곳에서 영원히 기다리게 된다. 그동안 요청을 처리하는 스레드(thread)나 커넥션(connection)은 붙잡힌 상태로 남는다. 이것이 쌓이면 하나의 느린 LLM이 애플리케이션 전체를 끌고 들어가 멈추게 만든다. 이를 **카스케이드 장애 (cascade failure = 연쇄 붕괴)**라고 한다.
그러므로, "여기까지 응답이 오지 않으면 포기한다"라는 선을 반드시 그어야 한다. 그리고 타임아웃에는 한 종류만 있는 것이 아니라는 점도 알아두면 좋다.
연결 타임아웃 (Connection Timeout): 애초에 연결될 때까지의 대기 시간 (예: 5초).
전체 타임아웃 (Total Timeout): 1회 호출 전체의 상한선 (예: 60초).
스트리밍 아이들 타임아웃 (Streaming Idle Timeout): 한 글자씩 흘러나오는 응답에서 "다음 글자가 오지 않는 시간"의 상한선 (예: 20초 동안 오지 않으면 끊음).
스트리밍 시에는 전체 타임아웃만 설정하면 곤란할 때가 있다. 긴 응답은 정상적인 상황이라도 60초 이상 걸릴 수 있기 때문이다. 대신 "다음 조각이 ○초 동안 오지 않는다면, 그것은 죽은 것이다"라는 아이들 타임아웃을 사용하는 것이 살아있는지 죽었는지를 더 잘 판별할 수 있다.
TypeScript (Node.js)에서 AbortController를 사용하여 타임아웃이 포함된 호출을 구현하는 예시다. AbortController는 "이 처리를 이제 중단해도 좋아"라는 신호를 보내기 위해 브라우저와 Node.js 모두에 표준으로 포함된 메커니즘이다.
// TypeScript: 타임아웃을 포함하여 LLM 호출 (fetch 기반의 의사 코드 예시)
async function callWithTimeout(
body: unknown,
...
finally에서 반드시 clearTimeout을 호출해야 한다. 이 부분을 자주 놓치기 때문에 넣어두었다.
그리고, 타임아웃은 리트라이와 결합했을 때 비로소 의미를 갖는다는 점도 숙지해 두자. "60초 기다림 → 끊기 → 잠시 대기 → 다시 한 번 시도". 이 조합을 통해 우연히 느렸던 한 번의 요청을 넘길 수 있다.
단, 여기서 다음 장의 이야기로 이어지는 매우 중요한 주의사항이 있다. 타임아웃으로 끊었지만, 실제로는 서버 측에서 처리가 끝까지 실행되었을 수도 있다는 점이다. 즉, "도달하지 않았다고 생각해서 리트라이를 했는데, 사실은 2번 실행되었다"는 상황이다. 이를 방지하는 것이 다음 주제인 멱등성 (idempotency)이다.
이 부분이 이 글에서 가장 중요한 대목일지도 모른다. 형광펜을 칠해도 좋을 정도다.
⚠️ 부작용(Side effect)이 있는 작업을 아무 생각 없이 리트라이(Retry)해서는 안 된다.
멱등성 (idempotency)이라는 말, 어려워 보이지만 의미는 간단합니다.
멱등 = 같은 조작을 1번 하든 10번 하든, 결과가 변하지 않는 것.
엘리베이터의 「↑」 버튼을 떠올려 보세요. 1번을 누르든 5번을 누르든, 엘리베이터는 한 번만 옵니다. 이것이 멱등입니다. 반면, 자판기의 「구매」 버튼을 5번 누르면, 5개가 나오고 돈도 5번 빠져나갑니다. 이것은 멱등하지 않습니다.
LLM 호출 자체(문장을 생성할 뿐인 작업)는 기본적으로 몇 번을 호출해도 실질적인 해가 없는... 것처럼 보입니다. 하지만 실제 기능은 이런 「부작용 (side effect)」과 세트로 되어 있는 경우가 많습니다.
- 생성 결과를 바탕으로 메일 보내기
- 생성 결과를 DB에 저장하기
- 결제 처리로 돈 차감하기
- 외부로 게시·공개하기
여기서 리트라이가 날카로운 이빨을 드러냅니다. 「타임아웃(Timeout)되었으니까 다시 한번」이라며 리트라이를 했는데, 사실 1회차도 백그라운드에서 성공해서 메일이 2통 발송되거나, 돈이 2번 차감되는 상황이 발생합니다. 이는 사용자의 신뢰를 단번에 깨뜨리는 사고입니다.
방어 방법은 2단계로 구성됩니다.
① 프로바이더의 Idempotency-Key를 사용한다 (LLM 호출의 중복 방지)
OpenAI는 Idempotency-Key라는 메커니즘을 제공합니다. 요청에 고유한 키를 붙여두면, 동일한 키를 가진 중복 요청은 새로 처리하지 않고 첫 번째 결과를 그대로 반환합니다. 타임아웃 후 재전송 시, 실수로 2번 생성되거나 2번 결제되는 것을 방지할 수 있습니다.
import uuid
import openai
client = openai.OpenAI()
...
키를 만드는 방법이 핵심입니다. 리트라이할 때마다 새로운 키를 부여하면 의미가 없습니다. 「같은 조작이라면 몇 번을 리트라이해도 같은 키」가 되도록 해야 합니다. 따라서 uuid4()를 리트라이 루프(Retry loop) 밖에서 한 번만 생성하거나,
user_action_id와 같은 「변하지 않는 사실」로부터 키를 구성합니다.
② 자신의 애플리케이션 측에서도 중복 실행을 차단한다 (부작용의 중복 방지)
프로바이더의 키가 지켜주는 것은 「LLM 호출」까지입니다. 메일 전송이나 결제 같은 자신의 애플리케이션의 부작용은 스스로 지켜야 합니다. 방법은 조작 키를 DB에 기록하여 「이미 수행한 조작은 스킵(Skip)한다」는 것뿐입니다.
// TypeScript: 동일한 조작을 두 번 실행하지 않도록 방어 (의사 코드)
async function runOnce(actionKey: string, effect: () => Promise<void>) {
// actionKey에 UNIQUE 제약 조건을 건 테이블에 먼저 예약을 넣는다
...
암기해야 할 원칙은 이것입니다.
읽기나 생성만 하는 조작은 리트라이 OK. 보내기, 삭제, 결제, 공개하는 조작은 멱등 키(Idempotency key)로 보호한 뒤 리트라이.
리트라이를 해도, 기다려도, 상대방이 장시간 다운(Down)되어 있다면 결국 실패합니다. 그렇다면 전부 포기해야 할까요? 아닙니다. **탈출구 (Fallback)**를 마련해 둡니다.
폴백 (fallback) = 본래 계획이 실패했을 때의 「차선책」. LLM의 세계에서는 다음과 같은 탈출구를 생각할 수 있습니다.
- 다른 모델로 전환: 고성능 모델이 혼잡하다면, 가벼운 모델로 잠정적인 답변을 제공.
- 다른 프로바이더로 전환: A사가 다운되었다면, B사의 동일한 모델로 전환.
- 캐싱된 답변 반환: 자주 묻는 질문이라면 이전에 생성했던 답변을 사용.
- 정해진 문구로 정중하게 항복: 「현재 혼잡합니다. 잠시 후 다시 시도해 주세요」라고 에러 화면이 아닌 인간적인 방식으로 응답 (이를 Graceful degradation = 우아한 품질 저하라고 합니다).
중요한 것은 「새하얀 에러 화면」만은 피하는 것입니다. 다소 품질이 떨어지더라도 무언가 답변이 돌아오는 편이 사용자는 안심합니다.
모델을 순차적으로 시도하는 폴백 체인(Fallback chain)의 예시입니다.
// TypeScript: 위에서부터 순서대로 시도하고, 실패하면 다음으로 전환
type Caller = (prompt: string) => Promise<string>;
async function withFallback(prompt: string, chain: Caller[]): Promise<string> {
...
여기서 걱정이 많은 제가 한마디 덧붙이자면, 폴백 (Fallback)은 「조용히 품질이 떨어지는」 위험과 세트입니다. 「어느샌가 모든 응답이 저렴한 모델에서 반환되고 있었고, 품질이 낮아졌는데도 아무도 눈치채지 못했다」는 흔히 발생하는 사고입니다. 그러므로 폴백 (Fallback)이 발동되면 반드시 로그와 메트릭 (Metrics)에 남겨야 합니다. 「오늘은 폴백 (Fallback) 비율이 평소보다 10배다 → 메인 모델이 상태가 안 좋구나」라고 알아차릴 수 있도록 말이죠. 문제를 회피하는 것 자체보다, 회피했는데도 침묵하고 있는 것이 더 무서운 법입니다.
마지막 방어선은 서킷 브레이커 (Circuit Breaker)입니다. 집의 차단기 (전기를 너무 많이 써서 자동으로 전원을 차단하는 그것) 와 같은 발상입니다.
문제는 이런 상황입니다. 프로바이더 (Provider)가 완전히 다운되었습니다. 그런데도 요청이 올 때마다 「호출 → 60초 타임아웃 (Timeout) 대기 → 실패 → 리트라이 (Retry) → 다시 60초…」를 성실하게 반복합니다. 이것은 다운된 것을 알고 있는 상대에게 매번 풀 타임으로 기다리게 만들고, 무의미한 토큰 (Token) 비용도 지불하게 하며, 사용자도 기다리게 만듭니다. 아무도 이득을 보지 못합니다.
서킷 브레이커 (Circuit Breaker)는 「이 상대, 아까부터 계속 실패하고 있네. 당분간 호출하는 걸 멈추자」라고 자동으로 판단하여, 일정 시간 동안 이쪽에서 호출하는 것을 중단하는 메커니즘입니다. 상태는 세 가지입니다.
| 상태 | 의미 | 동작 |
|---|---|---|
| Closed (닫힘) | 정상 운전 | 그대로 호출한다. 실패를 카운트한다 |
| Open (열림) | 고장 감지 | 일정 시간 동안 호출을 즉시 거부 (기다리지 않고 즉시 실패 또는 즉시 폴백 (Fallback)) |
| Half-Open (반열림) | 복구 테스트 중 | 가끔 딱 한 번만 시도한다. 성공하면 Closed로 돌아간다. 실패하면 다시 Open |
「닫힘/열림」이 다소 헷갈릴 수 있지만, 전기 회로 용어이므로 「Closed = 전기가 흐름 = 정상」, 「Open = 회로가 끊김 = 차단」이라고 기억하면 이해하기 쉽습니다.
최소한의 서킷 브레이커 (Circuit Breaker)를 TypeScript로 작성하면 다음과 같습니다.
// TypeScript: 최소 서킷 브레이커
class CircuitBreaker {
private failures = 0;
...
서킷 브레이커 (Circuit Breaker)와 폴백 (Fallback)은 세트로 사용할 때 최고의 효과를 발휘합니다. 「브레이커가 열려 있음 (= 메인 모델 다운 중) → 기다리지 않고 즉시 다른 모델로 회피」. 사용자는 긴 대기 시간조차 경험하지 않고 다른 경로의 답변을 받을 수 있습니다.
여기까지는 「다운된 이후에 어떻게 할 것인가」였습니다. 하지만 가장 좋은 것은 애초에 429 에러를 겪지 않는 것이겠죠.
레이트 리밋 (Rate Limit)은 프로바이더 (Provider)가 「1분당 이만큼까지」라고 정해둔 상한선입니다. 여기서 OpenAI와 Anthropic의 공식 사양을 정확히 짚고 넘어갑시다 (2026년 6월 기준).
레이트 리밋 (Rate Limit)은 3가지 축으로 측정됩니다.
- RPM (Requests Per Minute): 분당 요청 수
- ITPM (Input Tokens Per Minute): 분당 입력 토큰 수
- OTPM (Output Tokens Per Minute): 분당 출력 토큰 수
어느 하나라도 초과하면 429 에러가 발생합니다. 「요청 수는 여유로운데, 너무 긴 문장을 던져서 토큰 수로 걸리는 경우」는 흔히 있는 일입니다.
그리고 양사 모두 응답 헤더를 통해 「앞으로 몇 번 / 몇 토큰이 남았는지」를 알려줍니다. 이것이 레이트 리밋 (Rate Limit) 대책의 지도 역할을 합니다.
- OpenAI:
x-ratelimit-remaining-requests,x-ratelimit-remaining-tokens,x-ratelimit-reset-requests등 - Anthropic:
anthropic-ratelimit-requests-remaining,anthropic-ratelimit-tokens-remaining,retry-after등
Anthropic의 메커니즘에서 알아두면 유용한 점은 토큰 버킷 (Token Bucket) 방식이라는 것입니다. 1분마다 한꺼번에 리셋되는 것이 아니라, 버킷에 물이 연속적으로 조금씩 채워지는 이미지이므로, 「짧은 버스트 (Burst, 순간적인 집중 액세스)」는 상한선 이내라도 거부될 수 있습니다. 따라서 한꺼번에 던지지 말고 완만하게 흘려보내는 것이 효과적입니다. 공식 문서에서도 「갑자기 트래픽을 늘리면 acceleration limit으로 인해 429가 발생하므로, 점진적으로 램프업 (Ramp-up) 하라」고 명시하고 있습니다.
구현 방법으로는, 클라이언트 측에서 동시 실행 수를 제한하는 것이 가장 간편하고 효과적입니다. Python의 asyncio.Semaphore
(세마포어(Semaphore) = 동시에 통과할 수 있는 인원수를 정하는 문지기)를 사용하여, 병렬 호출 수에 상한을 둡니다.
import asyncio
# 동시에 실행할 LLM 호출은 최대 5개까지라는 문지기
semaphore = asyncio.Semaphore(5)
...
return_exceptions=True로 설정해 두면, 한 건이 실패해도 전체가 휘말려 중단되지 않으므로 (실패는 실패로서 개별적으로 받을 수 있음) 배치 처리(Batch processing)에서는 거의 필수입니다.
동시 실행을 제한하는 것만으로도 429 에러는 상당히 줄어듭니다. "리트라이(Retry)로 맞서 싸우기" 전에, "애초에 수도꼭지를 가늘게 만들기". 이것이 가장 평화로운 방법입니다.
5가지 방어책을 살펴보았습니다. 이것을 하나의 호출 흐름으로 나열하면 다음과 같습니다. 실무에서의 LLM 호출은 대략 이 순서로 통과시키는 것이 정석입니다.
[애플리케이션]
↓ ① 동시 실행 리미터 (수도꼭지를 가늘게 / 429 사전 회피)
↓ ② 서킷 브레이커 (Circuit Breaker) (장애가 발생한 상대라면 즉시 회피)
...
전문적으로는 이러한 역할 분담을 **Failure Budget Allocation (실패 예산 배분)**이라고 부릅니다.
- **리트라이 (Retry)**는 "일시적인 노이즈"를 흡수합니다 (순간적인 혼잡·순간 단절)
- **서킷 브레이커 (Circuit Breaker)**는 "성능이 저하된 엔드포인트"를 흡수합니다 (수십 초~수 분간의 불안정)
- **폴백 (Fallback)**은 "장기적인 장애"를 흡수합니다 (프로바이더가 장시간 다운)
각각 보호하는 시간 스케일이 다릅니다. 그래서 하나면 충분한 것이 아니라, 계층(Layer)을 만들어 겹쳐야 합니다.
그리고 잊어서는 안 될 마지막 한 장이 예산 가드 (비용의 천장)입니다. 리트라이도 폴백도 "다시 호출하는" 작업이므로, 설정을 실수하면 비용이 폭주합니다. "1 요청당 최대 ○회까지", "하루 최대 $○까지"와 같은 상한을 코드나 모니터링을 통해 반드시 설정해 두어야 합니다.
방어책을 추가할수록 호출 횟수는 늘어납니다. 따라서 "얼마나 쓰면 멈출 것인가"라는 브레이크도 반드시 세트로 갖춰야 합니다.
지금까지 구현에 대해 이야기해 왔지만, 이 글의 숨은 주제는 "AI 시대에 인간은 무엇을 설계하는가"입니다.
레질리언스(Resilience, 회복 탄력성) 설계 측면에서 보면, 경계선은 꽤 명확합니다.
| 영역 | 결정 주체 | 내용 |
|---|---|---|
| SLO (어디까지 장애를 허용할 것인가) | 인간 | "이 기능은 99.9% 응답 보장", "폴백은 저렴한 모델로 가능" 등의 방침 |
| 실패 시의 동작 | 인간 | 장애 발생 시 무엇을 반환할지, 어디로 우회할지, 언제 포기할지 |
| 불가역적 작업의 처리 | 인간 | 과금·전송·공개·삭제를 리트라이 대상으로 할지에 대한 판단 |
| 비용 상한 | 인간 | 얼마까지 사용하면 멈출 것인가 |
| ↑를 구현하는 코드 | AI | 백오프(Backoff) 함수, CB 클래스, 테스트, 타입 정의 생성 |
| 실패 로그의 1차 분류 | AI | 에러 뭉치를 "retryable / non-retryable"로 분류하는 초안 |
즉, "무엇을, 왜(What / Why)"는 인간이 쥐고, "어떻게 만들 것인가(How)"는 AI에게 맡기는 것입니다. AI는 백오프 코드를 순식간에 작성해 줍니다. 하지만 "과금 처리를 자동으로 리트라이해도 되는가"는 비즈니스적 판단의 영역이며, AI에게 통째로 맡겨서는 안 되는 영역입니다.
여기서 AI의 도움을 받기 위한 프롬프트 예시 3가지를 남겨둡니다. 모두 "최종 판단은 인간"임을 전제로, AI에게는 재료를 준비하게 만드는 구조입니다.
프롬프트 1: 실패 현황 파악 (분류 초안을 AI가 작성하도록 함)
당신은 SRE입니다. 다음 에러 로그(최근 1주일 분량)를 읽고, LLM API 호출의
실패를 "retryable / non-retryable / 조사 필요"의 3가지로 분류해 주세요.
# 출력 포맷
...
프롬프트 2: 리트라이 & 폴백 방침 리뷰
다음은 우리의 LLM 호출 리트라이/폴백 설정입니다.
실무 운영 관점에서 위험한 점이나 누락된 부분을 비판적으로 리뷰해 주세요.
# 관점
...
프롬프트 3: 불가역적 작업 추출
이 코드베이스 내에서 LLM 결과에 따라 실행되는 "부작용(Side-effect)이 있는 작업"을
모두 찾아내 주세요. 특히, 리트라이 시 중복 실행이 발생할 수 있는
불가역적 작업(전송·과금·공개·삭제·외부 쓰기)을 최우선으로 나열하고,
...
기본적인 방어 기법을 학습한 후, 실수하기 쉬운 포인트들을 정리해 보겠습니다. 걱정이 많은 제가 직접 총점검했습니다.
| # | 함정 | 위험한 이유 | 대책 |
|---|---|---|---|
| 1 | 무분별한 리트라이 (Retry) | 401이나 400 에러를 재시도하는 것은 무의미하게 상대 서버를 공격하는 것이며, 429(Too Many Requests) 상황을 악화시킴 | retryable(재시도 가능 여부) 판정을 한 곳으로 집중 |
| 2 | 지터 (Jitter) 없는 일제 리트라이 | thundering herd(천둥 치는 들소 떼) 현상으로 인해 스스로 장애의 원인이 됨 | 반드시 Full Jitter를 적용 |
| 3 | 타임아웃 (Timeout) 미설정 | 하나의 지연이 애플리케이션 전체를 끌고 내려감 (카스케이드 장애, Cascading Failure) | 전체 타임아웃과 스트림 유휴(Stream Idle) 타임아웃의 이중 설정 |
| 4 | 비멱등(Non-idempotent) 작업의 리트라이 | 이중 과금, 메일 중복 발송 등 = 신뢰도의 즉각적인 상실 | 멱등성 키 (Idempotency Key) + 애플리케이션 측의 runOnce 가드 |
| 5 | 조용한 폴백 (Fallback) | 품질 저하를 아무도 알아차리지 못함 | 폴백 발동 시 반드시 로그 및 메트릭 (Metrics) 기록 |
| 6 | 리트라이/폴백 비용 폭주 | 방어 기법이 그대로 청구서 폭발로 이어짐 | 1 요청당 또는 1일당 상한선 가드 |
그리고 잊기 쉬운 "철수 라인 (여기까지 오면 리트라이를 중단한다)"도 정해두어야 합니다.
횟수 상한: "최대 5회" 등. 무한 리트라이는 금지.
총 시간 상한: "총 90초가 지나면 포기하고, 사용자에게는 부드럽게 양해를 구한다". 계속 기다리게 만들지 말 것.
비용 상한: "이 처리에 $○ 이상이 들면 중단한다".
"포기하는 것"을 설계에 넣는 것은 패배가 아닙니다. 끝까지 미련을 갖는 것보다, 깔끔하게 차선책으로 전환하는 것이 사용자에게는 더 친절합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기