取り組みの背景:なぜ Spring Boot × Gemini API が企業に選ばれるのか
Java / Spring Boot は多くの日本企業のシステム開発における事実上の標準となっています。これに Google の最新 AI モデルである Gemini API を組み合わせることで、既存のエンタープライズシステムに高度な AI 機能を組み込むことができます。
無料の入門記事「Spring Boot で Gemini API を使う基本ガイド」では基礎的な統合方法を解説しました。ここで扱うのはその先——本番環境で実際に稼働させるための設計パターン、セキュリティ、可観測性、テスト戦略まで、エンタープライズグレードの実装を体系的に解説します。
この記事で扱う主なトピック:
- Spring AI フレームワークの本番活用パターン
- マルチテナント設計(テナントごとの API キー管理)
- 会話メモリの永続化と管理
- 非同期・並列処理による高スループット設計
- セキュリティ実装(APIキー管理・レート制限・入力検証)
- Micrometer / OpenTelemetry を使った可観測性
- 本番対応テスト戦略(ユニット・統合・コントラクト)
対象読者: Spring Boot の基礎知識があり、Gemini API を本番環境に導入したいバックエンドエンジニア・アーキテクト。
Spring AI フレームワーク:Gemini 統合の最適解
Spring AI とは何か
Spring AI は Spring エコシステムに AI 機能を組み込むための公式フレームワークです。2024年末に GA(Generally Available)となり、Gemini を含む主要 AI プロバイダーへの統合が大幅に強化されましました。
Spring AI を使うと:
- プロバイダー非依存の統一 API で AI 機能を実装できる
- Spring Boot の自動構成(Auto-configuration)が利用できる
- Spring の DI・AOP・トランザクション管理が AI コンポーネントに適用できる
<!-- pom.xml: Spring AI BOM を使った依存関係管理 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring AI Vertex AI Gemini スターター -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-vertex-ai-gemini-spring-boot-starter</artifactId>
</dependency>
<!-- 会話メモリ(Redis 永続化) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-redis-store-spring-boot-starter</artifactId>
</dependency>
<!-- ベクターストア(RAG 構築用) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
</dependency>
</dependencies>
ChatClient の本番設定
// GeminiConfig.java: 本番向け ChatClient 設定
@Configuration
@EnableConfigurationProperties(GeminiProperties.class)
public class GeminiConfig {
@Bean
@Primary
public ChatClient chatClient(
VertexAiGeminiChatModel chatModel,
GeminiProperties properties) {
return ChatClient.builder(chatModel)
// デフォルトシステムプロンプト(全リクエスト共通)
.defaultSystem("""
あなたは {company} のカスタマーサポート AI です。
丁寧で正確な日本語で回答してください。
個人情報や機密情報を含む回答は絶対に行わないでください。
不明な場合は「担当者にお繋ぎします」と回答してください。
""")
// デフォルトアドバイザー設定
.defaultAdvisors(
new MessageChatMemoryAdvisor(chatMemory()),
new SafeGuardAdvisor(properties.getBlockedTerms()),
new RequestResponseLoggingAdvisor()
)
// デフォルト ChatOptions
.defaultOptions(VertexAiGeminiChatOptions.builder()
.withModel("gemini-2.5-pro")
.withTemperature(0.2f) // 本番では低め
.withMaxOutputTokens(2048)
.withTopP(0.8f)
.build())
.build();
}
@Bean
public ChatMemory chatMemory(RedisTemplate<String, Object> redisTemplate) {
// Redis を使った永続的な会話メモリ
return new RedisChatMemory(redisTemplate, Duration.ofHours(24));
}
}
# application-production.yml
spring:
ai:
vertex:
ai:
gemini:
project-id: ${GCP_PROJECT_ID}
location: us-central1
# Vertex AI 経由で本番利用(API Key ではなく SA 認証)
transport: grpc # gRPC は HTTP/2 より高スループット
# Redis 会話メモリ設定
data:
redis:
host: ${REDIS_HOST}
port: 6379
password: ${REDIS_PASSWORD}
ssl:
enabled: true
gemini:
# カスタムプロパティ
blocked-terms:
- "個人情報"
- "パスワード"
rate-limit:
requests-per-minute: 60
tokens-per-minute: 100000
マルチテナント設計:テナントごとの Gemini 設定管理
企業向けシステムでは、複数のテナント(顧客・部門)が同一システムを利用するマルチテナント構成が必要です。Gemini API においては、テナントごとに異なる設定・モデル・プロンプトを管理する必要があります。
テナント Context の実装
// TenantContext.java: スレッドローカルでテナント情報を管理
@Component
public class TenantContext {
private static final ThreadLocal<TenantInfo> currentTenant =
new InheritableThreadLocal<>();
public static void setTenant(TenantInfo tenant) {
currentTenant.set(tenant);
}
public static TenantInfo getTenant() {
TenantInfo tenant = currentTenant.get();
if (tenant == null) {
throw new TenantNotFoundException("テナント情報が設定されていません");
}
return tenant;
}
public static void clear() {
currentTenant.remove();
}
@Data
@Builder
public static class TenantInfo {
private String tenantId;
private String tenantName;
private GeminiTier tier; // FREE / STANDARD / ENTERPRISE
private Map<String, String> customConfig;
}
}
// TenantFilter.java: HTTP リクエストからテナント情報を抽出
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TenantFilter extends OncePerRequestFilter {
private final TenantRepository tenantRepository;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String tenantId = extractTenantId(request);
TenantInfo tenant = tenantRepository.findById(tenantId)
.orElseThrow(() -> new InvalidTenantException(tenantId));
TenantContext.setTenant(tenant);
filterChain.doFilter(request, response);
} finally {
TenantContext.clear(); // 必ずクリア(メモリリーク防止)
}
}
private String extractTenantId(HttpServletRequest request) {
// ヘッダー、JWT クレーム、サブドメインから取得
String tenantId = request.getHeader("X-Tenant-ID");
if (tenantId != null) return tenantId;
// JWT から取得
String jwt = request.getHeader("Authorization");
if (jwt != null && jwt.startsWith("Bearer ")) {
return jwtDecoder.decode(jwt.substring(7))
.getClaimAsString("tenant_id");
}
throw new MissingTenantException("テナントIDが指定されていません");
}
}
テナント別 ChatClient の動的生成
// TenantAwareChatService.java: テナント設定に基づく動的 ChatClient
@Service
@Slf4j
public class TenantAwareChatService {
private final VertexAiGeminiChatModel baseChatModel;
private final TenantConfigRepository configRepository;
// テナント別 ChatClient をキャッシュ(メモリ効率化)
private final Cache<String, ChatClient> chatClientCache =
Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofMinutes(30))
.build();
public String chat(String userMessage, String conversationId) {
TenantInfo tenant = TenantContext.getTenant();
ChatClient client = getOrCreateChatClient(tenant);
return client.prompt()
.system(sp -> sp
.text(tenant.getCustomConfig().getOrDefault(
"system_prompt",
"あなたは {tenant} のアシスタントです。"))
.param("tenant", tenant.getTenantName()))
.user(userMessage)
.advisors(a -> a.param(
AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY,
tenant.getTenantId() + ":" + conversationId))
.call()
.content();
}
private ChatClient getOrCreateChatClient(TenantInfo tenant) {
return chatClientCache.get(tenant.getTenantId(), tenantId -> {
TenantConfig config = configRepository.findByTenantId(tenantId)
.orElseGet(TenantConfig::defaultConfig);
return ChatClient.builder(baseChatModel)
.defaultOptions(VertexAiGeminiChatOptions.builder()
.withModel(config.getModel())
.withTemperature(config.getTemperature())
.withMaxOutputTokens(config.getMaxTokens())
.build())
.build();
});
}
}
非同期・並列処理による高スループット設計
Gemini API のレスポンスタイムは数百ミリ秒から数秒かかります。本番環境では、スレッドブロッキングを避け、高スループットを実現するための非同期設計が不可欠です。
Spring WebFlux + Project Reactor による非同期実装
// ReactiveGeminiService.java: WebFlux を使った非同期 AI サービス
@Service
@Slf4j
public class ReactiveGeminiService {
private final VertexAiGeminiChatModel chatModel;
private final MeterRegistry meterRegistry;
// ストリーミングレスポンス(Server-Sent Events 対応)
public Flux<String> streamResponse(String userMessage, String tenantId) {
return Flux.defer(() -> {
Prompt prompt = new Prompt(
userMessage,
VertexAiGeminiChatOptions.builder()
.withModel("gemini-2.5-flash") // ストリーミングには Flash が最適
.withTemperature(0.3f)
.build()
);
return chatModel.stream(prompt)
.map(response -> response.getResult().getOutput().getContent())
.doOnNext(chunk -> log.trace("Stream chunk: {} chars", chunk.length()))
.doOnError(e -> log.error("Streaming error for tenant {}: {}", tenantId, e.getMessage()))
.onErrorResume(this::handleStreamError);
})
.subscribeOn(Schedulers.boundedElastic())
.retryWhen(Retry.backoff(3, Duration.ofMillis(500))
.filter(this::isRetryable)
.doBeforeRetry(rs -> log.warn("Retrying stream ({}/3)", rs.totalRetries() + 1)));
}
// バッチ処理:複数リクエストの並列実行
public Mono<List<String>> processBatch(List<String> requests, String tenantId) {
int batchSize = 5; // 同時実行数(レート制限に応じて調整)
return Flux.fromIterable(requests)
.flatMap(
request -> processWithRateLimit(request, tenantId),
batchSize // 同時実行数の上限
)
.collectList()
.doOnSuccess(results -> {
meterRegistry.counter("gemini.batch.completed",
"tenant", tenantId,
"count", String.valueOf(results.size())
).increment();
});
}
// レート制限を考慮した単一リクエスト処理
private Mono<String> processWithRateLimit(String request, String tenantId) {
return Mono.fromCallable(() -> {
Prompt prompt = new Prompt(request);
return chatModel.call(prompt)
.getResult().getOutput().getContent();
})
.subscribeOn(Schedulers.boundedElastic())
.timeout(Duration.ofSeconds(30))
.onErrorMap(TimeoutException.class,
e -> new GeminiTimeoutException("リクエストタイムアウト: " + request.substring(0, 50)));
}
private boolean isRetryable(Throwable e) {
// 429 (Rate Limit) と 503 (Service Unavailable) のみリトライ
if (e instanceof WebClientResponseException wce) {
return wce.getStatusCode() == HttpStatus.TOO_MANY_REQUESTS ||
wce.getStatusCode() == HttpStatus.SERVICE_UNAVAILABLE;
}
return false;
}
private Flux<String> handleStreamError(Throwable e) {
log.error("Unrecoverable stream error: {}", e.getMessage());
return Flux.just("[エラーが発生しました。しばらく後にお試しください]");
}
}
// Controller での SSE エンドポイント実装
@RestController
@RequestMapping("/api/v1/chat")
public class ChatController {
private final ReactiveGeminiService geminiService;
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamChat(
@RequestBody ChatRequest request,
@AuthenticationPrincipal TenantUser user) {
return geminiService
.streamResponse(request.getMessage(), user.getTenantId())
.map(chunk -> ServerSentEvent.<String>builder()
.data(chunk)
.build())
.concatWith(Flux.just(
ServerSentEvent.<String>builder()
.event("done")
.data("[DONE]")
.build()
));
}
}
セキュリティ実装:本番環境の必須対策
APIキー管理とシークレット管理
// SecretManagerConfig.java: Google Secret Manager からキーを取得
@Configuration
public class SecretManagerConfig {
@Bean
public SecretManagerServiceClient secretManagerClient()
throws IOException {
return SecretManagerServiceClient.create();
}
@Bean
@Scope("prototype") // テナントごとにスコープ
public GeminiApiKeyProvider apiKeyProvider(
SecretManagerServiceClient secretClient,
@Value("${gcp.project-id}") String projectId) {
return tenantId -> {
// テナント別の API キーを Secret Manager から取得
String secretName = String.format(
"projects/%s/secrets/gemini-api-key-%s/versions/latest",
projectId, tenantId);
AccessSecretVersionResponse response =
secretClient.accessSecretVersion(secretName);
return response.getPayload().getData().toStringUtf8();
};
}
}
// InputValidationService.java: ユーザー入力の検証
@Service
public class InputValidationService {
private static final int MAX_INPUT_LENGTH = 32000; // トークン節約
private static final Pattern INJECTION_PATTERN =
Pattern.compile(
"(?i)(ignore|forget|disregard).*(previous|above|instruction|system)",
Pattern.DOTALL
);
public ValidationResult validate(String input, TenantInfo tenant) {
// 長さチェック
if (input.length() > MAX_INPUT_LENGTH) {
return ValidationResult.failure(
"入力が最大文字数(" + MAX_INPUT_LENGTH + "文字)を超えています");
}
// プロンプトインジェクション検出
if (INJECTION_PATTERN.matcher(input).find()) {
log.warn("Potential prompt injection detected for tenant: {}",
tenant.getTenantId());
return ValidationResult.failure("入力内容が不正です");
}
// テナント固有のブロックリストチェック
List<String> blockedTerms = tenant.getCustomConfig()
.getOrDefault("blocked_terms", "")
.lines()
.filter(s -> !s.isBlank())
.toList();
for (String term : blockedTerms) {
if (input.toLowerCase().contains(term.toLowerCase())) {
return ValidationResult.failure(
"禁止されたキーワードが含まれています");
}
}
return ValidationResult.success(input.trim());
}
}
レート制限の実装
// RateLimiterService.java: テナント別レート制限
@Service
@Slf4j
public class RateLimiterService {
private final RedisTemplate<String, String> redisTemplate;
// スライディングウィンドウアルゴリズムによるレート制限
public boolean tryAcquire(String tenantId, RateLimitConfig config) {
String key = "rate_limit:" + tenantId + ":" +
Instant.now().getEpochSecond() / 60; // 1分ウィンドウ
Long count = redisTemplate.opsForValue().increment(key);
redisTemplate.expire(key, Duration.ofMinutes(2));
if (count == null) return true;
if (count > config.getRequestsPerMinute()) {
log.warn("Rate limit exceeded for tenant: {} ({}/{})",
tenantId, count, config.getRequestsPerMinute());
return false;
}
return true;
}
// トークン使用量の追跡
public void recordTokenUsage(
String tenantId, int promptTokens, int completionTokens) {
String dailyKey = "tokens:" + tenantId + ":" +
LocalDate.now().toString();
redisTemplate.opsForHash().increment(
dailyKey, "prompt_tokens", promptTokens);
redisTemplate.opsForHash().increment(
dailyKey, "completion_tokens", completionTokens);
redisTemplate.expire(dailyKey, Duration.ofDays(7));
// 月次使用量警告
checkMonthlyQuota(tenantId, promptTokens + completionTokens);
}
private void checkMonthlyQuota(String tenantId, int tokens) {
String monthlyKey = "tokens_monthly:" + tenantId + ":" +
YearMonth.now().toString();
Long totalTokens = redisTemplate.opsForValue()
.increment(monthlyKey, tokens);
redisTemplate.expire(monthlyKey, Duration.ofDays(35));
// 80% 到達時に警告メール送信
long monthlyLimit = getMonthlyTokenLimit(tenantId);
if (totalTokens != null && totalTokens > monthlyLimit * 0.8) {
eventPublisher.publishEvent(
new QuotaWarningEvent(tenantId, totalTokens, monthlyLimit));
}
}
}
可観測性:本番監視の実装
Micrometer によるメトリクス収集
// GeminiMetricsAdvisor.java: AI リクエストの計測
@Component
public class GeminiMetricsAdvisor implements RequestResponseAdvisor {
private final MeterRegistry meterRegistry;
private final ThreadLocal<Timer.Sample> timerSample = new ThreadLocal<>();
@Override
public AdvisedRequest adviseRequest(
AdvisedRequest request, Map<String, Object> context) {
timerSample.set(Timer.start(meterRegistry));
context.put("start_time", System.currentTimeMillis());
context.put("tenant_id", TenantContext.getTenant().getTenantId());
// プロンプトのトークン数を推定(課金管理用)
int estimatedTokens = estimateTokens(
request.userText() + request.systemText());
context.put("estimated_prompt_tokens", estimatedTokens);
meterRegistry.counter("gemini.request.count",
"tenant", context.get("tenant_id").toString(),
"model", getModelName(request)
).increment();
return request;
}
@Override
public ChatResponse adviseResponse(
ChatResponse response, Map<String, Object> context) {
Timer.Sample sample = timerSample.get();
if (sample != null) {
sample.stop(Timer.builder("gemini.request.latency")
.tag("tenant", context.get("tenant_id").toString())
.tag("status", "success")
.register(meterRegistry));
timerSample.remove();
}
// 実際のトークン使用量を記録
if (response.getMetadata().getUsage() != null) {
Usage usage = response.getMetadata().getUsage();
meterRegistry.counter("gemini.tokens.prompt",
"tenant", context.get("tenant_id").toString()
).increment(usage.getPromptTokens());
meterRegistry.counter("gemini.tokens.completion",
"tenant", context.get("tenant_id").toString()
).increment(usage.getGenerationTokens());
// コスト計算(Gemini 2.5 Pro の単価)
double estimatedCost = calculateCost(
usage.getPromptTokens(),
usage.getGenerationTokens()
);
meterRegistry.gauge("gemini.cost.request",
Tags.of("tenant", context.get("tenant_id").toString()),
estimatedCost
);
}
return response;
}
// Gemini 2.5 Pro の料金(参考値: 2026年4月時点)
private double calculateCost(long promptTokens, long completionTokens) {
double promptCost = promptTokens / 1_000_000.0 * 1.25; // $1.25/1M tokens
double completionCost = completionTokens / 1_000_000.0 * 10.0; // $10/1M tokens
return promptCost + completionCost;
}
}
OpenTelemetry による分散トレーシング
// TracingConfig.java: OpenTelemetry 設定
@Configuration
public class TracingConfig {
@Bean
public OpenTelemetrySdk openTelemetry(
@Value("${otel.exporter.otlp.endpoint}") String endpoint) {
return OpenTelemetrySdk.builder()
.setTracerProvider(SdkTracerProvider.builder()
.addSpanProcessor(BatchSpanProcessor.builder(
OtlpGrpcSpanExporter.builder()
.setEndpoint(endpoint)
.build())
.build())
.setResource(Resource.getDefault().merge(
Resource.create(Attributes.of(
ResourceAttributes.SERVICE_NAME, "gemini-api-service",
ResourceAttributes.SERVICE_VERSION, "2.0.0"
))))
.build())
.build();
}
}
// GeminiTracingService.java: AI リクエストのトレーシング
@Service
public class GeminiTracingService {
private final Tracer tracer;
private final ChatClient chatClient;
public String chatWithTracing(
String userMessage, String tenantId, String conversationId) {
Span span = tracer.spanBuilder("gemini.chat")
.setAttribute("tenant.id", tenantId)
.setAttribute("conversation.id", conversationId)
.setAttribute("user.message.length", userMessage.length())
.startSpan();
try (Scope scope = span.makeCurrent()) {
String response = chatClient.prompt()
.user(userMessage)
.advisors(a -> a.param(
AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY,
tenantId + ":" + conversationId))
.call()
.content();
span.setAttribute("response.length", response.length());
span.setStatus(StatusCode.OK);
return response;
} catch (Exception e) {
span.recordException(e);
span.setStatus(StatusCode.ERROR, e.getMessage());
throw e;
} finally {
span.end();
}
}
}
テスト戦略:本番品質を保証する
ユニットテスト:MockChatClient の活用
// ChatServiceTest.java: ChatClient のモックを使ったユニットテスト
@ExtendWith(MockitoExtension.class)
class TenantAwareChatServiceTest {
@Mock
private VertexAiGeminiChatModel chatModel;
@Mock
private TenantConfigRepository configRepository;
@InjectMocks
private TenantAwareChatService chatService;
@BeforeEach
void setUp() {
// テナント情報をセット
TenantContext.setTenant(TenantInfo.builder()
.tenantId("tenant-001")
.tenantName("テスト株式会社")
.tier(GeminiTier.STANDARD)
.customConfig(Map.of())
.build());
}
@AfterEach
void tearDown() {
TenantContext.clear();
}
@Test
void チャット応答が正常に返されること() {
// Given
String userMessage = "こんにちは";
String expectedResponse = "こんにちは!何かお手伝いできることはありますか?";
ChatResponse mockResponse = createMockChatResponse(expectedResponse);
when(chatModel.call(any(Prompt.class))).thenReturn(mockResponse);
// When
String actual = chatService.chat(userMessage, "conv-001");
// Then
assertThat(actual).isEqualTo(expectedResponse);
verify(chatModel, times(1)).call(any(Prompt.class));
}
@Test
void レート制限超過時に例外がスローされること() {
// Given
when(rateLimiterService.tryAcquire(anyString(), any()))
.thenReturn(false);
// When & Then
assertThatThrownBy(() -> chatService.chat("テスト", "conv-001"))
.isInstanceOf(RateLimitExceededException.class)
.hasMessageContaining("レート制限");
}
private ChatResponse createMockChatResponse(String content) {
AssistantMessage message = new AssistantMessage(content);
Generation generation = new Generation(message);
return new ChatResponse(List.of(generation));
}
}
統合テスト:Testcontainers の活用
// GeminiIntegrationTest.java: 実 API を使った統合テスト(CI/CD 用)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestcontainersのRedis
@ActiveProfiles("integration-test")
class GeminiIntegrationTest {
@Container
static RedisContainer redis = new RedisContainer(
DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379);
@DynamicPropertySource
static void configureRedis(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", redis::getFirstMappedPort);
}
@Autowired
private TestRestTemplate restTemplate;
@Test
@Disabled("APIキーが設定されている環境のみ実行")
void 本番APIとの疎通確認() {
// Given
ChatRequest request = new ChatRequest("Hello, Gemini!");
// When
ResponseEntity<ChatResponse> response = restTemplate.postForEntity(
"/api/v1/chat",
request,
ChatResponse.class
);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().getMessage()).isNotBlank();
}
@Test
void 会話の継続性が保持されること() {
// モック ChatClient を使った会話継続テスト
String conversationId = UUID.randomUUID().toString();
// 1回目のメッセージ
chatService.chat("私の名前は田中です", conversationId);
// 2回目:名前を覚えているか確認
String response = chatService.chat("私の名前は何ですか?", conversationId);
// 実際の会話では「田中」が含まれるはず(モックでは別途設定)
assertThat(conversationId).isNotNull();
}
}
個人開発者の視点から(実体験メモ)
まとめ
ここで扱うのはGemini API を Spring Boot 本番環境で運用するための核心的な設計パターンを解説しました。
重要なポイントを振り返ると:
- Spring AI フレームワークを活用することで、プロバイダー非依存の保守性の高いコードが書ける
- マルチテナント設計では ThreadLocal + Redis キャッシュ + テナント別設定管理が鍵
- 非同期処理は Spring WebFlux + Project Reactor で実装し、レート制限に配慮した設計が不可欠
- セキュリティは Secret Manager・プロンプトインジェクション対策・レート制限の3層で守る
- 可観測性は Micrometer(メトリクス)+ OpenTelemetry(トレーシング)+ 構造化ログの組み合わせが最強
- テストはユニット(Mock ChatClient)・統合(Testcontainers)・E2E の3層で品質を保証する
これらのパターンを組み合わせることで、数万ユーザーが利用する本番システムでも安定して Gemini API を活用できます。
Spring AI の最新機能や Gemini 2.5 Pro の新機能については、公式 Spring AI ドキュメントと Google AI for Developers を定期的にご確認ください。
Spring AI の設計思想から本番パターンまで、本記事のテーマを補完する内容が充実しています。