From f9e1197813a6f0330651269da909dafaab495360 Mon Sep 17 00:00:00 2001 From: hemsej018 Date: Mon, 18 Aug 2025 00:41:09 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=EB=A0=88=EC=A7=88=EB=A6=AC=EC=96=B8?= =?UTF-8?q?=EC=8A=A44j=20=EB=B9=8C=EB=93=9C=EA=B7=B8=EB=9E=98=EB=93=A4=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cs25-service/build.gradle | 6 ++++++ 1 file changed, 6 insertions(+) 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' From 5e385aee25cc613293061996369fa132a4bc4acd Mon Sep 17 00:00:00 2001 From: hemsej018 Date: Mon, 18 Aug 2025 01:22:16 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=EB=A0=88=EC=A7=88=EB=A6=AC=EC=96=B8?= =?UTF-8?q?=EC=8A=A4=20appliciation=20properties=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ai/resilience/AiResilience.java | 5 +++++ .../src/main/resources/application.properties | 21 ++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 cs25-service/src/main/java/com/example/cs25service/domain/ai/resilience/AiResilience.java 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..ae69ae19 --- /dev/null +++ b/cs25-service/src/main/java/com/example/cs25service/domain/ai/resilience/AiResilience.java @@ -0,0 +1,5 @@ +package com.example.cs25service.domain.ai.resilience; + +public class AiResilience { + +} diff --git a/cs25-service/src/main/resources/application.properties b/cs25-service/src/main/resources/application.properties index 5cc8fa9c..3d0f7314 100644 --- a/cs25-service/src/main/resources/application.properties +++ b/cs25-service/src/main/resources/application.properties @@ -67,12 +67,31 @@ 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 +# 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 #MAIL spring.mail.host=smtp.gmail.com spring.mail.port=587 From 000b232d2b71450b334c46e2d6e59b9c49453f73 Mon Sep 17 00:00:00 2001 From: hemsej018 Date: Mon, 18 Aug 2025 15:04:43 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:fallback=EA=B5=AC=EC=A1=B0=20CB,Retry?= =?UTF-8?q?=20=EB=8D=B0=EC=BD=94=EB=A0=88=EC=9D=B4=ED=84=B0=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ai/client/ClaudeChatClient.java | 36 ++++++++++------- .../domain/ai/client/OpenAiChatClient.java | 39 +++++++++++------- .../domain/ai/resilience/AiResilience.java | 40 +++++++++++++++++++ 3 files changed, 86 insertions(+), 29 deletions(-) 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..568f93ee 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,15 @@ 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() + .onErrorResume(error -> { + throw 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..5b1ab024 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,15 @@ 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() + .onErrorResume(error -> { + throw 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 index ae69ae19..d6802272 100644 --- 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 @@ -1,5 +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)); + } } From ca531ba75823fca21ddb898d5e451b35d8e158ee Mon Sep 17 00:00:00 2001 From: hemsej018 Date: Mon, 18 Aug 2025 15:36:50 +0900 Subject: [PATCH 4/5] =?UTF-8?q?chore:=EC=8A=A4=ED=8A=B8=EB=A6=BC=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EB=A7=A4=ED=95=91=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EC=9D=B4=ED=9B=84=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cs25service/domain/ai/client/ClaudeChatClient.java | 5 +---- .../cs25service/domain/ai/client/OpenAiChatClient.java | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) 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 568f93ee..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 @@ -44,9 +44,6 @@ public Flux stream(String systemPrompt, String userPrompt) { .user(userPrompt) .stream() .content() - .onErrorResume(error -> { - throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR); - }) - ); + ).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 5b1ab024..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 @@ -46,9 +46,6 @@ public Flux stream(String systemPrompt, String userPrompt) { .user(userPrompt) .stream() .content() - .onErrorResume(error -> { - throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR); - }) - ); + ).onErrorMap(e -> new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR)); } } From 0e236b07d4bbb209e65480c1418b4dba824a24a0 Mon Sep 17 00:00:00 2001 From: hemsej018 Date: Mon, 18 Aug 2025 15:40:01 +0900 Subject: [PATCH 5/5] =?UTF-8?q?chore:4xx=20=EC=98=A4=EB=A5=98=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cs25-service/src/main/resources/application.properties | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cs25-service/src/main/resources/application.properties b/cs25-service/src/main/resources/application.properties index 3d0f7314..39649f39 100644 --- a/cs25-service/src/main/resources/application.properties +++ b/cs25-service/src/main/resources/application.properties @@ -85,6 +85,10 @@ resilience4j.circuitbreaker.configs.default.permittedNumberOfCallsInHalfOpenStat 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 @@ -92,6 +96,10 @@ 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