워커마다 안전해도 전체 요청은 많아질 수 있다
여러 서버의 이미지 요청은 워커별 제한만으로 다룰 수 없어, 동시 실행 허가와 429 이후 감속 상태를 Redis에서 공유하도록 했다. 워커는 외부 호출의 실행 허가(permit)를 받아야 요청을 시작한다. 이 구현은 동시 실행 수를 제어하며, 1분 구간의 요청 수를 정확히 세는 RPM 제한기는 아니다.
강의 슬라이드 이미지를 만드는 AI Commons에서는 서버가 늘 때 외부 모델로 나가는 전체 요청도 늘었다. 한 워커가 429 응답을 받고 기다려도 다른 워커는 계속 요청할 수 있었다. 개별 재시도와 서비스 전체 감속을 연결해야 했다. 공급자의 429에는 용량 부족도 포함될 수 있어, 고정된 분당 한도 하나를 맞추면 모든 429가 사라진다고 가정하지 않았다.
확인과 발급을 한 번에 처리한다
실행 허가인 permit은 Redis의 Sorted Set에 저장한다. 현재 실행 수를 읽고 여유가 있으면 새 permit을 넣는 과정을 각각의 요청으로 처리하면, 여러 워커가 같은 빈자리를 보고 동시에 진입할 수 있다. 이를 막기 위해 만료 항목 정리, 현재 상태 확인, 실행 수 확인, permit 발급을 하나의 Lua 스크립트로 묶었다.
permit에는 만료 시각도 둔다. 정상 종료하면 바로 반납하지만, 프로세스가 종료돼 반납하지 못한 항목은 이후 획득 과정에서 정리한다. 시간 기준은 각 서버의 시계 대신 Redis의 시간을 사용한다. 이때 만료 시간은 실제 외부 호출의 최대 실행 시간과 함께 맞춰야 한다. 요청이 아직 실행 중인데 permit부터 만료되면 새 요청을 추가로 허용할 수 있기 때문이다.
-- acquire-permit.lua의 핵심 순서
redis.call("ZREMRANGEBYSCORE", inflightKey, "-inf", nowMs)
local inflight = redis.call("ZCARD", inflightKey)
if inflight >= currentLimit then return {0, "at_limit", 0} end
redis.call("ZADD", inflightKey, nowMs + permitTTL, permitId)
return {1, waveId, state} 이 발췌 전에는 상태와 cooldown을 검사한다. 단순히 Sorted Set에 항목을 넣는 것으로 모든 요청을 허용하는 코드는 아니다. 현재 기본 permit TTL은 660초로, 기본 외부 호출 제한 600초보다 길게 잡고 설정 검사도 여유 시간을 요구한다. TTL 자체가 외부 호출을 취소하지는 않으므로 실제 호출 상한이 바뀌면 함께 맞춰야 한다.
워커는 permit을 얻지 못하면 지터를 섞어 기다린 뒤 다시 확인한다. 대기에도 상한을 두어 무기한 진행 중인 작업을 만들지 않도록 했다. 큐의 작업 상태, 외부 요청의 실행 시간, permit을 기다리는 시간은 서로 다른 상태이므로 운영 로그에서도 구분할 필요가 있다.
429가 오면 함께 멈추고, 적은 요청으로 회복을 확인한다
스케줄러는 정상, 대기, 회복 확인, 제한적 복구의 상태를 공유한다. 429를 받으면 동시 실행 상한을 줄이고 새로운 요청을 잠시 막는다. 대기 시간이 끝나면 적은 수의 요청으로 상태를 확인하며, 성공이 누적될 때 상한을 올린다. 실패 직후 이전 속도로 한꺼번에 돌아가는 흐름을 피하려는 선택이다.
감속은 AIMD의 곱셈 감소에서 착안했지만, 실제 복구 코드는 성공 조건을 만족할 때 상한을 두 배씩 늘리는 slow-start 방식이다. 매번 일정량을 더하는 순수한 AIMD 구현이라고 부르지는 않는다. 동시에 나간 여러 요청에서 뒤늦게 429가 돌아오는 경우도 고려했다. 요청 당시의 waveId를 확인해 이미 반영한 실패 묶음이 상한을 계속 깎지 않도록 했다.
기본 상한 10에서 동작을 따라가면 다음과 같다. 숫자는 현재 기본 설정의 실행 순서를 설명하며 운영 로그가 아니다.
- 같은 wave의 요청 A·B 발급permit과 발급 시점 waveId를 받아 외부 호출을 시작한다.
- A가 429 반환permit을 실패로 반납하고 현재 wave에 429를 보고한다. 상한은 10→5, waveId는 1 증가하고 COOLDOWN으로 이동한다.
- B도 뒤늦게 429 반환이전 waveId이므로 추가 감속을 적용하지 않는다. 같은 실패 묶음이 상한을 거듭 깎지 않는다.
- 대기 종료와 성공 누적PROBE 상한 1로 확인한다. 연속 성공 조건을 채우면 DEGRADED에서 2→4→8→10으로 회복한다.
대기는 응답의 Retry-After를 우선 참고하고, 없으면 5·10·20·30초 순서를 사용하며 설정 상한을 적용한다. 재시도는 permit을 다시 획득하므로 공유 cooldown을 거친다. 회복 확인 요청에 또 429가 오면 다시 대기로 돌아간다.
429, 일반 장애, Redis 장애의 처리를 나눴다
429는 공유 스케줄러가 감속과 재시도를 담당하며, Circuit Breaker의 일반 실패 집계에서는 제외한다. Circuit Breaker는 5xx나 네트워크 오류 같은 일반 장애를 다룬다. 현재 코드에는 일시적인 503을 정해진 횟수와 전체 시간 예산 안에서 재시도하는 경로도 있다. 같은 오류를 여러 계층이 제한 없이 재시도하지 않도록 책임과 종료 조건을 명시해야 했다.
Redis에 접근할 수 없는 경우에는 로컬 동시성 제한으로 전환하는 fail-open 정책을 택했다. 생성 기능을 바로 멈추지 않는 대신, 그동안은 여러 서버의 합산 동시 실행 수를 보장하지 못한다. 로컬 모드 전환은 Redis 장애가 해결됐다는 뜻은 아니다. 분산 모드와 로컬 전환 모드를 구분해 표시하고, Redis 복구 후 다시 공유 상태를 사용하도록 했다.
구현한 복구 경로와 운영 효과를 구분한다
코드에서 확인할 수 있는 변화는 원자적인 permit 발급, 공유 감속 상태, 만료 permit 정리, 오류별 복구 경로다. 429 발생률, 대기 시간, 전체 완료 시간의 전후 측정은 다음 평가 범위다.
후속 비교는 같은 부하에서 성공률·큐 대기·전체 완료 시간과 Redis 장애 중의 요청 수를 함께 남겨야 한다. 오류를 줄이는 동안 완료 시간이 과도하게 늘지 않았는지도 선택의 기준이다. Lua의 원자성은 발급 순간의 경쟁을 막고, 실제 호출 시간 상한과 Redis 가용성이 그 보장을 유지하는 조건이다.
관련 구현 맥락은 AI Commons 프로젝트에 정리했다. 기술 배경은 Redis의 Lua 실행 설명과 Vertex AI의 429 오류 문서를 참고할 수 있다.