LLM 타임아웃과 재시도 설계, 지수 백오프가 비용을 키우는 구간

LLM 타임아웃과 재시도

지수 백오프를 켜두면 재시도가 안전해지고 비용도 자연히 안정된다고 생각하는 분들이 많습니다. 하지만 LLM 호출처럼 요청 하나의 처리 시간이 길고 단가도 큰 환경에서는, LLM 타임아웃과 재시도 설계를 지수 백오프 하나로 밀어붙이는 순간 대기 시간과 토큰 비용이 동시에 불어나는 구간이 생깁니다.

원인은 재시도 정책 자체가 아니라, 타임아웃이 걸리는 시점에 서버가 이미 토큰을 얼마나 생성해 놓았는지를 구분하지 않는다는 데 있습니다. 아래에서 이 구간이 왜 생기는지, 그리고 어떤 조건에서 지수 백오프 대신 다른 전략을 써야 하는지 실제 계산으로 짚어보겠습니다.

지수 백오프가 오히려 손해를 보는 지점

지수 백오프는 원래 429(Rate Limit)처럼 “서버가 지금 바빠서 거절했다”는 오류에 맞춰 설계된 전략입니다. 이런 오류는 요청이 서버에 도달하기 전에 즉시 거부되기 때문에, 재시도해도 추가 비용이 거의 없습니다.

문제는 LLM 호출에서 흔히 발생하는 오류가 429뿐만 아니라 클라이언트 타임아웃과 5xx라는 점입니다. 긴 답변을 생성하던 중 클라이언트가 정해둔 시간(예: 30초)을 넘겨 연결을 끊으면, 서버 쪽에서는 이미 수백~수천 개의 토큰을 만들어낸 뒤일 수 있습니다. 이 상태에서 지수 백오프를 적용해 대기 시간을 1초, 2초, 4초, 8초로 늘려가며 재시도하면, 매 시도마다 이미 완성된 응답을 처음부터 다시 만들어내는 비용이 그대로 누적됩니다. 요청 간격을 벌리는 동안 실패 비용은 줄지 않고, 오히려 사용자 체감 지연만 길어집니다.

재시도 간격을 지수로 늘렸을 때 실제로 벌어지는 계산

말로만 설명하면 체감이 어려우니, 재시도 5회 기준으로 대기 시간을 직접 계산해보겠습니다. 아래 코드는 상한(cap)이 없는 지수 백오프와, 8초에서 상한을 둔 지수 백오프를 비교합니다.

def backoff_wait(attempt, base=1.0, factor=2.0, cap=None):
    wait = base * (factor ** attempt)
    return min(wait, cap) if cap else wait

def simulate(retries=5, cap=None):
    waits = [backoff_wait(i, cap=cap) for i in range(retries)]
    return waits, sum(waits)

uncapped_waits, uncapped_total = simulate(retries=5, cap=None)
capped_waits, capped_total = simulate(retries=5, cap=8)

print("cap 없음:", uncapped_waits, "합계", uncapped_total, "초")
print("cap=8초:", capped_waits, "합계", capped_total, "초")

API 타임아웃 오류를 디버깅하는 개발자

cap 없음: [1.0, 2.0, 4.0, 8.0, 16.0] 합계 31.0 초
cap=8초: [1.0, 2.0, 4.0, 8.0, 8.0] 합계 23.0 초

상한을 두지 않으면 5번째 재시도만으로도 16초를 그냥 흘려보냅니다. 여기에 토큰 비용을 얹어보면 문제가 더 뚜렷해집니다. 예시로, 실패 직전까지 매번 출력 토큰 800개를 만들다 끊기고 최종 성공 시 1,500개를 완성한다고 가정해보겠습니다. (아래 단가는 설명을 위한 임의 가정치이며 실제 API 요금이 아닙니다.)

  • 가정 단가: 출력 토큰 1,000개당 0.03달러
  • 실패 4회 × 800토큰 = 3,200토큰 낭비 → 약 0.096달러
  • 성공 1회 × 1,500토큰 → 약 0.045달러
  • 총 비용 약 0.141달러 vs 한 번에 성공했을 때 0.045달러

같은 조건이라면 재시도 때문에 토큰 비용만 3배 가까이 늘어나는 셈입니다. 여기에 대기 시간까지 지수로 늘어나니, LLM 타임아웃과 재시도 설계에서 상한 없는 백오프는 실패가 반복될수록 비용과 지연이 함께 폭주하는 구조가 됩니다.

타임아웃 값은 얼마로 잡아야 할까요?

정답은 호출 유형마다 다릅니다. 짧은 분류·추출 작업(입력 몇백 토큰, 출력 수십 토큰 수준)은 실제 응답 시간의 p95를 측정해 그 값에 여유를 조금 더한 수준(보통 10~20초 사이)이면 충분한 경우가 많습니다.

반면 긴 문서 요약이나 에이전트 체인처럼 여러 번의 LLM 호출이 순차로 이어지는 작업은 단일 타임아웃 하나로 묶지 않는 편이 안전합니다. 개별 호출 단위 타임아웃과 전체 파이프라인 타임아웃을 분리해서, 개별 호출이 실패해도 전체 흐름이 처음부터 재시작되지 않도록 설계하는 것이 중요합니다. 값을 정할 근거가 없다면 감으로 30초, 60초를 넣기보다, 최근 1~2주간의 실제 응답 시간 로그를 뽑아 분포를 확인하고 그 위에서 결정하는 방식이 재시도 횟수와 비용을 함께 통제하는 데 유리합니다.

지수 백오프·고정 간격·즉시 실패, 언제 어떤 걸 써야 하나

지수적으로 증가하는 클라우드 비용 그래프

세 방식은 적합한 오류 유형이 서로 다릅니다. 표로 정리하면 다음과 같습니다.

전략 적합한 오류 비용 영향 주의점
지수 백오프 429(Rate Limit) 재시도당 추가 비용 거의 없음 타임아웃·5xx에 그대로 쓰면 대기·비용이 함께 증가
고정 간격 재시도 네트워크 순단, 일시적 5xx 예측 가능, 관리 쉬움 서버가 실제로 바쁜 상황에는 부적합
즉시 실패(서킷 브레이커) 반복되는 타임아웃, 부분 생성 낭비가 큰 경우 실패를 빨리 확정해 낭비 차단 사용자에게 대체 응답이나 재시도 버튼 등 별도 UX 필요

지수 백오프의 기본 개념과 지터(jitter)를 함께 쓰는 이유는 위키백과의 지수 백오프 문서에 정리돼 있습니다. 실무에서는 이 세 가지를 오류 코드별로 분기해서 쓰는 편이, 하나의 정책으로 모든 실패를 처리하는 것보다 비용 통제에 유리합니다.

이 설계가 안 통하는 경우: 스트리밍과 부분 완료 응답

스트리밍 응답을 쓰고 있다면 위에서 설명한 계산이 그대로 적용되지 않습니다. 스트리밍은 토큰이 생성되는 대로 클라이언트에 전달되기 때문에, 연결이 끊기기 전까지 받은 부분 응답을 버리지 않고 그대로 활용하거나 이어붙이는 방식이 재시도보다 효율적인 경우가 많습니다.

또한 도구 호출(tool call)이 중간에 끼어 있는 에이전트 흐름에서는 재시도가 같은 부작용을 두 번 일으킬 위험이 있습니다. 예를 들어 결제 API를 호출하는 도구가 타임아웃 응답만 못 받았을 뿐 실제로는 처리에 성공했다면, 백오프 후 재시도가 중복 처리로 이어질 수 있습니다. 이런 경우는 재시도 정책보다 멱등키(idempotency key)를 먼저 도입하는 편이 우선순위가 높습니다. OpenAI 파이썬 SDK가 429·5xx에 한해 제한적으로 자동 재시도를 수행하는 방식은 공식 저장소 README의 재시도·타임아웃 안내에서 확인할 수 있는데, 여기서도 재시도 대상 오류를 특정 코드로 좁혀둔 이유가 바로 이 부작용을 줄이기 위해서입니다.

타임아웃은 짧게, 재시도는 원인별로 다르게 설계해 보세요

LLM 타임아웃과 재시도 설계를 지수 백오프 하나로 뭉뚱그리면, 429처럼 재시도 비용이 거의 없는 오류와 타임아웃처럼 재시도할 때마다 토큰 비용이 다시 발생하는 오류를 똑같이 취급하게 됩니다. 그 결과 실패가 반복될수록 대기 시간과 비용이 함께 불어나는 구간이 생깁니다.

바로 실행해볼 수 있는 다음 단계는 이렇습니다. 먼저 최근 로그에서 오류 코드별 비율(429 대 타임아웃·5xx)을 뽑아보고, 타임아웃 값을 호출 유형별 p95 응답 시간 기준으로 다시 잡아보세요. 그다음 429에는 지수 백오프를, 반복되는 타임아웃에는 재시도 상한을 낮추거나 즉시 실패 후 대체 응답으로 넘어가는 서킷 브레이커를 붙이는 식으로 오류 유형별 정책을 분리하는 것이 이 구간을 피하는 가장 확실한 방법입니다.

문서가 바뀔 때마다 다시 임베딩하고 있다면 — 변경 감지와 증분 색인 설계

Leave a Comment