LLM 앱 운영 #5 신뢰성 — 레이트리밋, 재시도, 폴백

5 분 소요

4편까지가 돈 이야기였다면 이번에는 멈추지 않는 구조를 다룹니다. LLM API 운영에서 429(레이트리밋)와 529(과부하)는 장애가 아니라 일상입니다. 트래픽이 좋은 날일수록 더 자주 만납니다. 신뢰성 설계의 목표는 이 일상적인 거절 앞에서 사용자 경험이 무너지지 않게 하는 것입니다.

레이트리밋의 구조 — 무엇이 한도에 걸리는가 #

레이트리밋은 하나의 숫자가 아닙니다. 분당 요청 수(RPM)와 분당 토큰 수(입력·출력 별도)가 각각 걸려 있고, 어느 하나만 넘어도 429가 옵니다. 운영에서 중요한 따름정리 두 가지가 있습니다.

  • 요청 수가 적어도 토큰으로 걸릴 수 있습니다. 긴 컨텍스트의 RAG·에이전트 요청은 몇 개만 겹쳐도 토큰 한도를 칩니다. 1편의 계측에서 요청 수와 토큰량을 따로 보는 이유가 여기에 있습니다.
  • 한도는 모델별입니다. 2편의 라우팅이 신뢰성 장치이기도 합니다. haiku로 가는 트래픽은 opus의 한도를 먹지 않으므로, 라우팅은 한도라는 파이프 자체를 나눠 줍니다.

429 응답에는 retry-after 헤더(몇 초 뒤에 다시 오라)가 실려 옵니다. 이 값을 무시하고 즉시 재시도하면 한도를 더 굳게 만들 뿐입니다.

재시도 — SDK가 주는 것과 직접 챙길 것 #

기본기는 에이전트 1편에서 본 그대로입니다. SDK가 429와 5xx 계열을 지수 백오프로 자동 재시도하고(기본 2회), max_retries로 조절합니다. 운영 관점에서 덧붙일 것이 세 가지 있습니다.

retry_config.py
client = anthropic.Anthropic(
    max_retries=4,          # 야간 배치 등 지연 허용 경로는 넉넉히
    timeout=60.0,           # 기본(10분)은 운영에는 너무 길다. 경로별로 설정
)
  • 재시도 예산은 경로별로 다릅니다. 사용자가 기다리는 챗봇에서 4회 재시도(수십 초)는 무의미합니다. 실시간 경로는 짧게(1〜2회), 백그라운드 경로는 길게 잡습니다.
  • 재시도하면 안 되는 에러를 구분합니다. 400(잘못된 요청)과 401(인증)은 백 번 다시 보내도 같습니다. SDK 는 이를 알아서 구분하지만, 직접 재시도 로직을 얹을 때 4xx 를 재시도 루프에 넣는 실수가 흔합니다.
  • 재시도도 비용입니다. 1편의 로그에 재시도 횟수를 남기면, “비용이 늘었는데 트래픽은 그대로"의 범인이 재시도 폭주인 경우를 잡을 수 있습니다.

타임아웃과 스트리밍 — 긴 응답의 신뢰성 #

LLM 의 응답 시간은 출력 길이에 비례해서, 긴 생성은 수십 초가 정상입니다. 여기서 두 가지 사고가 납니다. 타임아웃을 짧게 잡아 정상 응답을 끊거나, 길게 잡아 죽은 연결을 하염없이 기다리거나입니다. 표준 해법은 긴 응답 경로를 스트리밍으로 바꾸는 것입니다.

streaming_path.py
with client.messages.stream(
    model="claude-opus-4-8",
    max_tokens=16000,
    messages=messages,
) as stream:
    for text in stream.text_stream:
        push_to_user(text)            # 첫 토큰부터 사용자에게 보인다
    response = stream.get_final_message()

스트리밍은 체감 지연(첫 토큰까지의 시간)을 줄이는 UX 기능으로 소개되곤 하지만, 운영 관점에서는 신뢰성 기능입니다. 연결이 살아 있다는 신호가 토큰 단위로 오기 때문에 “죽었는지 느린지 모르는” 구간이 사라지고, max_tokens 가 큰 요청에서 SDK 가 스트리밍을 요구하는 것도 같은 이유(긴 무응답 연결의 타임아웃 위험)입니다. 출력이 길어질 수 있는 경로는 스트리밍을 기본값으로 둡니다.

폴백 — 그래도 안 될 때의 단계적 후퇴 #

재시도로 해결되지 않는 상황(지속적 429, 529, 장애)을 위해 후퇴 계단을 미리 설계합니다. 위에서부터 시도하고 안 되면 내려갑니다.

  1. 모델 강등 — 주 모델이 막히면 같은 요청을 한 단계 작은 모델로 보냅니다. 품질은 조금 내려가지만 서비스는 계속됩니다. 2편의 라우팅 테이블에 fallback 열을 추가하는 모양이 됩니다.
  2. 큐잉 — 실시간성이 덜한 요청은 “잠시 후 처리됩니다"로 받고 큐에 넣습니다. 4편의 배치 파이프라인이 그대로 완충 장치가 됩니다.
  3. 정중한 실패 — 끝내 안 되면 빨리, 명확하게 실패합니다. “지금 요청이 많아 잠시 후 다시 시도해 주세요"는 30초 스피너보다 좋은 경험입니다.
fallback.py
FALLBACK = {"claude-opus-4-8": "claude-sonnet-4-6",
            "claude-sonnet-4-6": "claude-haiku-4-5"}

def call_with_fallback(model: str, **kwargs):
    try:
        return call_llm(model=model, **kwargs)
    except (anthropic.RateLimitError, anthropic.InternalServerError):
        fallback = FALLBACK.get(model)
        if fallback is None:
            raise
        logger.warning("fallback: %s -> %s", model, fallback)
        return call_llm(model=fallback, **kwargs)

폴백 발동을 로그에 남기는 것이 중요합니다. 폴백은 증상 완화이지 치료가 아니므로, 자주 발동한다면 근본 원인(한도 상향 요청, 트래픽 분산, 캐싱으로 토큰 절감)을 처리할 신호입니다.

부하를 만들지 않는 쪽 — 동시성 제한 #

마지막 조각은 방향이 반대입니다. 받는 쪽이 아니라 내가 보내는 속도의 통제입니다. 트래픽 급증이 그대로 API 호출 급증이 되면 한도를 스스로 들이받습니다. 호출 경로에 동시 실행 상한(세마포어)을 두면, 급증분은 대기열에서 잠시 줄을 서고 API 쪽 429는 줄어듭니다. 에이전트 5편의 병렬 서브에이전트나 평가 일괄 실행처럼 내부에서 동시 호출을 만드는 코드에 특히 필요합니다. 한도 안에서 일정하게 흐르는 트래픽이, 몰아쳤다 막혔다를 반복하는 트래픽보다 총처리량도 높습니다.

흔히 걸려 넘어지는 곳 #

  • 429를 장애로 취급한다 — 레이트리밋은 설계 대상이지 알림 대상이 아닙니다. 다만 발생률의 추세는 봐야 합니다. 추세 상승은 한도 상향이나 절감 작업의 신호입니다.
  • 모든 경로에 같은 타임아웃을 쓴다 — 분류(1초면 끝)와 긴 생성(30초가 정상)의 타임아웃이 같을 수 없습니다. 2편의 라우팅 테이블에 타임아웃도 경로별로 둡니다.
  • 폴백을 만들고 잊는다 — 조용히 강등된 채 몇 주를 돌면 품질 저하가 기본값이 됩니다. 폴백 발동률을 대시보드에 올립니다.

마무리 #

이번 글에서는 멈추지 않는 구조를 만들었습니다.

  • 레이트리밋은 요청 수와 토큰 수가 따로 걸립니다. retry-after 를 존중하는 재시도는 SDK 에 맡기고, 재시도 예산은 경로별로 정합니다.
  • 긴 응답 경로에서는 스트리밍이 신뢰성 장치 역할을 합니다. 타임아웃도 경로별로 둡니다.
  • 폴백은 모델 강등 → 큐잉 → 정중한 실패의 계단으로 설계하고, 발동률을 감시합니다. 보내는 쪽의 동시성 제한이 한도 충돌 자체를 줄입니다.

비용과 신뢰성을 갖췄으니 남은 위협은 바깥에서 옵니다. 다음 글인 “LLM 앱 운영 #6 보안 — 프롬프트 인젝션과 데이터 경계"에서 입력으로 앱을 조종하려는 시도를 다룹니다.

X