From 3f3787c469b92e9c9e63a10658451bfd33719eee Mon Sep 17 00:00:00 2001 From: dongan Date: Wed, 27 May 2026 15:55:35 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20ChatbotService=20Resilience4j=20?= =?UTF-8?q?=EA=B3=A0=EB=8F=84=ED=99=94=20(Bulkhead,=20Retry,=20TimeLimiter?= =?UTF-8?q?,=20Fallback)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bulkhead: 동시 AI 호출 최대 20개 제한, 초과 시 즉시 거부 - Retry: 일시적 오류 자동 재시도 (CustomException 제외로 429/인증 오류 재시도 방지) - TimeLimiter: CompletableFuture 10초 타임아웃 적용 - Fallback: 예외 던지는 대신 에러 유형별 사용자 친화적 메시지 반환 Co-Authored-By: Claude Sonnet 4.6 --- .../chatbot/service/ChatbotService.java | 73 +++++++++++++++++-- 1 file changed, 65 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/catchtable/chatbot/service/ChatbotService.java b/src/main/java/com/catchtable/chatbot/service/ChatbotService.java index 3d6f965..9a9b428 100644 --- a/src/main/java/com/catchtable/chatbot/service/ChatbotService.java +++ b/src/main/java/com/catchtable/chatbot/service/ChatbotService.java @@ -14,9 +14,14 @@ import com.catchtable.reservation.service.ReservationService; import com.catchtable.store.service.StoreService; import lombok.extern.slf4j.Slf4j; +import io.github.resilience4j.bulkhead.Bulkhead; +import io.github.resilience4j.bulkhead.BulkheadFullException; +import io.github.resilience4j.bulkhead.BulkheadRegistry; import io.github.resilience4j.circuitbreaker.CallNotPermittedException; import io.github.resilience4j.circuitbreaker.CircuitBreaker; import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryRegistry; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.Message; @@ -27,6 +32,10 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; @Slf4j @Service @@ -34,6 +43,7 @@ public class ChatbotService { private static final int MAX_HISTORY_SIZE = 20; private static final int SUMMARY_KEEP_RECENT = 10; + private static final long AI_TIMEOUT_SECONDS = 10; private final ChatClient chatClient; private final ChatbotDbService dbService; @@ -42,11 +52,15 @@ public class ChatbotService { private final StoreService storeService; private final StoreRemainService storeRemainService; private final CircuitBreaker aiCircuitBreaker; + private final Bulkhead aiBulkhead; + private final Retry aiRetry; public ChatbotService(ChatClient chatClient, ChatbotDbService dbService, ReservationService reservationService, CouponService couponService, StoreService storeService, StoreRemainService storeRemainService, - CircuitBreakerRegistry circuitBreakerRegistry) { + CircuitBreakerRegistry circuitBreakerRegistry, + BulkheadRegistry bulkheadRegistry, + RetryRegistry retryRegistry) { this.chatClient = chatClient; this.dbService = dbService; this.reservationService = reservationService; @@ -54,6 +68,8 @@ public ChatbotService(ChatClient chatClient, ChatbotDbService dbService, this.storeService = storeService; this.storeRemainService = storeRemainService; this.aiCircuitBreaker = circuitBreakerRegistry.circuitBreaker("ai-api"); + this.aiBulkhead = bulkheadRegistry.bulkhead("ai-api"); + this.aiRetry = retryRegistry.retry("ai-api"); } public ChatMessageResponse sendMessage(Long userId, ChatMessageRequest request) { @@ -96,23 +112,64 @@ private String callAi(List history, Long userId, Double latitude, D if (longitude != null) context.put("longitude", longitude); try { - return aiCircuitBreaker.executeSupplier(() -> + // 적용 순서: Bulkhead → Retry → CircuitBreaker → 실제 호출(타임아웃 포함) + return aiBulkhead.executeSupplier(() -> + aiRetry.executeSupplier(() -> + aiCircuitBreaker.executeSupplier(() -> + executeAiCallWithTimeout(messages, context) + ) + ) + ); + } catch (CallNotPermittedException e) { + log.warn("AI 서킷브레이커 OPEN — 요청 차단됨"); + return "AI 서비스가 일시적으로 점검 중입니다. 잠시 후 다시 시도해 주세요."; + } catch (BulkheadFullException e) { + log.warn("AI Bulkhead 초과 — 동시 요청 한도 초과"); + return "현재 많은 요청이 몰려 있습니다. 잠시 후 다시 시도해 주세요."; + } catch (CustomException e) { + return buildFallbackMessage(e); + } catch (Exception e) { + log.error("AI API 호출 중 예상치 못한 예외", e); + return "AI 서비스를 일시적으로 이용할 수 없습니다. 잠시 후 다시 시도해 주세요."; + } + } + + // 10초 타임아웃 적용 — 초과 시 CHAT_AI_TIMEOUT 예외 (Retry 제외 대상) + private String executeAiCallWithTimeout(List messages, Map context) { + try { + return CompletableFuture.supplyAsync(() -> chatClient.prompt() .messages(messages) .tools(reservationService, couponService, storeService, storeRemainService) .toolContext(context) .call() .content() - ); - } catch (CallNotPermittedException e) { - log.warn("AI API 서킷브레이커 OPEN 상태 — 요청 차단됨"); - throw new CustomException(ErrorCode.CHAT_AI_CIRCUIT_OPEN); - } catch (Exception e) { - handleAiException(e); + ).get(AI_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (TimeoutException e) { + log.warn("AI API 타임아웃 ({}초 초과)", AI_TIMEOUT_SECONDS); + throw new CustomException(ErrorCode.CHAT_AI_TIMEOUT); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof CustomException ce) throw ce; + handleAiException(new RuntimeException(cause.getMessage(), cause)); return null; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CustomException(ErrorCode.CHAT_AI_ERROR); } } + // 각 에러 유형별 사용자 노출 메시지 — 예외를 던지지 않고 fallback 문자열 반환 + private String buildFallbackMessage(CustomException e) { + return switch (e.getErrorCode()) { + case CHAT_AI_TIMEOUT -> "AI 응답이 지연되고 있습니다. 잠시 후 다시 시도해 주세요."; + case CHAT_AI_RATE_LIMIT -> "AI 서비스 요청 한도에 도달했습니다. 1분 후 다시 시도해 주세요."; + case CHAT_AI_AUTH_ERROR -> "AI 서비스 인증에 문제가 발생했습니다. 관리자에게 문의해 주세요."; + case CHAT_AI_CIRCUIT_OPEN -> "AI 서비스가 일시적으로 점검 중입니다. 잠시 후 다시 시도해 주세요."; + default -> "AI 서비스를 일시적으로 이용할 수 없습니다. 잠시 후 다시 시도해 주세요."; + }; + } + private List buildMessages(List history, Long userId, String summarySuffix) { List messages = new ArrayList<>(); messages.add(new SystemMessage(buildSystemPrompt(summarySuffix))); From 0ce9eaae5907d27d32eb47257d98deb9d6d4a04e Mon Sep 17 00:00:00 2001 From: dongan Date: Wed, 27 May 2026 16:13:45 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20Retry=20=EB=AF=B8=EB=8F=99=EC=9E=91?= =?UTF-8?q?=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20AI=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=EC=8A=A4=EB=A0=88=EB=93=9C=ED=92=80=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - executeAiCallWithTimeout()에서 RuntimeException throw로 변경하여 Retry 동작 보장 - CompletableFuture.supplyAsync()에 전용 Executor(newFixedThreadPool(20)) 주입으로 ForkJoinPool 스레드 기아 해결 - callAi() catch(Exception)에서 handleAiException() 호출 후 CustomException 변환 Co-Authored-By: Claude Sonnet 4.6 --- .../chatbot/service/ChatbotService.java | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/catchtable/chatbot/service/ChatbotService.java b/src/main/java/com/catchtable/chatbot/service/ChatbotService.java index 9a9b428..734e30f 100644 --- a/src/main/java/com/catchtable/chatbot/service/ChatbotService.java +++ b/src/main/java/com/catchtable/chatbot/service/ChatbotService.java @@ -34,6 +34,8 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -44,6 +46,8 @@ public class ChatbotService { private static final int MAX_HISTORY_SIZE = 20; private static final int SUMMARY_KEEP_RECENT = 10; private static final long AI_TIMEOUT_SECONDS = 10; + // Bulkhead max-concurrent-calls(20)와 동일한 크기로 격리된 전용 스레드풀 + private static final int AI_THREAD_POOL_SIZE = 20; private final ChatClient chatClient; private final ChatbotDbService dbService; @@ -54,6 +58,8 @@ public class ChatbotService { private final CircuitBreaker aiCircuitBreaker; private final Bulkhead aiBulkhead; private final Retry aiRetry; + // ForkJoinPool 공용 풀 대신 AI 호출 전용 스레드풀 사용 + private final Executor aiExecutor; public ChatbotService(ChatClient chatClient, ChatbotDbService dbService, ReservationService reservationService, CouponService couponService, @@ -70,6 +76,7 @@ public ChatbotService(ChatClient chatClient, ChatbotDbService dbService, this.aiCircuitBreaker = circuitBreakerRegistry.circuitBreaker("ai-api"); this.aiBulkhead = bulkheadRegistry.bulkhead("ai-api"); this.aiRetry = retryRegistry.retry("ai-api"); + this.aiExecutor = Executors.newFixedThreadPool(AI_THREAD_POOL_SIZE); } public ChatMessageResponse sendMessage(Long userId, ChatMessageRequest request) { @@ -113,6 +120,8 @@ private String callAi(List history, Long userId, Double latitude, D try { // 적용 순서: Bulkhead → Retry → CircuitBreaker → 실제 호출(타임아웃 포함) + // executeAiCallWithTimeout은 RuntimeException을 그대로 전파 → Retry가 재시도 판단 + // 최종 실패 시 catch(Exception)에서 handleAiException → CustomException → fallback return aiBulkhead.executeSupplier(() -> aiRetry.executeSupplier(() -> aiCircuitBreaker.executeSupplier(() -> @@ -127,14 +136,21 @@ private String callAi(List history, Long userId, Double latitude, D log.warn("AI Bulkhead 초과 — 동시 요청 한도 초과"); return "현재 많은 요청이 몰려 있습니다. 잠시 후 다시 시도해 주세요."; } catch (CustomException e) { + // 재시도 불필요한 예외 (429, 인증 오류 등) — ignore-exceptions 설정으로 Retry 미통과 return buildFallbackMessage(e); } catch (Exception e) { - log.error("AI API 호출 중 예상치 못한 예외", e); + // Retry 소진 후 최종 실패 → handleAiException으로 에러 분류 후 fallback + try { + handleAiException(e); + } catch (CustomException ce) { + return buildFallbackMessage(ce); + } return "AI 서비스를 일시적으로 이용할 수 없습니다. 잠시 후 다시 시도해 주세요."; } } - // 10초 타임아웃 적용 — 초과 시 CHAT_AI_TIMEOUT 예외 (Retry 제외 대상) + // RuntimeException을 그대로 전파 → Retry가 재시도 여부 판단 가능 + // CustomException(429, 인증 오류 등)만 그대로 throw → Retry ignore-exceptions에서 제외 private String executeAiCallWithTimeout(List messages, Map context) { try { return CompletableFuture.supplyAsync(() -> @@ -143,30 +159,34 @@ private String executeAiCallWithTimeout(List messages, Map "AI 응답이 지연되고 있습니다. 잠시 후 다시 시도해 주세요."; - case CHAT_AI_RATE_LIMIT -> "AI 서비스 요청 한도에 도달했습니다. 1분 후 다시 시도해 주세요."; - case CHAT_AI_AUTH_ERROR -> "AI 서비스 인증에 문제가 발생했습니다. 관리자에게 문의해 주세요."; + case CHAT_AI_TIMEOUT -> "AI 응답이 지연되고 있습니다. 잠시 후 다시 시도해 주세요."; + case CHAT_AI_RATE_LIMIT -> "AI 서비스 요청 한도에 도달했습니다. 1분 후 다시 시도해 주세요."; + case CHAT_AI_AUTH_ERROR -> "AI 서비스 인증에 문제가 발생했습니다. 관리자에게 문의해 주세요."; case CHAT_AI_CIRCUIT_OPEN -> "AI 서비스가 일시적으로 점검 중입니다. 잠시 후 다시 시도해 주세요."; - default -> "AI 서비스를 일시적으로 이용할 수 없습니다. 잠시 후 다시 시도해 주세요."; + default -> "AI 서비스를 일시적으로 이용할 수 없습니다. 잠시 후 다시 시도해 주세요."; }; }