diff --git a/cs25-service/build.gradle b/cs25-service/build.gradle index f9215094..4159c5c4 100644 --- a/cs25-service/build.gradle +++ b/cs25-service/build.gradle @@ -33,6 +33,12 @@ dependencies { implementation "org.springframework.ai:spring-ai-starter-mcp-client:1.0.0" implementation "org.springframework.ai:spring-ai-starter-mcp-client-webflux:1.0.0" + // resilience4j + implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.3.0' + implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.3.0' + implementation 'io.github.resilience4j:resilience4j-retry:2.3.0' + implementation 'io.github.resilience4j:resilience4j-reactor:2.3.0' + //JavaMailSender implementation 'jakarta.mail:jakarta.mail-api:2.1.0' diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/ClaudeChatClient.java b/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/ClaudeChatClient.java index 8ef9e3f8..8b618aab 100644 --- a/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/ClaudeChatClient.java +++ b/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/ClaudeChatClient.java @@ -2,6 +2,7 @@ import com.example.cs25service.domain.ai.exception.AiException; import com.example.cs25service.domain.ai.exception.AiExceptionCode; +import com.example.cs25service.domain.ai.resilience.AiResilience; import org.springframework.ai.chat.client.ChatClient; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; @@ -11,18 +12,23 @@ public class ClaudeChatClient implements AiChatClient { private final ChatClient anthropicChatClient; + private final AiResilience resilience; - public ClaudeChatClient(@Qualifier("anthropicChatClient") ChatClient anthropicChatClient) { + public ClaudeChatClient(@Qualifier("anthropicChatClient") ChatClient anthropicChatClient, + AiResilience resilience) { this.anthropicChatClient = anthropicChatClient; + this.resilience = resilience; } @Override public String call(String systemPrompt, String userPrompt) { - return anthropicChatClient.prompt() - .system(systemPrompt) - .user(userPrompt) - .call() - .content(); + return resilience.executeSync("claude", () -> + anthropicChatClient.prompt() + .system(systemPrompt) + .user(userPrompt) + .call() + .content() + ); } @Override @@ -32,13 +38,12 @@ public ChatClient raw() { @Override public Flux stream(String systemPrompt, String userPrompt) { - return anthropicChatClient.prompt() - .system(systemPrompt) - .user(userPrompt) - .stream() - .content() - .onErrorResume(error -> { - throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR); - }); + return resilience.executeStream("claude", () -> + anthropicChatClient.prompt() + .system(systemPrompt) + .user(userPrompt) + .stream() + .content() + ).onErrorMap(e -> new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR)); } } diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/OpenAiChatClient.java b/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/OpenAiChatClient.java index 7ca1c63d..7f99f60a 100644 --- a/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/OpenAiChatClient.java +++ b/cs25-service/src/main/java/com/example/cs25service/domain/ai/client/OpenAiChatClient.java @@ -2,6 +2,7 @@ import com.example.cs25service.domain.ai.exception.AiException; import com.example.cs25service.domain.ai.exception.AiExceptionCode; +import com.example.cs25service.domain.ai.resilience.AiResilience; import org.springframework.ai.chat.client.ChatClient; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; @@ -11,19 +12,25 @@ public class OpenAiChatClient implements AiChatClient { private final ChatClient openAiChatClient; + private final AiResilience resilience; - public OpenAiChatClient(@Qualifier("openAiChatModelClient") ChatClient openAiChatClient) { + public OpenAiChatClient( + @Qualifier("openAiChatModelClient") ChatClient openAiChatClient, + AiResilience resilience) { this.openAiChatClient = openAiChatClient; + this.resilience = resilience; } @Override public String call(String systemPrompt, String userPrompt) { - return openAiChatClient.prompt() - .system(systemPrompt) - .user(userPrompt) - .call() - .content() - .trim(); + return resilience.executeSync("openai", () -> + openAiChatClient.prompt() + .system(systemPrompt) + .user(userPrompt) + .call() + .content() + .trim() + ); } @Override @@ -33,13 +40,12 @@ public ChatClient raw() { @Override public Flux stream(String systemPrompt, String userPrompt) { - return openAiChatClient.prompt() - .system(systemPrompt) - .user(userPrompt) - .stream() - .content() - .onErrorResume(error -> { - throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR); - }); + return resilience.executeStream("openai", () -> + openAiChatClient.prompt() + .system(systemPrompt) + .user(userPrompt) + .stream() + .content() + ).onErrorMap(e -> new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR)); } } diff --git a/cs25-service/src/main/java/com/example/cs25service/domain/ai/resilience/AiResilience.java b/cs25-service/src/main/java/com/example/cs25service/domain/ai/resilience/AiResilience.java new file mode 100644 index 00000000..d6802272 --- /dev/null +++ b/cs25-service/src/main/java/com/example/cs25service/domain/ai/resilience/AiResilience.java @@ -0,0 +1,45 @@ +package com.example.cs25service.domain.ai.resilience; + +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.reactor.circuitbreaker.operator.CircuitBreakerOperator; +import io.github.resilience4j.reactor.retry.RetryOperator; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryRegistry; +import java.util.function.Supplier; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Flux; + +@Component +@RequiredArgsConstructor +public class AiResilience { + + private final CircuitBreakerRegistry cbRegistry; + private final RetryRegistry retryRegistry; + + /** + * 동기 호출: Retry → CircuitBreaker 순서 + */ + public T executeSync(String name, Supplier supplier) { + CircuitBreaker cb = cbRegistry.circuitBreaker(name); + Retry retry = retryRegistry.retry(name); + + Supplier withRetry = Retry.decorateSupplier(retry, supplier); + Supplier withCb = CircuitBreaker.decorateSupplier(cb, withRetry); + + return withCb.get(); + } + + /** + * Flux 스트리밍: RetryOperator → CircuitBreakerOperator 순서 + */ + public Flux executeStream(String name, Supplier> supplier) { + CircuitBreaker cb = cbRegistry.circuitBreaker(name); + Retry retry = retryRegistry.retry(name); + + return supplier.get() + .transformDeferred(RetryOperator.of(retry)) + .transformDeferred(CircuitBreakerOperator.of(cb)); + } +} diff --git a/cs25-service/src/main/resources/application.properties b/cs25-service/src/main/resources/application.properties index 5cc8fa9c..39649f39 100644 --- a/cs25-service/src/main/resources/application.properties +++ b/cs25-service/src/main/resources/application.properties @@ -67,12 +67,39 @@ spring.ai.mcp.client.enabled=true spring.ai.mcp.client.type=SYNC spring.ai.mcp.client.request-timeout=60s spring.ai.mcp.client.root-change-notification=false -# STDIO Connect: Brave Search +# Brave Search spring.ai.mcp.client.stdio.connections.brave.command=server-brave-search spring.ai.mcp.client.stdio.connections.brave.args[0]=--transport spring.ai.mcp.client.stdio.connections.brave.args[1]=stdio spring.ai.mcp.client.stdio.connections.brave.env.BRAVE_API_KEY=${BRAVE_API_KEY} spring.autoconfigure.exclude=org.springframework.ai.model.tool.autoconfigure.ToolCallingAutoConfiguration +# CircuitBreaker +resilience4j.circuitbreaker.configs.default.slidingWindowType=COUNT_BASED +resilience4j.circuitbreaker.configs.default.slidingWindowSize=10 +resilience4j.circuitbreaker.configs.default.failureRateThreshold=50 +resilience4j.circuitbreaker.configs.default.slowCallDurationThreshold=5s +resilience4j.circuitbreaker.configs.default.slowCallRateThreshold=70 +resilience4j.circuitbreaker.configs.default.minimumNumberOfCalls=6 +resilience4j.circuitbreaker.configs.default.waitDurationInOpenState=30m +resilience4j.circuitbreaker.configs.default.permittedNumberOfCallsInHalfOpenState=1 +resilience4j.circuitbreaker.configs.default.automaticTransitionFromOpenToHalfOpenEnabled=true +resilience4j.circuitbreaker.instances.openai.baseConfig=default +resilience4j.circuitbreaker.instances.claude.baseConfig=default +resilience4j.circuitbreaker.configs.default.ignoreExceptions[0]=org.springframework.web.reactive.function.client.WebClientResponseException.BadRequest +resilience4j.circuitbreaker.configs.default.ignoreExceptions[1]=org.springframework.web.reactive.function.client.WebClientResponseException.Unauthorized +resilience4j.circuitbreaker.configs.default.ignoreExceptions[2]=org.springframework.web.reactive.function.client.WebClientResponseException.Forbidden +resilience4j.circuitbreaker.configs.default.ignoreExceptions[3]=org.springframework.web.reactive.function.client.WebClientResponseException.NotFound +# Retry +resilience4j.retry.configs.default.maxAttempts=2 +resilience4j.retry.configs.default.waitDuration=200ms +resilience4j.retry.configs.default.enableExponentialBackoff=true +resilience4j.retry.configs.default.exponentialBackoffMultiplier=2 +resilience4j.retry.instances.openai.baseConfig=default +resilience4j.retry.instances.claude.baseConfig=default +resilience4j.retry.configs.default.ignoreExceptions[0]=org.springframework.web.reactive.function.client.WebClientResponseException.BadRequest +resilience4j.retry.configs.default.ignoreExceptions[1]=org.springframework.web.reactive.function.client.WebClientResponseException.Unauthorized +resilience4j.retry.configs.default.ignoreExceptions[2]=org.springframework.web.reactive.function.client.WebClientResponseException.Forbidden +resilience4j.retry.configs.default.ignoreExceptions[3]=org.springframework.web.reactive.function.client.WebClientResponseException.NotFound #MAIL spring.mail.host=smtp.gmail.com spring.mail.port=587