[feat] LLM 재시도에 지수 백오프 도입 - #207
Conversation
재시도 정책이 "무엇 때문에 실패했는지"로 갈리려면 그 정보가 먼저 예외에 실려야 한다. LlmFailureKind 네 가지(CALL·RATE_LIMITED·PERMANENT·VALIDATION)를 두고, 댓글·카드 예외가 LlmFailure 인터페이스로 그 값을 노출한다. 정책이 두 계열을 한 가지 방식으로 읽을 수 있게 하기 위해서다. 실패 종류를 서브클래스가 아니라 필드로 둔 이유가 있다. 재시도 대상 판정이 예외 타입으로 이뤄지는데(LlmRetryExecutor 의 retryOn), 재시도를 금지해야 할 실패를 서브클래스로 만들면 그 타입 판정에 그대로 걸려 오히려 재시도된다. 기본값은 VALIDATION 이다. 이 예외를 던지는 곳의 다수가 파서·검증기이고, 그들은 호출이 왜 실패했는지 알 필요가 없다. 대신 호출 실패 경로는 기본값에 기대지 말고 종류를 명시해야 한다 - 다음 커밋의 제너레이터가 그렇게 한다. kind 를 생성자 맨 뒤에 둔 것은 토큰 네 개를 위치 인자로 넘기는 호출부가 여섯 곳 있어서다. 중간에 끼우면 그 자리가 전부 깨진다.
제너레이터가 catch (e: Exception) 으로 모든 호출 실패를 한 덩어리로 뭉개고 있었다. ApiException.code() 가 HTTP 상태를 돌려주므로 여기서 갈라낼 수 있다. 분류는 GeminiFailureKinds 한 곳에만 둔다. SDK 예외 타입을 아는 자리를 하나로 묶어, 재시도 정책과 서비스로는 SDK 가 새어나가지 않게 하려는 것이다. 제너레이터 셋이 이 함수를 공유한다. GenAiIOException 과 ApiException 을 따로 짚는다. 둘의 공통 부모(BaseException)가 SDK 패키지 밖에서 보이지 않아 한 번에 잡을 수 없다. 호출 타임아웃은 SocketTimeoutException 을 감싼 GenAiIOException 으로 올라온다. 429 는 RATE_LIMITED, 408 은 CALL, 나머지 4xx 는 PERMANENT 다. 408 만 4xx 에서 빼낸 것은 "요청이 늦었다"라 다시 부를 값어치가 있어서다. 5xx 와 SDK 밖의 예기치 않은 오류는 CALL 로 둔다 - 종류를 못 읽었다는 이유로 재시도를 포기하지는 않는다. 설정 조회(DB) 실패 세 곳에는 CALL 을 명시했다. 메시지가 "LLM 호출에 실패했습니다"라 호출 실패처럼 보이지만 실제로는 DB 실패이고, 기본값 VALIDATION 에 맡기면 간격 없이 곧바로 DB 를 다시 두드리게 된다. 이 커밋만으로는 동작이 달라지지 않는다. 정책이 아직 kind 를 보지 않는다.
prod generation_log 성공 169 건의 실측 지연은 COMMENT p99 가 2062ms, 관측 최댓값이 REPLY 의 5073ms 다. 30초는 COMMENT p99 의 약 열다섯 배다. 타임아웃은 "보통 얼마나 걸리나"가 아니라 "언제 포기하나"를 정하는 값인데, 이 값이면 매달린 호출 하나가 필요한 것보다 열다섯 배 오래 톰캣 스레드를 붙잡는다. Web MVC 라 요청 하나가 스레드 하나를 끝까지 쥐고, 스레드 포화는 댓글만이 아니라 로그인·조회를 포함한 API 전체를 멈춘다. 15초는 관측 최댓값의 세 배, COMMENT p99 의 일곱 배다. 재시도 횟수를 늘리기 전에 먼저 내려둔다 - 30초인 채로 시도만 늘리면 예산 검증에 걸린다. 테스트 yml 은 main 을 병합이 아니라 통째로 대체하므로 같이 내린다.
지금까지는 모든 실패를 간격 없이 똑같이 두 번 시도했다. 상대가 과부하일 때
즉시 재시도하면 오히려 부하를 키우고, 400·401·403 처럼 다시 불러도 같은 답이
올 실패까지 재시도해 사용자를 더 기다리게 한다.
LlmFailureRetryPolicy 가 "다시 부를지"와 "얼마나 기다릴지"를 함께 답한다.
둘 다 실패 종류라는 같은 정보로 갈리므로 한 자리에 두었다 - 나뉘어 있으면
종류를 하나 추가할 때 한쪽만 고치고 지나치기 쉽다.
네트워크·IO·5xx 재시도 O 지수 0.5s → 1s
429 재시도 O 고정 2s
400·401·403 재시도 X -
응답 검증 실패 재시도 O 대기 없음
429 를 따로 뺀 것은 "상대가 아프다"가 아니라 "우리가 너무 빨리 부른다"라서다.
0.5초 뒤 재시도하면 그대로 또 429 다. SDK 가 응답 헤더를 노출하지 않아
Retry-After 를 읽을 수 없어 고정 간격을 길게 잡는다. 반대로 검증 실패는
Gemini 가 멀쩡하고 형식만 틀린 것이라 기다릴 이유가 없다.
executor 의 재시도 판정에 관문을 하나 더했다. retryOn 타입("내가 아는
실패인가")만으로는 가를 수 없다 - 영구 실패가 같은 예외 타입에 실려 오기
때문이다. retryIf 가 "다시 시도할 값어치가 있나"를 답한다. 기본값이 곧 위
정책이라 호출부 네 곳은 그대로 둔다.
시도를 2회에서 3회로 늘렸다. prod 에서 재시도가 한 번도 발생한 적이 없어
(generation_log 169 건 전부 1회 성공) 실측 비용 증가는 없다.
예산 검증이 요청당 호출 체인 수를 반영한다. 카드 생성은 한 요청에서 감정
분류 → 한 줄 생성으로 LLM 을 두 번 순차 호출하는데, 기존 검증은 "한 요청 =
체인 하나"를 가정했다. 그래서 카드 경로만 실제의 절반으로 계산돼, 통과해도
안전하다는 보장이 없었다. 지금 값에서는 여유롭지만 누가 25초로 올리면 댓글은
통과하고 카드만 nginx proxy_read_timeout 을 넘긴다.
곱하는 항이 늘어난 만큼 설정할 수 있는 per-attempt 상한은 좁아진다(60초 →
18.6초). 타임아웃을 올리려면 재시도 횟수·체인 수를 함께 봐야 한다는 뜻이고,
그 관계를 사람이 암산하는 것보다 부팅 때 계산해 막는 편이 낫다고 봤다.
카드 경로만 CARD_MAX_ATTEMPTS = 1 로 묶여 있었다. #161 에서 executor 로 전환할 때 동작을 바꾸지 않으려고 남겨둔 값인데, 시도를 늘리려면 한 요청이 LLM 을 두 번 순차 호출하는 구간이라 예산을 다시 계산해야 해서 미뤄둔 것이다. 앞 커밋이 그 계산에 체인 수를 반영했으니 이제 풀 수 있다. 상수를 지우고 기본값을 쓴다. 감정 분류·한 줄 생성 모두 댓글과 같은 세 번을 시도하고, generation_log 의 attempt_count 에 실제 시도 횟수가 남는다. 네트워크가 한 번 튀었다고 그대로 실패하던 경로가 이제 다시 시도된다. 시도별 토큰 합산 테스트를 더했다. 검증·파싱에 실패한 시도도 호출은 됐으니 과금되는데, 카드 경로에서는 그 합산이 이제서야 처음으로 여러 시도에 걸쳐 일어난다. 누산기 자체는 #161 에서 자리를 잡아뒀지만 실제로 여러 시도를 겪는 테스트가 없었다. 배치의 "하루 한 번" 제한을 설명하는 주석을 사실에 맞게 고쳤다. 카드 한 장이 최악 여섯 번 호출이 되고, 카드 경로는 일일 토큰 상한을 보지 않으므로(#195) 영구적으로 실패하는 방이 태우는 양을 묶는 장치가 사실상 그 하루 한 번뿐이다.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (25)
💤 Files with no reviewable changes (1)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. WalkthroughLLM 실패를 ChangesLLM 재시도 보호 흐름
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: ⚪ Minimal · up to The retry and timeout behavior changes are merge-ready after normal checks and review; no actionable merge-blocking risk remains. Sequence Diagram(s)sequenceDiagram
participant Client
participant LlmRetryExecutor
participant GeminiGenerator
participant GeminiFailureKinds
participant LlmFailureRetryPolicy
Client->>LlmRetryExecutor: 생성 요청
LlmRetryExecutor->>GeminiGenerator: Gemini 호출
GeminiGenerator->>GeminiFailureKinds: 예외 분류
GeminiFailureKinds-->>LlmRetryExecutor: 실패 종류
LlmRetryExecutor->>LlmFailureRetryPolicy: 재시도 여부·백오프 조회
LlmFailureRetryPolicy-->>LlmRetryExecutor: 정책 결과
LlmRetryExecutor->>GeminiGenerator: 재시도 가능한 경우 재호출
LlmRetryExecutor-->>Client: 생성 결과 또는 최종 실패
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Test Coverage
|
🔗 연관 이슈
📌 개요
LLM 호출 실패를 종류별로 갈라 재시도 정책을 적용한다. 지금은 모든 실패를 간격 없이 똑같이 두 번 시도한다. 상대가 과부하일 때 곧바로 다시 부르면 부하만 키우고, 400·401·403 처럼 다시 불러도 같은 답이 올 실패까지 재시도한다. 재시도가 아예 없던 카드 경로도 같이 붙인다.
🔧 주요 변경사항
1. 실패를 네 종류로 구분한다
LlmFailureKind를 예외에 실어 보낸다. SDK 예외 타입을 아는 곳은GeminiFailureKinds하나뿐이다. 재시도 정책과 서비스까지 Gemini 가 새어나가지 않는다.GenAiIOException·ServerExceptionClientExceptioncode 429ClientException429 는 "상대가 아프다"가 아니라 **"우리가 너무 빨리 부른다"**는 뜻이라, 0.5초 뒤 다시 불러도 429 다.
ApiException이 헤더를 노출하지 않아Retry-After는 못 읽고 고정 간격으로 뒀다. 반대로 검증 실패는 Gemini 는 멀쩡하고 형식만 틀렸다. 기다릴 이유가 없다.2. 재시도 판정에 관문을 하나 더했다
retryOn타입만으로는 못 가른다. 영구 실패도 같은 예외 타입에 실려 오기 때문이다.retryIf가 **"다시 시도할 값어치가 있나"**를 답한다.3. per-attempt 타임아웃 30초 → 15초
실측 지연은 COMMENT p99 2062ms, 관측 최댓값은 REPLY 의 5073ms 다. 30초는 p99 의 약 15배라 매달린 호출 하나가 필요한 것보다 그만큼 오래 톰캣 스레드를 붙잡는다. 15초도 관측 최댓값의 3배다.
4. 카드 경로에도 재시도
CARD_MAX_ATTEMPTS = 1을 지웠다. #161 에서 executor 로 전환할 때 동작을 바꾸지 않으려고 남겨둔 값이다. 감정 분류·한 줄 생성 모두 댓글과 같은 3회를 시도하고,attempt_count에 실제 횟수가 남는다.5. 예산 검증이 호출 체인 수를 반영한다
카드 생성은 한 요청에서 감정 분류 → 한 줄 생성으로 LLM 을 두 번 순차 호출한다. 기존 검증은 "한 요청 = 체인 하나"를 가정해 카드 경로만 실제의 절반으로 계산했다. 검증을 통과해도 안전하다는 보장이 없었다.
🌐 API · DB 영향
💬 리뷰 포인트
실패 종류를 서브클래스가 아니라 필드로 둔 것
이슈에는
LlmPermanentFailedException을 서브클래스로 두자고 적었는데, 그러면 안 된다. 재시도 대상은 예외 타입으로 판정한다(retryOn). 재시도를 금지할 실패를 서브클래스로 만들면 그 판정에 그대로 걸려 오히려 재시도된다.LlmRetryExecutorTest의재시도 대상 예외의 하위 타입도 재시도한다가 그 동작을 이미 못 박아 두고 있었다.설정할 수 있는 타임아웃 상한이 좁아진다
곱하는 항이 늘어난 만큼
gemini.request-timeout의 허용 상한이 60초에서 18.6초로 줄어든다. 지금은 15초라 통과하지만 여유가 3.6초뿐이다. 넘기면 부팅에서 죽는다(require). 나중에 더 큰 모델로 옮겨 타임아웃을 올려야 한다면 재시도 횟수나 체인 가정도 함께 봐야 한다.#163 전까지 열려 있는 구멍
429 가 이어지면 사용자는 49초를 기다린 뒤 실패를 본다(15s × 3 + 2s × 2). Gemini 가 아픈 날에만 나오는 경로지만, 그 상황을 즉시 실패로 바꾸는 것은 #163 서킷브레이커다. 그때까지는 이 구멍이 열려 있다.
이슈 본문의 최악 계산을 고쳤다
이슈에 적어둔 카드 93초는 지수 백오프(1.5초)만 센 값이라 틀렸다. 실제 최악은 429 고정 간격(4초) 쪽이라 98초다. 이슈 본문도 같이 정정했다.
✅ 검증
로컬에서 전체 테스트 828개 통과(Testcontainers 33개 클래스 포함), ktlint 통과. 커밋 5개는 각각 단독으로 빌드되는지도 따로 확인했다.
Summary by CodeRabbit
개선 사항
문서