Skip to content

Feat/#94 chatbot resilience - #95

Closed
docodocod wants to merge 2 commits into
mainfrom
feat/#94-chatbot-resilience
Closed

Feat/#94 chatbot resilience#95
docodocod wants to merge 2 commits into
mainfrom
feat/#94-chatbot-resilience

Conversation

@docodocod

Copy link
Copy Markdown
Contributor

📢 기능 설명

timelimiter / retry / berkhead 추가

필요시 실행결과 스크린샷 첨부

연결된 issue

연결된 issue를 자동을 닫기 위해 아래 {이슈넘버}를 입력해주세요.

close #94

✅ 체크리스트

  • PR 제목 규칙 잘 지켰는가?
  • 추가/수정사항을 설명하였는가?
  • 이슈넘버를 적었는가?

docodocod and others added 2 commits May 27, 2026 14:26
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>
@docodocod docodocod closed this May 27, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +138 to 160
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);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

⚠️ CompletableFuture 사용 시 스레드 고갈 및 작업 미취소 위험

현재 executeAiCallWithTimeout 메서드에서 10초 타임아웃을 구현하기 위해 CompletableFuture.supplyAsync를 사용하고 있으나, 아래와 같은 심각한 자원 및 성능 문제가 존재합니다.

  1. ForkJoinPool.commonPool() 고갈 위험 (Thread Starvation)
    • CompletableFuture.supplyAsync(Supplier)는 별도의 Executor를 지정하지 않으면 JVM 전체가 공유하는 공용 풀인 ForkJoinPool.commonPool()을 사용합니다.
    • LLM API 호출과 같은 외부 I/O 작업은 대표적인 블로킹 작업입니다. 동시 요청이 증가할 경우 공용 풀의 스레드가 모두 점유되어, 애플리케이션 내의 다른 병렬 작업(Parallel Stream 등)까지 대기 상태에 빠져 전체 시스템 성능이 급격히 저하될 수 있습니다.
  2. 타임아웃 발생 시 백그라운드 작업 미취소
    • .get(AI_TIMEOUT_SECONDS, TimeUnit.SECONDS)에서 TimeoutException이 발생하더라도, 백그라운드에서 실행 중인 비동기 작업은 자동으로 취소되지 않고 계속 실행됩니다. 이로 인해 불필요한 LLM API 호출이 끝까지 진행되어 비용 및 자원 낭비가 발생합니다.
  3. Resilience4j TimeLimiter 미사용
    • PR 설명에는 timelimiter를 추가했다고 명시되어 있으나, 실제 코드에서는 Resilience4j의 TimeLimiter 대신 수동으로 CompletableFuture를 제어하고 있습니다.

해결 방안

  • 외부 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);
        }
    }

Comment thread k6/04-circuit-breaker.js
Comment on lines +91 to +111
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,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

⚠️ 서킷 브레이커 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,
    });

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] AI 챗봇 안정성 강화 (Resilience4j

1 participant