Feat/#94 chatbot resilience - #95
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>
…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 introduces a comprehensive suite of k6 load testing scripts, Grafana dashboards for monitoring, and integrates Resilience4j Bulkhead and Retry patterns alongside CircuitBreaker in the ChatbotService. Feedback highlights critical issues in the chatbot service, specifically the risk of thread starvation from using the default ForkJoinPool for blocking AI calls and the failure to cancel background tasks on timeout. Additionally, a bug was identified in the circuit breaker test script where fallback responses are incorrectly counted as successful requests.
| 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); | ||
| ).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); | ||
| } | ||
| } |
There was a problem hiding this comment.
⚠️ CompletableFuture 사용 시 스레드 고갈 및 작업 미취소 위험
현재 executeAiCallWithTimeout 메서드에서 10초 타임아웃을 구현하기 위해 CompletableFuture.supplyAsync를 사용하고 있으나, 아래와 같은 심각한 자원 및 성능 문제가 존재합니다.
- ForkJoinPool.commonPool() 고갈 위험 (Thread Starvation)
CompletableFuture.supplyAsync(Supplier)는 별도의Executor를 지정하지 않으면 JVM 전체가 공유하는 공용 풀인ForkJoinPool.commonPool()을 사용합니다.- LLM API 호출과 같은 외부 I/O 작업은 대표적인 블로킹 작업입니다. 동시 요청이 증가할 경우 공용 풀의 스레드가 모두 점유되어, 애플리케이션 내의 다른 병렬 작업(Parallel Stream 등)까지 대기 상태에 빠져 전체 시스템 성능이 급격히 저하될 수 있습니다.
- 타임아웃 발생 시 백그라운드 작업 미취소
.get(AI_TIMEOUT_SECONDS, TimeUnit.SECONDS)에서TimeoutException이 발생하더라도, 백그라운드에서 실행 중인 비동기 작업은 자동으로 취소되지 않고 계속 실행됩니다. 이로 인해 불필요한 LLM API 호출이 끝까지 진행되어 비용 및 자원 낭비가 발생합니다.
- Resilience4j TimeLimiter 미사용
- PR 설명에는
timelimiter를 추가했다고 명시되어 있으나, 실제 코드에서는 Resilience4j의TimeLimiter대신 수동으로CompletableFuture를 제어하고 있습니다.
- PR 설명에는
해결 방안
- 외부 I/O 전용으로 구성된 별도의
ThreadPoolTaskExecutor를 빈으로 등록하여 주입받아 사용해야 합니다. - 타임아웃이나 인터럽트 발생 시
future.cancel(true)를 호출하여 백그라운드 작업을 명시적으로 취소해야 합니다. - 또는 Resilience4j의
TimeLimiter를 활용하여 일관된 회복력 패턴을 적용하는 것을 권장합니다.
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()
); // TODO: 외부 I/O 작업이므로 commonPool 대신 전용 Executor를 주입받아 사용하세요.
try {
return future.get(AI_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true); // 타임아웃 발생 시 백그라운드 작업 취소 시도
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) {
future.cancel(true); // 인터럽트 발생 시 백그라운드 작업 취소 시도
Thread.currentThread().interrupt();
throw new CustomException(ErrorCode.CHAT_AI_ERROR);
}
}| const isSuccess = res.status === 200 || res.status === 201; | ||
| const isFastFail = res.timings.duration < CIRCUIT_OPEN_THRESHOLD_MS && !isSuccess; | ||
|
|
||
| if (isFastFail) { | ||
| // 빠른 실패 = 서킷 OPEN 상태 | ||
| circuitOpenCount.add(1); | ||
| llmErrorRate.add(true); | ||
| console.log(`[서킷 OPEN] ${res.timings.duration.toFixed(0)}ms - status: ${res.status}`); | ||
| } else if (isSuccess) { | ||
| circuitClosedCount.add(1); | ||
| llmErrorRate.add(false); | ||
| } else { | ||
| // 느린 실패 (LLM 타임아웃 등) | ||
| llmErrorRate.add(true); | ||
| console.log(`[LLM 실패] ${res.timings.duration.toFixed(0)}ms - status: ${res.status} - ${res.body?.substring(0, 100)}`); | ||
| } | ||
|
|
||
| check(res, { | ||
| '서킷 OPEN (즉시 차단)': () => isFastFail, | ||
| '정상 응답 (서킷 CLOSED)': () => isSuccess, | ||
| }); |
There was a problem hiding this comment.
⚠️ 서킷 브레이커 OPEN 상태 감지 로직 오류 (테스트 신뢰성 문제)
ChatbotService.java의 예외 처리 로직을 보면, 서킷 브레이커가 OPEN 상태이거나 Bulkhead가 가득 찼을 때 예외를 외부로 던지지 않고 "AI 서비스가 일시적으로 점검 중입니다..."와 같은 대체 텍스트(Fallback)를 정상적인 200 OK 응답으로 반환하고 있습니다.
하지만 현재 k6 스크립트에서는 단순히 res.status === 200인 경우 무조건 성공(isSuccess = true)으로 판단하고 있습니다. 이로 인해 서킷 브레이커가 열려 대체 텍스트가 반환되는 상황에서도 이를 정상 응답(circuitClosedCount)으로 집계하게 되어, 부하 테스트 결과에서 서킷 브레이커 작동 여부를 올바르게 모니터링할 수 없습니다.
해결 방안
응답 바디(res.body)에 대체 텍스트가 포함되어 있는지 여부를 함께 체크하여 서킷 OPEN 상태를 판별하도록 수정해야 합니다.
const isFallback = res.body && res.body.includes("점검 중입니다");
const isSuccess = (res.status === 200 || res.status === 201) && !isFallback;
const isFastFail = (res.timings.duration < CIRCUIT_OPEN_THRESHOLD_MS && !isSuccess) || isFallback;
if (isFastFail) {
// 빠른 실패 또는 대체 응답 = 서킷 OPEN 상태
circuitOpenCount.add(1);
llmErrorRate.add(true);
console.log('[서킷 OPEN/대체 응답] ' + res.timings.duration.toFixed(0) + 'ms - status: ' + res.status);
} else if (isSuccess) {
circuitClosedCount.add(1);
llmErrorRate.add(false);
} else {
// 느린 실패 (LLM 타임아웃 등)
llmErrorRate.add(true);
console.log('[LLM 실패] ' + res.timings.duration.toFixed(0) + 'ms - status: ' + res.status + ' - ' + (res.body ? res.body.substring(0, 100) : ""));
}
check(res, {
'서킷 OPEN (즉시 차단/대체 응답)': () => isFastFail,
'정상 응답 (서킷 CLOSED)': () => isSuccess,
});
📢 기능 설명
timelimiter / retry / berkhead 추가
필요시 실행결과 스크린샷 첨부
연결된 issue
연결된 issue를 자동을 닫기 위해 아래 {이슈넘버}를 입력해주세요.
close #94
✅ 체크리스트