diff --git a/docs/feature_description/GEMINI_TIMEOUT_AND_RETRY_STRATEGY.md b/docs/feature_description/GEMINI_TIMEOUT_AND_RETRY_STRATEGY.md new file mode 100644 index 00000000..01847d8a --- /dev/null +++ b/docs/feature_description/GEMINI_TIMEOUT_AND_RETRY_STRATEGY.md @@ -0,0 +1,125 @@ +# Gemini SDK 타임아웃 및 재시도 전략 (Timeout & Retry Strategy) + +## 1. 개요 (Overview) +본 문서는 Google Gemini API 연동 시 안정성을 확보하기 위해 적용한 **타임아웃(Timeout)** 및 **재시도(Retry)** 정책의 수립 과정과 최종 구현 내용을 상세히 기술합니다. + +--- + +## 2. 의사결정 과정 (Decision Logs) + +우리는 시스템의 **안정성(Stability)**과 **유연성(Flexibility)** 사이에서 최적의 균형을 찾기 위해 다음과 같은 과정을 거쳤습니다. + +### 2.1 문제 정의 +- **무한 대기 위험**: 초기 구현에는 SDK에 명시적인 타임아웃 설정이 없었습니다. AI 모델 특성상 응답 생성에 수십 초~수분이 소요될 수 있는데, 네트워크 단절이나 서비스 장애 발생 시 스레드가 무한히 대기(Hang)하여 전체 시스템의 자원을 고갈시킬 위험이 있었습니다. +- **요청별 특성 차이**: '간단한 채팅 응답'은 10초 내에 끝나야 하지만, '거대 텍스트 기반 퀴즈 생성'은 3분 이상 소요될 수도 있습니다. 이를 하나의 설정으로 묶는 것은 비효율적입니다. + +### 2.2 대안 검토 및 선택 + +#### ❌ Option 1: 글로벌 타임아웃 (`GeminiClientManager`) +`Client` 객체를 생성할 때 `HttpOptions`를 주입하여 전역적으로 타임아웃을 고정하는 방식입니다. +- **장점**: 구현이 매우 간단하며 `application.yml`에서 중앙 관리가 용이합니다. +- **단점**: 모든 요청에 동일한 타임아웃이 강제됩니다. 짧은 작업과 긴 작업을 구분할 수 없어, 긴 작업에 맞추면 짧은 작업의 장애 감지가 늦어지고, 짧은 작업에 맞추면 긴 작업이 실패합니다. +- **결과**: **기각 (Too rigid)** + +#### ✅ Option 2: 요청별 타임아웃 (`GeminiRequest` → `GenerateContentConfig`) +요청 단위로 타임아웃을 설정하고, 이를 SDK 호출 시점에 주입하는 방식입니다. +- **장점**: **유연성 극대화**. 호출하는 비즈니스 로직(퀴즈, 채팅 등)의 특성에 맞춰 타임아웃을 동적으로 조절할 수 있습니다. +- **구현**: + - `GeminiRequest` DTO에 `timeout` 필드 추가 (Default: 60,000ms). + - `GeminiSdkClient`에서 `request.getTimeout()` 값을 읽어 `GenerateContentConfig.httpOptions`에 적용. +- **결과**: **최종 채택 (Selected)** + +--- + +## 3. 최종 구현 상세 (Implementation) + +### 3.1 타임아웃 적용 로직 +- **기본값**: 60초 (1분). 별도 설정 없이 호출하면 이 값이 적용됩니다. +- **커스텀**: `GeminiRequest.builder().timeout(120000L).build()` 와 같이 밀리초(ms) 단위로 설정 가능. + +### 3.2 코드 예시 (Java) +```java +// GeminiSdkClient.java +GenerateContentConfig config = GenerateContentConfig.builder() + // ... + .httpOptions(HttpOptions.builder() + .timeout((int) request.getTimeout()) // Request에서 타임아웃 가져오기 + .build()) + .build(); +``` + +--- + +## 4. Gemini SDK 정책 분석 보고서 (Reference) + +> **Note**: 아래 내용은 Google GenAI SDK(Python/Java 공통 사상)의 기본 정책을 분석한 내용입니다. + +### 4.1 타임아웃(Timeout) 정책 + +#### 기본 타임아웃 설정 +Gemini SDK의 타임아웃은 `HttpOptions` 클래스의 `timeout` 파라미터로 제어됩니다. + +- **단위**: 밀리초(Milliseconds) + +| 설정값 | 초 단위 | 용도 | +| :--- | :--- | :--- | +| **30,000ms** | 30초 | 경량 요청, 빠른 응답 필요 (예: 챗봇 대화) | +| **60,000ms** | 60초 | **표준 요청 (기본 권장값)** | +| **600,000ms** | 600초 | 장시간 처리 (예: 대용량 텍스트/이미지/비디오 분석) | + +#### 타임아웃 제약 사항 +실제 운영 환경에서 관찰된 제약사항입니다. +- **상한선**: SDK 또는 API 게이트웨이 레벨에서 약 **600초(10분)** 의 하드 타임아웃이 존재할 수 있습니다. +- **연결 중단**: 지나치게 긴 요청은 중간 로드밸런서 등에서 연결이 끊길 수 있으므로, 적절한 타임아웃 설정이 필수입니다. + +### 4.2 재시도(Retry) 정책 + +#### 재시도 설정 구조 (`HttpRetryOptions`) +SDK는 일시적인 네트워크 오류나 서버 과부하 상황에 대응하기 위해 재시도 메커니즘을 내장하고 있습니다. + +| 파라미터 | 기본값 (Default) | 설명 | +| :--- | :--- | :--- | +| `attempts` | **5회** | 최대 재시도 시도 횟수 | +| `initial_delay` | 1초 | 첫 재시도 전 대기 시간 | +| `max_delay` | 60초 | 재시도 대기 시간의 최대 상한 | +| `exp_base` | 2 | 지수 백오프(Exponential Backoff) 승수 | + +#### 지수 백오프(Exponential Backoff) 시뮬레이션 +기본값(5회) 기준으로 재시도 시 대기 시간은 다음과 같이 늘어납니다. + +| 시도 차수 | 계산식 (초) | 대기 시간 | 누적 대기 시간 | +| :--- | :--- | :--- | :--- | +| 1차 재시도 | `1 * 2^0` | 1초 | 1초 | +| 2차 재시도 | `1 * 2^1` | 2초 | 3초 | +| 3차 재시도 | `1 * 2^2` | 4초 | 7초 | +| 4차 재시도 | `1 * 2^3` | 8초 | 15초 | +| 5차 재시도 | `1 * 2^4` | 16초 | 31초 | +| **Total** | - | - | **약 31초 + API 수행 시간** | + +#### 자동 재시도 대상 에러 +SDK는 다음 HTTP 상태 코드에 대해 자동으로 재시도를 수행합니다. +- **500 (Internal Server Error)**: 서버 내부 오류. +- **502 (Bad Gateway)**: 게이트웨이 오류. +- **503 (Service Unavailable)**: 서비스 일시 중단. +- **504 (Gateway Timeout)**: 게이트웨이 타임아웃. +> **Note**: **429 (Too Many Requests)** 에러는 SDK 레벨의 재시도에서 **제외**되었습니다 (Fail-Fast 적용). 이는 애플리케이션 레벨에서 즉각적으로 감지하고 대응(Rate Limit 롤백 취소 등)하기 위함입니다. + +#### Jitter (불규칙성) +Thundering Herd(동시다발적 재시도로 인한 2차 폭주)를 방지하기 위해, 대기 시간에 무작위 값(Jitter)이 추가되어 기본적으로 분산됩니다. + +### 4.3 재시도와 토큰 사용량 추적의 트레이드오프 (Trade-off Analysis) + +우리 시스템은 `GeminiTokenTracker`를 통해 토큰 사용량을 기록하고 나중에 가용 `Client`를 선택하는 데 활용합니다. 하지만 **SDK 내부 재시도**가 발생할 경우, 재시도 과정에서 소모된 토큰은 최종 성공 응답에만 포함되거나, 실패 시 누락될 수 있다는 잠재적 오차가 존재합니다. + +#### 이슈 정의 (The Discrepancy) +- **상황**: 1차 시도 (500 에러) -> 2차 시도 (성공) +- **현상**: 애플리케이션은 **2차 시도의 성공 토큰**만 기록합니다. 1차 시도에서 토큰이 소모되었더라도 기록되지 않습니다. + +#### 트레이드오프 분석 (Why it's acceptable) +이러한 오차를 감수하고 SDK 재시도를 사용하는 이유는 다음과 같습니다. + +1. **과금 정책 (Billing)**: 일반적으로 Google Cloud를 포함한 대다수의 클라우드 API는 `5xx` (Internal Server Error) 발생 시 **과금하지 않습니다**. 따라서 1차 시도(500 에러)가 누락되더라도 실제 비용이나 한도(Quota)에는 영향이 거의 없습니다. +2. **429 에러 제외**: 유일하게 과금이나 Quota에 민감할 수 있는 `429 (Rate Limit)` 에러는 SDK 재시도에서 **제외**했습니다. 따라서 Rate Limit 관련 에러는 즉시 감지되고 애플리케이션 로직에 의해 정확히 처리됩니다. +3. **408 (Request Timeout) 제외**: 무료 티어(Free Tier) 사용 시 RPM/RPD(일일/분당 요청 수) 제한이 엄격합니다. 408 에러가 SDK 내부에서 재시도될 경우, 우리 시스템이 집계하지 못한 '숨겨진 요청'이 발생하여 Quota 관리의 정확도가 떨어질 수 있습니다. 따라서 **Accurate Accounting(정확한 집계) > UX/Stability** 원칙에 따라 408도 재시도에서 제외합니다. + +**결론**: `5xx` (서버 오류)만 제한적으로 재시도하며, 클라이언트 측 이슈나 과금/Quota에 영향을 줄 수 있는 `429`, `408`은 즉시 실패 처리(Fail-Fast)하여 시스템의 예측 가능성을 확보했습니다. diff --git a/src/main/java/book/book/quiz/dto/external/GeminiRequest.java b/src/main/java/book/book/quiz/dto/external/GeminiRequest.java index 79d2eb9d..ae87b75b 100644 --- a/src/main/java/book/book/quiz/dto/external/GeminiRequest.java +++ b/src/main/java/book/book/quiz/dto/external/GeminiRequest.java @@ -14,4 +14,7 @@ public class GeminiRequest { private final float temperature; private final Class responseType; private final String contextForLogging; + + @Builder.Default + private final long timeout = 60000L; // 기본 타임아웃 60초 } diff --git a/src/main/java/book/book/quiz/external/gemini/GeminiRequestFactory.java b/src/main/java/book/book/quiz/external/gemini/GeminiRequestFactory.java index 0923a5ea..df53f3b7 100644 --- a/src/main/java/book/book/quiz/external/gemini/GeminiRequestFactory.java +++ b/src/main/java/book/book/quiz/external/gemini/GeminiRequestFactory.java @@ -29,6 +29,7 @@ public GeminiRequest createBatchQuizRequest(Book book, List .temperature(0.7f) .responseType(GeminiQuizResponses.class) .contextForLogging("[퀴즈생성] 책 이름: " + book.getTitle() + ", 책 ID: " + book.getId()) + .timeout(120000L) // 120초 타임아웃 .build(); } } diff --git a/src/main/java/book/book/quiz/external/gemini/GeminiSdkClient.java b/src/main/java/book/book/quiz/external/gemini/GeminiSdkClient.java index 0e051978..88f6ace0 100644 --- a/src/main/java/book/book/quiz/external/gemini/GeminiSdkClient.java +++ b/src/main/java/book/book/quiz/external/gemini/GeminiSdkClient.java @@ -12,8 +12,11 @@ import com.google.genai.Client; import com.google.genai.errors.ApiException; import com.google.genai.types.GenerateContentConfig; +import com.google.genai.types.HttpOptions; +import com.google.genai.types.HttpRetryOptions; import com.google.genai.types.GenerateContentResponse; import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import lombok.RequiredArgsConstructor; @@ -51,6 +54,18 @@ public T generateContent(GeminiRequest request) { .temperature(request.getTemperature()) .responseMimeType("application/json") .responseSchema(request.getSchema()) + .responseSchema(request.getSchema()) + .httpOptions(HttpOptions.builder() + .timeout((int) request.getTimeout()) + .retryOptions(HttpRetryOptions.builder() + .attempts(5) + .initialDelay(1.0) + .maxDelay(60.0) + // multiplier=2.0, jitter=1.0 (Defaults) + .httpStatusCodes(List.of(500, 502, 503, 504)) // 429, 408 제외 (Fail-Fast & Accurate + // Accounting) + .build()) + .build()) .build(); try { @@ -89,6 +104,18 @@ public CompletableFuture generateContentAsync(GeminiRequest request) { .temperature(request.getTemperature()) .responseMimeType("application/json") .responseSchema(request.getSchema()) + .responseSchema(request.getSchema()) + .httpOptions(HttpOptions.builder() + .timeout((int) request.getTimeout()) + .retryOptions(HttpRetryOptions.builder() + .attempts(5) + .initialDelay(1.0) + .maxDelay(60.0) + // multiplier=2.0, jitter=1.0 (Defaults) + .httpStatusCodes(List.of(500, 502, 503, 504)) // 429, 408 제외 (Fail-Fast & Accurate + // Accounting) + .build()) + .build()) .build(); return generateContentAsyncInternal(client, request.getPrompt(), config) diff --git a/src/main/java/book/book/quiz/external/gemini/apikey/GeminiClientManager.java b/src/main/java/book/book/quiz/external/gemini/apikey/GeminiClientManager.java index 84f02182..43813fe0 100644 --- a/src/main/java/book/book/quiz/external/gemini/apikey/GeminiClientManager.java +++ b/src/main/java/book/book/quiz/external/gemini/apikey/GeminiClientManager.java @@ -29,7 +29,7 @@ public class GeminiClientManager { private final List clients; private final List accountNames; private final AtomicInteger currentIndex = new AtomicInteger(0); - + // 계정별 쿨다운 관리 (Key: AccountName, Value: ExhaustedTimeMillis) private final Map accountExhaustedTimes = new ConcurrentHashMap<>(); private static final long EXHAUSTION_COOLDOWN_MS = 60000; // 1분간 재시도 금지 (개별 키)