Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions docs/feature_description/GEMINI_TIMEOUT_AND_RETRY_STRATEGY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# 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) 승수 |
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

문서의 재시도 설정 구조 표에서 exp_base라는 파라미터를 사용하고 있으나, 실제 적용된 Java SDK에서는 multiplier를 사용합니다. 문서와 코드의 일관성을 위해 multiplier로 수정하는 것이 좋겠습니다.

Suggested change
| `exp_base` | 2 | 지수 백오프(Exponential Backoff) 승수 |
| `multiplier` | 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. **안정성 우선 (Stability > Accounting)**: 정확한 토큰 카운팅보다 중요한 것은 사용자의 요청을 **성공시키는 것**입니다. 재시도를 통해 일시적 오류를 극복하는 것이 UX 관점에서 훨씬 중요합니다.
3. **429 에러 제외**: 유일하게 과금이나 Quota에 민감할 수 있는 `429 (Rate Limit)` 에러는 SDK 재시도에서 **제외**했습니다. 따라서 Rate Limit 관련 에러는 즉시 감지되고 애플리케이션 로직에 의해 정확히 처리됩니다.
4. **408 (Request Timeout) 제외**: 무료 티어(Free Tier) 사용 시 RPM/RPD(일일/분당 요청 수) 제한이 엄격합니다. 408 에러가 SDK 내부에서 재시도될 경우, 우리 시스템이 집계하지 못한 '숨겨진 요청'이 발생하여 Quota 관리의 정확도가 떨어질 수 있습니다. 따라서 **Accurate Accounting(정확한 집계) > UX/Stability** 원칙에 따라 408도 재시도에서 제외합니다.

**결론**: `5xx` (서버 오류)만 제한적으로 재시도하며, 클라이언트 측 이슈나 과금/Quota에 영향을 줄 수 있는 `429`, `408`은 즉시 실패 처리(Fail-Fast)하여 시스템의 예측 가능성을 확보했습니다.
3 changes: 3 additions & 0 deletions src/main/java/book/book/quiz/dto/external/GeminiRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,7 @@ public class GeminiRequest<T> {
private final float temperature;
private final Class<T> responseType;
private final String contextForLogging;

@Builder.Default
private final long timeout = 60000L; // 기본 타임아웃 60초
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public GeminiRequest<GeminiQuizResponses> createBatchQuizRequest(Book book, List
.temperature(0.7f)
.responseType(GeminiQuizResponses.class)
.contextForLogging("[퀴즈생성] 책 이름: " + book.getTitle() + ", 책 ID: " + book.getId())
.timeout(120000L) // 120초 타임아웃
.build();
}
}
27 changes: 27 additions & 0 deletions src/main/java/book/book/quiz/external/gemini/GeminiSdkClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -51,6 +54,18 @@ public <T> T generateContent(GeminiRequest<T> 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 {
Expand Down Expand Up @@ -89,6 +104,18 @@ public <T> CompletableFuture<T> generateContentAsync(GeminiRequest<T> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public class GeminiClientManager {
private final List<Client> clients;
private final List<String> accountNames;
private final AtomicInteger currentIndex = new AtomicInteger(0);

// 계정별 쿨다운 관리 (Key: AccountName, Value: ExhaustedTimeMillis)
private final Map<String, Long> accountExhaustedTimes = new ConcurrentHashMap<>();
private static final long EXHAUSTION_COOLDOWN_MS = 60000; // 1분간 재시도 금지 (개별 키)
Expand Down
Loading