Skip to content
Merged
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ UCTale은 이야기의 중요한 상태와 판정을 서버에서 관리하고,
| Backend | Java 21, Spring Boot 4.1, Spring Web MVC, Spring Data JPA |
| Database | PostgreSQL, Flyway |
| Test Database | H2 + Testcontainers PostgreSQL 17.6 |
| Narrative AI | Google Gemini 3.7 Flash |
| Narrative AI | Google Gemini 3.8 Flash |
| Image AI | Pollinations |
| Deployment | Vercel, Render |

Expand All @@ -93,7 +93,8 @@ UCTale은 이야기의 중요한 상태와 판정을 서버에서 관리하고,

```env
GOOGLE_AI_API_KEY=...
GOOGLE_AI_MODEL=gemini-3.7-flash
GOOGLE_AI_MODEL=gemini-3.8-flash
GOOGLE_AI_FALLBACK_MODEL=gemini-3.5-flash-lite
POLLINATIONS_TOKEN=...
GAME_ACCESS_PASSWORD=...
GAME_ACCESS_SESSION_SECRET=32자 이상의 충분히 긴 임의 문자열
Expand Down
18 changes: 11 additions & 7 deletions docs/architecture/gemini-narrative-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@

UCTale의 Narrative provider 모델 버전을 game domain이나 `NarrativeGenerator` port에 노출하지 않고 provider adapter 설정으로 관리합니다.

현재 production primary 기본 모델은 `gemini-3.7-flash`입니다. Google 공식 문서에서 stable Flash로 제공되며 structured outputs와 `low` / `medium` / `high` thinking level을 지원합니다. 일시적인 provider 408/429/5xx 또는 network failure가 발생하면 bounded recovery의 다음 실제 provider attempt에서 stable `gemini-3.6-flash` fallback을 사용할 수 있습니다.
현재 production primary 기본 모델은 `gemini-3.8-flash`입니다. Google 공식 문서에서 stable Flash로 제공되며 structured outputs와 `low` / `medium` / `high` thinking level을 지원합니다. 일시적인 provider 408/429/5xx 또는 network failure가 발생하면 bounded recovery의 다음 실제 provider attempt에서 stable `gemini-3.5-flash-lite` fallback을 사용할 수 있습니다.

모델 선택 근거와 당시 가격은 구현 PR에 기록합니다. 이 문서는 현재 runtime 계약을 설명하며 별도 자체 benchmark 결과를 주장하지 않습니다.

## 설정

`application.properties`의 기본값은 다음 환경변수로 override할 수 있습니다.

- `GOOGLE_AI_MODEL`: primary stable Flash model ID. 기본 `gemini-3.7-flash`
- `GOOGLE_AI_FALLBACK_MODEL`: transient provider failure용 stable Flash model ID. 기본 `gemini-3.6-flash`
- `GOOGLE_AI_MODEL`: primary stable Flash model ID. 기본 `gemini-3.8-flash`
- `GOOGLE_AI_FALLBACK_MODEL`: transient provider failure용 stable Flash model ID. 기본 `gemini-3.5-flash-lite`
- `GOOGLE_AI_THINKING_OPENING`: opening thinking level. 기본 `medium`
- `GOOGLE_AI_THINKING_PROGRESS`: progress thinking level. 기본 `low`
- `GOOGLE_AI_API_KEY`: Gemini API key
Expand All @@ -22,7 +22,7 @@ UCTale의 Narrative provider 모델 버전을 game domain이나 `NarrativeGenera
- `GAME_TURN_RESERVATION_LEASE_SECONDS`: progress reservation lease. 기본 180초
- `GAME_TURN_RECOVERY_COOLDOWN_SECONDS`: provider recovery cooldown. 기본 30초

`latest`, preview, experimental alias는 production 설정으로 허용하지 않습니다. primary와 fallback 모두 `gemini-{major}.{minor}-flash` 형태의 명시적인 stable Flash ID만 허용합니다.
`latest`, preview, experimental alias는 production 설정으로 허용하지 않습니다. primary와 fallback 모두 `gemini-{major}.{minor}-flash` 또는 `gemini-{major}.{minor}-flash-lite` 형식의 명시적인 안정 모델 ID만 허용합니다.

## compatibility boundary

Expand Down Expand Up @@ -61,7 +61,7 @@ Gemini request는 다음을 유지합니다.
- HTTP 408
- HTTP 429
- HTTP 5xx
- connect/read I/O 등 `ResourceAccessException`
- connect/read I/O 등 `ResourceAccessException`과 `RestClientException`에 감싸진 `SocketTimeoutException`

이 retry는 adapter 내부에서 숨겨진 추가 호출을 만들지 않습니다. 첫 호출과 각 retry는 각각 하나의 실제 provider attempt이며 `NarrativeRecoveryExecutor`의 retry count와 일치합니다. progress에서는 기존 `markProviderAttemptStarted` callback이 각 실제 provider attempt 전에 한 번씩 실행됩니다. 따라서 provider 호출이 재시도되어도 canonical game state는 narrative 성공 후 기존 commit 경계에서 한 번만 저장됩니다.

Expand Down Expand Up @@ -103,6 +103,10 @@ progress의 transient retry가 모두 소진되면 API는 영구 차단 대신 `

bounded recovery와 canonical commit 경계는 기존 정책을 그대로 유지합니다. 모델 설정이나 Narrative 출력 언어 계약은 canonical game state 저장 의미를 변경하지 않습니다.

## 장애 및 배포 확인

2026-10-27 운영 로그에서 primary/fallback Gemini HTTP 503과 40초 read timeout을 확인했습니다. 새 버전의 지원 여부를 확인한 뒤 배포해야 하며, 환경변수 override를 적용할 경우 애플리케이션 모델 검증을 통과하는지 점검합니다. 이 문서의 모델 기본값은 코드 배포에 따라 적용되며 운영 환경변수로 별도 override될 수 있습니다. provider가 반복적으로 503을 반환할 경우 모델 교체만으로 성공을 보장하지 않습니다.

## rollback

production에서 문제가 발생하면 코드 변경 없이 `GOOGLE_AI_MODEL`, `GOOGLE_AI_FALLBACK_MODEL`과 필요한 thinking 설정을 검증된 stable Flash로 변경해 rollback할 수 있습니다.
Expand All @@ -120,7 +124,7 @@ rollback 후에는 최소한 다음을 확인합니다.
## 공식 참고

- Google Gemini models: https://ai.google.dev/gemini-api/docs/models
- Gemini 3.7 Flash: https://ai.google.dev/gemini-api/docs/models/gemini-3.7-flash
- Gemini 3.6 Flash: https://ai.google.dev/gemini-api/docs/models/gemini-3.6-flash
- Gemini 3.8 Flash: https://ai.google.dev/gemini-api/docs/models/gemini-3.8-flash
- Gemini 3.5 Flash-Lite: https://ai.google.dev/gemini-api/docs/models/gemini-3.5-flash-lite
- Thinking: https://ai.google.dev/gemini-api/docs/generate-content/thinking
- Pricing: https://ai.google.dev/gemini-api/docs/pricing
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestClientResponseException;

import java.net.SocketTimeoutException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
Expand Down Expand Up @@ -303,6 +305,15 @@ private NarrativeTurn generate(
logProviderResult(modelId, errorContext, thinkingLevel, tokenUsage, startedAt, "PROVIDER_RETRYABLE_FAILURE");
log.warn("gemini_provider_network_failure model={} context={} error={}", modelId, errorContext, exception.getClass().getSimpleName());
throw recoverable("PROVIDER_NETWORK", "Gemini provider 네트워크 호출이 일시적으로 실패했습니다.", exception);
} catch (RestClientException exception) {
if (hasReadTimeoutCause(exception)) {
logProviderResult(modelId, errorContext, thinkingLevel, tokenUsage, startedAt, "PROVIDER_RETRYABLE_FAILURE");
log.warn("gemini_provider_read_timeout model={} context={}", modelId, errorContext);
throw recoverable("PROVIDER_NETWORK", "Gemini provider 응답 대기 시간이 초과되었습니다.", exception);
}
logProviderResult(modelId, errorContext, thinkingLevel, tokenUsage, startedAt, "PROVIDER_FAILURE");
log.error("Gemini provider 처리 실패 model={} context={} error={}", modelId, errorContext, exception.getClass().getSimpleName());
throw new NarrativeProviderException("Gemini provider 응답 처리에 실패했습니다.", exception);
} catch (JacksonException exception) {
logProviderResult(modelId, errorContext, thinkingLevel, tokenUsage, startedAt, "REQUEST_SERIALIZATION_FAILURE");
throw new IllegalStateException("Gemini 요청 직렬화에 실패했습니다.", exception);
Expand All @@ -313,6 +324,15 @@ private NarrativeTurn generate(
}
}

private boolean hasReadTimeoutCause(Throwable exception) {
for (Throwable cause = exception; cause != null; cause = cause.getCause()) {
if (cause instanceof SocketTimeoutException) {
return true;
}
}
return false;
}

String createRequestBody(
String userPrompt,
GeminiProviderSettings.ThinkingLevel thinkingLevel
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
@Component
public final class GeminiProviderSettings {

private static final Pattern STABLE_FLASH_MODEL = Pattern.compile("^gemini-(\\d+)\\.(\\d+)-flash$");
private static final Pattern STABLE_FLASH_MODEL = Pattern.compile("^gemini-(\\d+)\\.(\\d+)-flash(?:-lite)?$");
private static final String GENERATE_CONTENT_URL =
"https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent";

Expand All @@ -28,7 +28,7 @@ public final class GeminiProviderSettings {
public GeminiProviderSettings(
@Value("${google.ai.api-key}") String apiKey,
@Value("${google.ai.model}") String modelId,
@Value("${google.ai.fallback-model:gemini-3.6-flash}") String fallbackModelId,
@Value("${google.ai.fallback-model:gemini-3.5-flash-lite}") String fallbackModelId,
@Value("${google.ai.thinking.opening}") String openingThinkingLevel,
@Value("${google.ai.thinking.progress}") String progressThinkingLevel
) {
Expand All @@ -47,7 +47,7 @@ public GeminiProviderSettings(
String openingThinkingLevel,
String progressThinkingLevel
) {
this(apiKey, modelId, "gemini-3.6-flash", openingThinkingLevel, progressThinkingLevel);
this(apiKey, modelId, "gemini-3.5-flash-lite", openingThinkingLevel, progressThinkingLevel);
}

String apiKey() {
Expand Down
4 changes: 2 additions & 2 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
spring.application.name=uctale

google.ai.api-key=${GOOGLE_AI_API_KEY}
google.ai.model=${GOOGLE_AI_MODEL:gemini-3.7-flash}
google.ai.fallback-model=${GOOGLE_AI_FALLBACK_MODEL:gemini-3.6-flash}
google.ai.model=${GOOGLE_AI_MODEL:gemini-3.8-flash}
google.ai.fallback-model=${GOOGLE_AI_FALLBACK_MODEL:gemini-3.5-flash-lite}
google.ai.thinking.opening=${GOOGLE_AI_THINKING_OPENING:medium}
google.ai.thinking.progress=${GOOGLE_AI_THINKING_PROGRESS:low}
google.ai.connect-timeout-ms=${GOOGLE_AI_CONNECT_TIMEOUT_MS:10000}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.net.SocketTimeoutException;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
Expand All @@ -25,7 +27,7 @@

class GeminiNarrativeAdapterTest {

private static final String TEST_MODEL = "gemini-3.7-flash";
private static final String TEST_MODEL = "gemini-3.8-flash";

private GeminiNarrativeAdapter adapter;
private GeminiProviderSettings settings;
Expand Down Expand Up @@ -165,6 +167,29 @@ void repairOpening_UsesSafeReasonCodeAndOpeningThinkingLevel() {
mockServer.verify();
}

@Test
@DisplayName("RestClientException 안의 읽기 타임아웃을 recoverable 네트워크 실패로 분류한다")
void openingWrappedReadTimeoutIsRecoverable() {
mockServer.expect(requestTo(settings.generateContentUrl()))
.andRespond(request -> { throw new RestClientException("response failure", new SocketTimeoutException("Read timed out")); });

assertThatThrownBy(() -> adapter.createOpening("세계", "캐릭터"))
.isInstanceOfSatisfying(RecoverableNarrativeResponseException.class, exception ->
assertThat(exception.reasonCode()).isEqualTo("PROVIDER_NETWORK"));
mockServer.verify();
}

@Test
@DisplayName("일반 RestClientException은 retryable로 처리하지 않는다")
void openingUnrelatedRestClientExceptionIsNotRetryable() {
mockServer.expect(requestTo(settings.generateContentUrl()))
.andRespond(request -> { throw new RestClientException("conversion failure"); });

assertThatThrownBy(() -> adapter.createOpening("세계", "캐릭터"))
.isInstanceOf(NarrativeProviderException.class);
mockServer.verify();
}

private String validNarrativeJson() {
return "{\"title\":\"첫날 밤\",\"story_text\":\"좀비가 나타났다!\",\"choices\":[{\"id\":1,\"text\":\"도망간다\"}],\"visual_assets\":{\"background\":\"dark subway\",\"characters\":[],\"assets\":[]}}";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ class GeminiProviderSettingsTest {
@DisplayName("Gemini 3 stable Flash는 model URL과 thinkingLevel 계약을 설정에서 만든다")
void gemini3Stable_UsesThinkingLevel() {
GeminiProviderSettings settings = new GeminiProviderSettings(
"key", "gemini-3.7-flash", "medium", "low"
"key", "gemini-3.8-flash", "medium", "low"
);

assertThat(settings.modelId()).isEqualTo("gemini-3.7-flash");
assertThat(settings.generateContentUrl()).endsWith("/models/gemini-3.7-flash:generateContent");
assertThat(settings.modelId()).isEqualTo("gemini-3.8-flash");
assertThat(settings.generateContentUrl()).endsWith("/models/gemini-3.8-flash:generateContent");
assertThat(settings.thinkingConfig(settings.openingThinkingLevel()))
.containsEntry("thinkingLevel", "medium")
.doesNotContainKey("thinkingBudget");
Expand All @@ -28,14 +28,14 @@ void gemini3Stable_UsesThinkingLevel() {
@DisplayName("provider transient retry는 설정된 stable fallback 모델을 선택한다")
void providerTransientRetry_UsesStableFallbackModel() {
GeminiProviderSettings settings = new GeminiProviderSettings(
"key", "gemini-3.7-flash", "gemini-3.6-flash", "medium", "low"
"key", "gemini-3.8-flash", "gemini-3.5-flash-lite", "medium", "low"
);

assertThat(settings.retryModelId("PROVIDER_HTTP_503")).isEqualTo("gemini-3.6-flash");
assertThat(settings.retryModelId("PROVIDER_RATE_LIMIT")).isEqualTo("gemini-3.6-flash");
assertThat(settings.retryModelId("MALFORMED_JSON")).isEqualTo("gemini-3.7-flash");
assertThat(settings.retryModelId("PROVIDER_HTTP_503")).isEqualTo("gemini-3.5-flash-lite");
assertThat(settings.retryModelId("PROVIDER_RATE_LIMIT")).isEqualTo("gemini-3.5-flash-lite");
assertThat(settings.retryModelId("MALFORMED_JSON")).isEqualTo("gemini-3.8-flash");
assertThat(settings.generateContentUrl(settings.fallbackModelId()))
.endsWith("/models/gemini-3.6-flash:generateContent");
.endsWith("/models/gemini-3.5-flash-lite:generateContent");
}

@Test
Expand Down Expand Up @@ -66,15 +66,15 @@ void unstableAliases_AreRejected() {
assertThatThrownBy(() -> new GeminiProviderSettings("key", "gemini-flash-latest", "medium", "low"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("stable Flash model ID");
assertThatThrownBy(() -> new GeminiProviderSettings("key", "gemini-3.7-flash-preview", "medium", "low"))
assertThatThrownBy(() -> new GeminiProviderSettings("key", "gemini-3.8-flash-preview", "medium", "low"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("stable Flash model ID");
}

@Test
@DisplayName("지원하지 않는 thinking level은 시작 시 fail-fast 한다")
void unsupportedThinkingLevel_IsRejected() {
assertThatThrownBy(() -> new GeminiProviderSettings("key", "gemini-3.7-flash", "minimal", "low"))
assertThatThrownBy(() -> new GeminiProviderSettings("key", "gemini-3.8-flash", "minimal", "low"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("allowed: low, medium, high");
}
Expand Down
Loading