Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 87 additions & 10 deletions src/main/java/com/catchtable/chatbot/service/ChatbotService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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 서비스를 일시적으로 이용할 수 없습니다. 잠시 후 다시 시도해 주세요.";
}
Comment on lines 121 to +149

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

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 서비스를 일시적으로 이용할 수 없습니다. 잠시 후 다시 시도해 주세요.";
        }

}

// RuntimeException을 그대로 전파 → Retry가 재시도 여부 판단 가능
// CustomException(429, 인증 오류 등)만 그대로 throw → Retry ignore-exceptions에서 제외
private String executeAiCallWithTimeout(List<Message> messages, Map<String, Object> context) {
try {
return CompletableFuture.supplyAsync(() ->

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.supplyAsync() 호출 시 별도의 Executor를 지정하지 않으면 JVM 전체에서 공유되는 ForkJoinPool.commonPool()을 사용하게 됩니다.\n\n외부 AI API 호출과 같은 블로킹 I/O 작업을 공용 풀에서 실행하면, AI 호출 지연 시 공용 풀의 스레드가 모두 고갈되어 애플리케이션 내의 다른 비동기 작업(예: 병렬 스트림, 기타 CompletableFuture 등)까지 함께 멈추는 스레드 고갈(Thread Starvation) 현상이 발생할 수 있습니다.\n\n따라서 AI 호출 전용의 ThreadPoolTaskExecutor 빈을 정의하고, 이를 주입받아 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);
}
Comment on lines +165 to 179

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

executeAiCallWithTimeout 내부에서 발생하는 TimeoutExceptionExecutionException을 즉시 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);
        }

}

// 각 에러 유형별 사용자 노출 메시지 — 예외를 던지지 않고 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)));
Expand Down
Loading