Conversation
k6 시나리오 테스트: 매장 탐색, 예약 동시성, 전체 플로우, 서킷브레이커 k6 단일 API 테스트: 매장 목록, PostGIS 지리 쿼리, 잔여석 조회, 쿠폰 선착순 발급 k6 내구성 테스트: 10VU x 30분 Soak 테스트 (메모리 누수, 커넥션풀 고갈 탐지) run.ps1: 테스트 번호로 실행하는 래퍼 스크립트 (01~08, 10) Grafana 백엔드 모니터링 대시보드 JSON (수동 import용) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Enum 매핑 수정 Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Enum 매핑 수정 Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
- timeout 문자열을 ms 숫자로 수정 (65s→65000, 15s→15000) - UTC→KST 날짜 변환 적용 (01, 03, 07, 10번 스크립트) - res.json() try-catch 안전 처리 (02번 - 502/503 HTML 응답 대비) - category 필터 한글→영문 Enum 값으로 수정 (03번) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat: k6 부하테스트 스크립트 및 Grafana 대시보드 추가
…Fallback) - Bulkhead: 동시 AI 호출 최대 20개 제한, 초과 시 즉시 거부 - Retry: 일시적 오류 자동 재시도 (CustomException 제외로 429/인증 오류 재시도 방지) - TimeLimiter: CompletableFuture 10초 타임아웃 적용 - Fallback: 예외 던지는 대신 에러 유형별 사용자 친화적 메시지 반환 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 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>
feat: ChatbotService Resilience4j 고도화 (Bulkhead, Retry, TimeLimiter, …
There was a problem hiding this comment.
Code Review
This pull request introduces comprehensive k6 load testing scripts, Grafana dashboards, and robust scheduling mechanisms to manage reservation slots in a 30-day rolling window, preventing database storage exhaustion. It also integrates Resilience4j Bulkhead, Retry, and CircuitBreaker patterns into the ChatbotService with a dedicated thread pool. The review feedback highlights critical improvements for the resilience implementation: correcting the Resilience4j decorator ordering to prevent unnecessary retries when the circuit is open, explicitly cancelling the CompletableFuture on timeout to avoid thread pool starvation, and ensuring the dedicated thread pool is gracefully shut down on application context destruction to prevent thread leaks.
| return aiBulkhead.executeSupplier(() -> | ||
| aiRetry.executeSupplier(() -> | ||
| aiCircuitBreaker.executeSupplier(() -> | ||
| executeAiCallWithTimeout(messages, context) | ||
| ) | ||
| ) | ||
| ); |
There was a problem hiding this comment.
Resilience4j 데코레이터의 적용 순서가 올바르지 않습니다. 현재는 aiRetry가 aiCircuitBreaker를 감싸고 있어 다음과 같은 문제가 발생할 수 있습니다:
- 서킷브레이커 OPEN 상태에서의 불필요한 재시도: 서킷브레이커가
OPEN상태가 되어CallNotPermittedException을 던질 때, 외부에 있는aiRetry가 이를 감싸고 있으므로 설정된 최대 재시도 횟수만큼 불필요하게 재시도를 반복하게 됩니다. 이는 빠른 실패(Fail-Fast)라는 서킷브레이커의 목적에 어긋나며 시스템 자원을 낭비합니다. - 서킷브레이커의 조기 차단(Tripping):
aiCircuitBreaker가 내부에 있으면, 단 한 번의 사용자 요청 내에서 발생하는 모든 개별 재시도 실패가 서킷브레이커에 각각의 실패로 기록됩니다. 이로 인해 서킷브레이커가 의도보다 훨씬 빠르게OPEN상태로 전환될 수 있습니다.
따라서 aiCircuitBreaker가 aiRetry를 감싸도록 순서를 변경해야 합니다. 이렇게 하면 서킷브레이커가 OPEN일 때는 재시도 없이 즉시 예외를 반환하고, CLOSED 상태일 때는 재시도 끝에 최종 실패한 경우에만 서킷브레이커에 1회의 실패로 기록됩니다.
| return aiBulkhead.executeSupplier(() -> | |
| aiRetry.executeSupplier(() -> | |
| aiCircuitBreaker.executeSupplier(() -> | |
| executeAiCallWithTimeout(messages, context) | |
| ) | |
| ) | |
| ); | |
| return aiBulkhead.executeSupplier(() -> | |
| aiCircuitBreaker.executeSupplier(() -> | |
| aiRetry.executeSupplier(() -> | |
| executeAiCallWithTimeout(messages, context) | |
| ) | |
| ) | |
| ); |
| private String executeAiCallWithTimeout(List<Message> messages, Map<String, Object> 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); | ||
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
CompletableFuture.supplyAsync(...).get(timeout)을 사용하여 타임아웃을 구현할 때, TimeoutException이 발생하더라도 백그라운드에서 실행 중인 비동기 작업(AI 호출)은 자동으로 취소되지 않고 계속 실행됩니다.
만약 외부 AI API가 응답하지 않거나 매우 느려져 타임아웃이 빈번하게 발생하면, 취소되지 않은 작업들이 aiExecutor 스레드 풀의 스레드를 계속 점유하게 되어 결국 스레드 풀 고갈(Thread Pool Starvation) 및 BulkheadFullException으로 이어지게 됩니다.
따라서 TimeoutException 및 InterruptedException 발생 시 future.cancel(true)를 호출하여 실행 중인 비동기 작업을 명시적으로 취소해 주어야 합니다.
private String executeAiCallWithTimeout(List<Message> messages, Map<String, Object> context) {
CompletableFuture<String> future = CompletableFuture.supplyAsync(() ->
chatClient.prompt()
.messages(messages)
.tools(reservationService, couponService, storeService, storeRemainService)
.toolContext(context)
.call()
.content(),
aiExecutor
);
try {
return future.get(AI_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true);
log.warn("AI API 타임아웃 ({}초 초과)", AI_TIMEOUT_SECONDS);
throw new RuntimeException("AI API timed out", e);
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof CustomException ce) throw ce;
if (cause instanceof RuntimeException re) throw re;
throw new RuntimeException(cause);
} catch (InterruptedException e) {
future.cancel(true);
Thread.currentThread().interrupt();
throw new RuntimeException("AI API interrupted", e);
}
}| 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); | ||
| } |
There was a problem hiding this comment.
Executors.newFixedThreadPool을 통해 생성된 스레드 풀은 Spring 컨테이너에 의해 수명이 관리되지 않습니다. 따라서 애플리케이션 컨텍스트가 종료되거나 재시작될 때(특히 테스트 환경이나 핫 리로드 시) 스레드 풀이 적절히 종료되지 않아 **스레드 누수(Thread Leak)**가 발생할 수 있습니다.
aiExecutor를 ExecutorService 타입으로 선언하고, @PreDestroy 어노테이션을 사용해 애플리케이션 종료 시 스레드 풀을 정상적으로 종료(Graceful Shutdown)하도록 개선하는 것이 안전합니다.
private final StoreService storeService;
private final StoreRemainService storeRemainService;
private final CircuitBreaker aiCircuitBreaker;
private final Bulkhead aiBulkhead;
private final Retry aiRetry;
// ForkJoinPool 공용 풀 대신 AI 호출 전용 스레드풀 사용
private final java.util.concurrent.ExecutorService aiExecutor;
public ChatbotService(ChatClient chatClient, ChatbotDbService dbService,
ReservationService reservationService, CouponService couponService,
StoreService storeService, StoreRemainService storeRemainService,
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);
}
@jakarta.annotation.PreDestroy
public void destroy() {
aiExecutor.shutdown();
try {
if (!aiExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
aiExecutor.shutdownNow();
}
} catch (InterruptedException e) {
aiExecutor.shutdownNow();
Thread.currentThread().interrupt();
}
}
📢 기능 설명
필요시 실행결과 스크린샷 첨부
연결된 issue
연결된 issue를 자동을 닫기 위해 아래 {이슈넘버}를 입력해주세요.
close #{이슈넘버}
✅ 체크리스트