feat: ChatbotService Resilience4j 고도화 (Bulkhead, Retry, TimeLimiter, … - #96
Conversation
…Fallback) - Bulkhead: 동시 AI 호출 최대 20개 제한, 초과 시 즉시 거부 - Retry: 일시적 오류 자동 재시도 (CustomException 제외로 429/인증 오류 재시도 방지) - TimeLimiter: CompletableFuture 10초 타임아웃 적용 - Fallback: 예외 던지는 대신 에러 유형별 사용자 친화적 메시지 반환 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request integrates Resilience4j's Bulkhead and Retry mechanisms alongside the existing CircuitBreaker to make the AI chatbot service more resilient, and introduces a 10-second timeout using CompletableFuture. The review feedback highlights critical issues with exception handling and thread management: wrapping exceptions into CustomException too early inside the timeout helper prevents the Retry mechanism from evaluating the original exception types (such as TimeoutException), and using the default ForkJoinPool.commonPool() for blocking AI API calls risks thread starvation across the application. It is highly recommended to propagate original exceptions to the outer Resilience4j chain and to configure a dedicated thread pool for the asynchronous AI calls.
| 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 서비스를 일시적으로 이용할 수 없습니다. 잠시 후 다시 시도해 주세요."; | ||
| } |
There was a problem hiding this comment.
Resilience4j의 Retry가 정상적으로 동작하려면 일시적인 네트워크 오류나 5xx 에러 등의 예외가 executeAiCallWithTimeout에서 CustomException으로 즉시 변환되지 않고 그대로 전파되어야 합니다. 현재 구조에서는 모든 예외가 executeAiCallWithTimeout 내부에서 CustomException으로 변환되어 던져지기 때문에, Retry 설정에서 CustomException을 제외할 경우 어떠한 재시도도 수행되지 않는 문제가 발생합니다.\n\n이를 해결하기 위해 executeAiCallWithTimeout에서는 원본 예외를 그대로 던지도록 하고, Resilience4j 체인의 가장 바깥쪽인 callAi에서 예외를 잡아 handleAiException을 통해 CustomException으로 변환한 뒤 fallback 메시지를 처리하도록 수정하는 것을 권장합니다.
try {
// 적용 순서: 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) {
try {
handleAiException(e);
} catch (CustomException ce) {
return buildFallbackMessage(ce);
}
return "AI 서비스를 일시적으로 이용할 수 없습니다. 잠시 후 다시 시도해 주세요.";
}| } 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); | ||
| } |
There was a problem hiding this comment.
executeAiCallWithTimeout 내부에서 발생하는 TimeoutException 및 ExecutionException을 즉시 CustomException으로 변환하여 던지면 Resilience4j의 Retry 메커니즘이 예외 타입을 기반으로 재시도 여부를 판단할 수 없게 됩니다.\n\n특히 타임아웃(TimeoutException)은 일시적인 네트워크 지연 등으로 인해 발생할 수 있어 재시도의 핵심 대상이 되어야 합니다. 따라서 이 메서드에서는 원본 예외(또는 표준 RuntimeException)를 그대로 던져 Retry가 동작할 수 있도록 하고, 최종 실패 시에만 상위 호출부(callAi)에서 CustomException으로 변환하도록 수정해야 합니다.
} 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);
}| // 10초 타임아웃 적용 — 초과 시 CHAT_AI_TIMEOUT 예외 (Retry 제외 대상) | ||
| private String executeAiCallWithTimeout(List<Message> messages, Map<String, Object> context) { | ||
| try { | ||
| return CompletableFuture.supplyAsync(() -> |
There was a problem hiding this comment.
CompletableFuture.supplyAsync() 호출 시 별도의 Executor를 지정하지 않으면 JVM 전체에서 공유되는 ForkJoinPool.commonPool()을 사용하게 됩니다.\n\n외부 AI API 호출과 같은 블로킹 I/O 작업을 공용 풀에서 실행하면, AI 호출 지연 시 공용 풀의 스레드가 모두 고갈되어 애플리케이션 내의 다른 비동기 작업(예: 병렬 스트림, 기타 CompletableFuture 등)까지 함께 멈추는 스레드 고갈(Thread Starvation) 현상이 발생할 수 있습니다.\n\n따라서 AI 호출 전용의 ThreadPoolTaskExecutor 빈을 정의하고, 이를 주입받아 supplyAsync의 두 번째 인자로 전달하여 격리된 스레드 풀에서 실행되도록 개선하는 것을 강력히 권장합니다.
- executeAiCallWithTimeout()에서 RuntimeException throw로 변경하여 Retry 동작 보장 - CompletableFuture.supplyAsync()에 전용 Executor(newFixedThreadPool(20)) 주입으로 ForkJoinPool 스레드 기아 해결 - callAi() catch(Exception)에서 handleAiException() 호출 후 CustomException 변환 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
📢 기능 설명
필요시 실행결과 스크린샷 첨부
연결된 issue
연결된 issue를 자동을 닫기 위해 아래 {이슈넘버}를 입력해주세요.
close #94
✅ 체크리스트