Skip to content

[feat] LLM 재시도에 지수 백오프 도입 - #207

Open
kite707 wants to merge 5 commits into
devfrom
feat/162-llm-retry-backoff
Open

[feat] LLM 재시도에 지수 백오프 도입#207
kite707 wants to merge 5 commits into
devfrom
feat/162-llm-retry-backoff

Conversation

@kite707

@kite707 kite707 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

🔗 연관 이슈

📌 개요

LLM 호출 실패를 종류별로 갈라 재시도 정책을 적용한다. 지금은 모든 실패를 간격 없이 똑같이 두 번 시도한다. 상대가 과부하일 때 곧바로 다시 부르면 부하만 키우고, 400·401·403 처럼 다시 불러도 같은 답이 올 실패까지 재시도한다. 재시도가 아예 없던 카드 경로도 같이 붙인다.

관측된 장애 때문은 아니다. prod generation_log 169건이 전부 attempt_count = 1 · success = true재시도 경로가 프로덕션에서 한 번도 돈 적이 없다. 실사용자 유입 전에 보호 장치를 깔아두는 작업이다.

🔧 주요 변경사항

1. 실패를 네 종류로 구분한다

LlmFailureKind 를 예외에 실어 보낸다. SDK 예외 타입을 아는 곳은 GeminiFailureKinds 하나뿐이다. 재시도 정책과 서비스까지 Gemini 가 새어나가지 않는다.

실패 SDK 타입 재시도 대기
네트워크·IO·5xx GenAiIOException · ServerException O 지수 0.5s → 1s
429 쿼터 초과 ClientException code 429 O 고정 2s
400·401·403 ClientException X -
응답 검증 실패 우리 파서 O 없음

429 는 "상대가 아프다"가 아니라 **"우리가 너무 빨리 부른다"**는 뜻이라, 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 을 두 번 순차 호출한다. 기존 검증은 "한 요청 = 체인 하나"를 가정해 카드 경로만 실제의 절반으로 계산했다. 검증을 통과해도 안전하다는 보장이 없었다.

(15s × 3시도 + 4s 백오프) × 체인 2 = 98초  <  nginx proxy_read_timeout 120초

🌐 API · DB 영향

  • 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

  • 개선 사항

    • LLM 실패 유형에 따라 재시도 여부와 대기 시간이 달라집니다.
    • 일시적 오류는 최대 3회까지 재시도하며, 호출 유형에 따라 백오프가 적용됩니다.
    • 영구 오류는 불필요한 재시도를 중단합니다.
    • 카드·댓글 생성 실패 시 오류 유형과 누적 사용량이 더 정확히 기록됩니다.
    • Gemini 요청 타임아웃이 30초에서 15초로 단축되었습니다.
  • 문서

    • LLM 생성 재시도 횟수 및 최악의 처리 시간 관련 안내를 최신 정책에 맞게 수정했습니다.

재시도 정책이 "무엇 때문에 실패했는지"로 갈리려면 그 정보가 먼저 예외에
실려야 한다. 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)
영구적으로 실패하는 방이 태우는 양을 묶는 장치가 사실상 그 하루 한 번뿐이다.
@kite707 kite707 self-assigned this Aug 23, 2026
@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a2cf2ab7-36ac-4013-9f0c-b4d652bca553

📥 Commits

Reviewing files that changed from the base of the PR and between 5ffc1cd and 11da33d.

📒 Files selected for processing (25)
  • src/main/kotlin/com/nexters/gamss/card/service/CardService.kt
  • src/main/kotlin/com/nexters/gamss/conversation/controller/ConversationController.kt
  • src/main/kotlin/com/nexters/gamss/conversation/repository/ConversationRepository.kt
  • src/main/kotlin/com/nexters/gamss/conversation/service/CommentGenerationService.kt
  • src/main/kotlin/com/nexters/gamss/llm/config/GeminiProperties.kt
  • src/main/kotlin/com/nexters/gamss/llm/error/CardGenerationFailedException.kt
  • src/main/kotlin/com/nexters/gamss/llm/error/CommentGenerationFailedException.kt
  • src/main/kotlin/com/nexters/gamss/llm/error/LlmFailure.kt
  • src/main/kotlin/com/nexters/gamss/llm/error/LlmFailureKind.kt
  • src/main/kotlin/com/nexters/gamss/llm/generation/GeminiCardMessageGenerator.kt
  • src/main/kotlin/com/nexters/gamss/llm/generation/GeminiCommentGenerator.kt
  • src/main/kotlin/com/nexters/gamss/llm/generation/GeminiEmotionExtractor.kt
  • src/main/kotlin/com/nexters/gamss/llm/generation/GeminiFailureKinds.kt
  • src/main/kotlin/com/nexters/gamss/llm/generation/LlmFailureRetryPolicy.kt
  • src/main/kotlin/com/nexters/gamss/llm/generation/LlmRetryExecutor.kt
  • src/main/kotlin/com/nexters/gamss/llm/generation/LlmRetryPolicy.kt
  • src/main/resources/application.yml
  • src/test/kotlin/com/nexters/gamss/card/service/CardServiceTest.kt
  • src/test/kotlin/com/nexters/gamss/conversation/service/CommentGenerationServiceTest.kt
  • src/test/kotlin/com/nexters/gamss/llm/config/GeminiPropertiesTest.kt
  • src/test/kotlin/com/nexters/gamss/llm/generation/GeminiFailureKindsTest.kt
  • src/test/kotlin/com/nexters/gamss/llm/generation/LlmFailureRetryPolicyTest.kt
  • src/test/kotlin/com/nexters/gamss/llm/generation/LlmRetryExecutorTest.kt
  • src/test/kotlin/com/nexters/gamss/llm/settings/LlmSettingsServiceTest.kt
  • src/test/resources/application.yml
💤 Files with no reviewable changes (1)
  • src/main/kotlin/com/nexters/gamss/card/service/CardService.kt

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


Walkthrough

LLM 실패를 LlmFailureKind으로 분류하고, 실패 종류에 따라 재시도와 백오프를 적용합니다. 최대 시도 횟수를 3회로 변경하고, 카드 생성의 호출 체인 2회를 타임아웃 예산에 반영합니다. Gemini 요청 타임아웃은 15초로 변경합니다.

Changes

LLM 재시도 보호 흐름

Layer / File(s) Summary
실패 종류 계약과 Gemini 분류
src/main/kotlin/com/nexters/gamss/llm/error/*, src/main/kotlin/com/nexters/gamss/llm/generation/Gemini*Generator.kt, src/main/kotlin/com/nexters/gamss/llm/generation/GeminiEmotionExtractor.kt
LlmFailureLlmFailureKind를 추가했습니다. Gemini 예외를 CALL, RATE_LIMITED, PERMANENT, VALIDATION으로 분류합니다. 파싱 과정에서 기존 실패 종류를 유지합니다.
실패별 재시도와 백오프 실행
src/main/kotlin/com/nexters/gamss/llm/generation/LlmFailureRetryPolicy.kt, src/main/kotlin/com/nexters/gamss/llm/generation/LlmRetryExecutor.kt, src/main/kotlin/com/nexters/gamss/llm/generation/LlmRetryPolicy.kt, src/test/kotlin/com/nexters/gamss/llm/generation/*Test.kt
영구 오류는 재시도하지 않습니다. 429에는 2초 고정 대기를 적용하고, 호출 오류에는 지수 백오프를 적용합니다. 검증 오류는 즉시 재시도합니다. retryIf가 재시도 여부를 추가로 결정합니다. 최대 시도 횟수는 3회입니다.
타임아웃과 호출 체인 예산 검증
src/main/kotlin/com/nexters/gamss/llm/config/GeminiProperties.kt, src/main/resources/application.yml, src/test/kotlin/com/nexters/gamss/llm/config/GeminiPropertiesTest.kt, src/test/kotlin/com/nexters/gamss/llm/settings/LlmSettingsServiceTest.kt, src/test/resources/application.yml
요청 타임아웃을 15초로 변경했습니다. 최악 백오프와 요청당 최대 2개의 순차 LLM 호출을 예산 검증에 포함합니다.
생성 경로와 실패 기록 검증
src/main/kotlin/com/nexters/gamss/conversation/controller/ConversationController.kt, src/main/kotlin/com/nexters/gamss/conversation/repository/ConversationRepository.kt, src/main/kotlin/com/nexters/gamss/conversation/service/CommentGenerationService.kt, src/test/kotlin/com/nexters/gamss/card/service/CardServiceTest.kt, src/test/kotlin/com/nexters/gamss/conversation/service/CommentGenerationServiceTest.kt
카드·댓글·답글 생성 테스트를 3회 시도 기준으로 변경했습니다. 실패 로그에 시도별 토큰 합계를 검증합니다. API 문서와 재시도 제한 설명을 갱신했습니다.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 11da3

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: 생성 결과 또는 최종 실패
Loading

Suggested reviewers: theminjunchoi

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed 제목은 LLM 재시도와 지수 백오프 도입이라는 PR의 핵심 변경을 명확하게 요약합니다.
Description check ✅ Passed 연관 이슈, 개요, 주요 변경사항, API·DB 영향, 리뷰 포인트를 모두 포함해 템플릿을 충족합니다.
Linked Issues check ✅ Passed 실패 유형별 재시도·백오프, 타임아웃 단축, 카드 재시도, 호출 체인 예산 검증 등 이슈 #162의 목표를 충족합니다.
Out of Scope Changes check ✅ Passed 변경 사항은 이슈 #162의 재시도 정책, 타임아웃, 카드 경로 및 관련 테스트 범위에 일치합니다.
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/162-llm-retry-backoff

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

Test Results

828 tests  +19   828 ✅ +19   2m 50s ⏱️ -2s
109 suites + 2     0 💤 ± 0 
109 files   + 2     0 ❌ ± 0 

Results for commit 11da33d. ± Comparison against base commit 5ffc1cd.

@github-actions

Copy link
Copy Markdown

Test Coverage

Overall Project 82.24% -0.54% 🍏
Files changed 68.52% 🍏

File Coverage
LlmFailureKind.kt 100% 🍏
CardGenerationFailedException.kt 100% 🍏
CommentGenerationFailedException.kt 100% 🍏
LlmRetryPolicy.kt 100% 🍏
LlmFailureRetryPolicy.kt 100% 🍏
CardService.kt 98.25% 🍏
GeminiFailureKinds.kt 97.73% -2.27% 🍏
LlmRetryExecutor.kt 95.14% 🍏
GeminiProperties.kt 93.62% 🍏
CommentGenerationService.kt 91.05% 🍏
ConversationController.kt 82.92% 🍏
ConversationRepository.kt 61.71% 🍏
GeminiEmotionExtractor.kt 5.63% -5.35% 🍏
GeminiCardMessageGenerator.kt 5.22% -8.62% 🍏
GeminiCommentGenerator.kt 3.39% -9.72% 🍏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] LLM 재시도에 지수 백오프 도입

1 participant