-
Notifications
You must be signed in to change notification settings - Fork 0
feat: ChatbotService Resilience4j 고도화 (Bulkhead, Retry, TimeLimiter, … #96
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,13 +32,22 @@ | |
| 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.Executor; | ||
| import java.util.concurrent.Executors; | ||
| import java.util.concurrent.TimeUnit; | ||
| import java.util.concurrent.TimeoutException; | ||
|
|
||
| @Slf4j | ||
| @Service | ||
| 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; | ||
|
|
@@ -42,18 +56,27 @@ public class ChatbotService { | |
| private final StoreService storeService; | ||
| private final StoreRemainService storeRemainService; | ||
| 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, | ||
| StoreService storeService, StoreRemainService storeRemainService, | ||
| CircuitBreakerRegistry circuitBreakerRegistry) { | ||
| CircuitBreakerRegistry circuitBreakerRegistry, | ||
| BulkheadRegistry bulkheadRegistry, | ||
| RetryRegistry retryRegistry) { | ||
| this.chatClient = chatClient; | ||
| this.dbService = dbService; | ||
| this.reservationService = reservationService; | ||
| this.couponService = couponService; | ||
| this.storeService = storeService; | ||
| this.storeRemainService = storeRemainService; | ||
| 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) { | ||
|
|
@@ -96,23 +119,77 @@ private String callAi(List<ChatMessage> history, Long userId, Double latitude, D | |
| if (longitude != null) context.put("longitude", longitude); | ||
|
|
||
| try { | ||
| return aiCircuitBreaker.executeSupplier(() -> | ||
| // 적용 순서: Bulkhead → Retry → CircuitBreaker → 실제 호출(타임아웃 포함) | ||
| // executeAiCallWithTimeout은 RuntimeException을 그대로 전파 → Retry가 재시도 판단 | ||
| // 최종 실패 시 catch(Exception)에서 handleAiException → CustomException → fallback | ||
| 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) { | ||
| // 재시도 불필요한 예외 (429, 인증 오류 등) — ignore-exceptions 설정으로 Retry 미통과 | ||
| return buildFallbackMessage(e); | ||
| } catch (Exception e) { | ||
| // Retry 소진 후 최종 실패 → handleAiException으로 에러 분류 후 fallback | ||
| try { | ||
| handleAiException(e); | ||
| } catch (CustomException ce) { | ||
| return buildFallbackMessage(ce); | ||
| } | ||
| return "AI 서비스를 일시적으로 이용할 수 없습니다. 잠시 후 다시 시도해 주세요."; | ||
| } | ||
| } | ||
|
|
||
| // RuntimeException을 그대로 전파 → Retry가 재시도 여부 판단 가능 | ||
| // CustomException(429, 인증 오류 등)만 그대로 throw → Retry ignore-exceptions에서 제외 | ||
| private String executeAiCallWithTimeout(List<Message> messages, Map<String, Object> context) { | ||
| try { | ||
| return CompletableFuture.supplyAsync(() -> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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); | ||
| return null; | ||
| .content(), | ||
| aiExecutor // ForkJoinPool 공용 풀 대신 AI 전용 스레드풀 사용 | ||
| ).get(AI_TIMEOUT_SECONDS, TimeUnit.SECONDS); | ||
| } catch (TimeoutException e) { | ||
| // RuntimeException으로 전파 → Retry가 재시도, 최종 실패 시 callAi에서 CHAT_AI_TIMEOUT으로 분류 | ||
| log.warn("AI API 타임아웃 ({}초 초과)", AI_TIMEOUT_SECONDS); | ||
| throw new RuntimeException("AI API timed out", e); | ||
| } catch (ExecutionException e) { | ||
| Throwable cause = e.getCause(); | ||
| // CustomException(429, 인증 오류)은 그대로 throw → Retry ignore-exceptions 적용 | ||
| if (cause instanceof CustomException ce) throw ce; | ||
| // 그 외 실행 예외는 RuntimeException으로 전파 → Retry 재시도 대상 | ||
| if (cause instanceof RuntimeException re) throw re; | ||
| throw new RuntimeException(cause); | ||
| } catch (InterruptedException e) { | ||
| Thread.currentThread().interrupt(); | ||
| throw new RuntimeException("AI API interrupted", e); | ||
| } | ||
|
Comment on lines
+165
to
179
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
} catch (TimeoutException e) {
log.warn("AI API 타임아웃 ({}초 초과)", AI_TIMEOUT_SECONDS);
throw new RuntimeException("AI API call timed out", e);
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof RuntimeException re) throw re;
throw new RuntimeException(cause);
} 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<Message> buildMessages(List<ChatMessage> history, Long userId, String summarySuffix) { | ||
| List<Message> messages = new ArrayList<>(); | ||
| messages.add(new SystemMessage(buildSystemPrompt(summarySuffix))); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Resilience4j의
Retry가 정상적으로 동작하려면 일시적인 네트워크 오류나 5xx 에러 등의 예외가executeAiCallWithTimeout에서CustomException으로 즉시 변환되지 않고 그대로 전파되어야 합니다. 현재 구조에서는 모든 예외가executeAiCallWithTimeout내부에서CustomException으로 변환되어 던져지기 때문에,Retry설정에서CustomException을 제외할 경우 어떠한 재시도도 수행되지 않는 문제가 발생합니다.\n\n이를 해결하기 위해executeAiCallWithTimeout에서는 원본 예외를 그대로 던지도록 하고, Resilience4j 체인의 가장 바깥쪽인callAi에서 예외를 잡아handleAiException을 통해CustomException으로 변환한 뒤 fallback 메시지를 처리하도록 수정하는 것을 권장합니다.