diff --git a/src/main/java/com/sofa/linkiving/domain/chat/ai/RagAnswerClient.java b/src/main/java/com/sofa/linkiving/domain/chat/ai/RagAnswerClient.java index 6fe928c8..a9f80cfa 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/ai/RagAnswerClient.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/ai/RagAnswerClient.java @@ -7,6 +7,9 @@ import com.sofa.linkiving.domain.chat.dto.request.RagAnswerReq; import com.sofa.linkiving.domain.chat.dto.response.RagAnswerRes; +import com.sofa.linkiving.global.error.exception.BusinessException; +import com.sofa.linkiving.infra.feign.EmptyAiResponseException; +import com.sofa.linkiving.infra.feign.ExternalApiErrorCode; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; @@ -43,23 +46,26 @@ private Counter buildCounter(String result) { @Override public RagAnswerRes generateAnswer(RagAnswerReq request) { + List ragAnswerRes; try { - List ragAnswerRes = ragAnswerFeign.generateAnswer(request); - - if (ragAnswerRes == null || ragAnswerRes.isEmpty()) { - log.warn("RagAnswerClient generateAnswer empty response"); - emptyCounter.increment(); - return null; - } - - log.info("RagAnswerClient generateAnswer ragAnswerRes={}", ragAnswerRes); - successCounter.increment(); - return ragAnswerRes.get(0); - + ragAnswerRes = ragAnswerFeign.generateAnswer(request); + } catch (BusinessException e) { + failureCounter.increment(); + log.warn("[AI Server] generateAnswer failed - code={}", e.getErrorCode().getCode()); + throw e; } catch (Exception e) { - log.error("RagAnswerClient generateAnswer error", e); failureCounter.increment(); - return null; + log.warn("[AI Server] generateAnswer failed - reason={}", e.getMessage()); + throw new BusinessException(ExternalApiErrorCode.EXTERNAL_API_COMMUNICATION_ERROR); } + + if (ragAnswerRes == null || ragAnswerRes.isEmpty()) { + emptyCounter.increment(); + log.warn("[AI Server] generateAnswer empty response"); + throw new EmptyAiResponseException(); + } + + successCounter.increment(); + return ragAnswerRes.get(0); } } diff --git a/src/main/java/com/sofa/linkiving/domain/link/ai/RagSummaryClient.java b/src/main/java/com/sofa/linkiving/domain/link/ai/RagSummaryClient.java index 752dc4fa..1e30beb8 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/ai/RagSummaryClient.java +++ b/src/main/java/com/sofa/linkiving/domain/link/ai/RagSummaryClient.java @@ -9,6 +9,9 @@ import com.sofa.linkiving.domain.link.dto.request.RagRegenerateSummaryReq; import com.sofa.linkiving.domain.link.dto.response.RagInitialSummaryRes; import com.sofa.linkiving.domain.link.dto.response.RagRegenerateSummaryRes; +import com.sofa.linkiving.global.error.exception.BusinessException; +import com.sofa.linkiving.infra.feign.EmptyAiResponseException; +import com.sofa.linkiving.infra.feign.ExternalApiErrorCode; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; @@ -51,47 +54,53 @@ private Counter buildCounter(String operation, String result) { @Override public RagInitialSummaryRes initialSummary(Long linkId, Long userId, String title, String url, String memo) { + List response; try { RagInitialSummaryReq req = new RagInitialSummaryReq(linkId, userId, title, url, memo); - List response = ragSummaryFeign.requestInitialSummary(req); - - if (response != null && !response.isEmpty()) { - log.info("[AI Server] Initial Summary Requested Success. LinkId: {}", linkId); - initialSuccess.increment(); - return response.get(0); - } - - initialEmpty.increment(); - return null; - + response = ragSummaryFeign.requestInitialSummary(req); + } catch (BusinessException e) { + initialFailure.increment(); + log.warn("[AI Server] Initial summary failed - linkId={}, code={}", linkId, e.getErrorCode().getCode()); + throw e; } catch (Exception e) { - log.error("[AI Server Error] Failed to request initial summary for LinkId: {}. Error: {}", linkId, - e.getMessage()); initialFailure.increment(); - return null; + log.warn("[AI Server] Initial summary failed - linkId={}, reason={}", linkId, e.getMessage()); + throw new BusinessException(ExternalApiErrorCode.EXTERNAL_API_COMMUNICATION_ERROR); } + + if (response == null || response.isEmpty()) { + initialEmpty.increment(); + log.warn("[AI Server] Initial summary empty response - linkId={}", linkId); + throw new EmptyAiResponseException(); + } + + initialSuccess.increment(); + return response.get(0); } @Override public RagRegenerateSummaryRes regenerateSummary(Long linkId, Long userId, String url, String existingSummary) { + List response; try { RagRegenerateSummaryReq req = new RagRegenerateSummaryReq(linkId, userId, url, existingSummary); - List response = ragSummaryFeign.requestRegenerateSummary(req); - - if (response != null && !response.isEmpty()) { - log.info("[AI Server] Regenerate Summary Success. LinkId: {}", linkId); - regenerateSuccess.increment(); - return response.get(0); - } - - regenerateEmpty.increment(); - return null; - + response = ragSummaryFeign.requestRegenerateSummary(req); + } catch (BusinessException e) { + regenerateFailure.increment(); + log.warn("[AI Server] Regenerate summary failed - linkId={}, code={}", linkId, e.getErrorCode().getCode()); + throw e; } catch (Exception e) { - log.error("[AI Server Error] Failed to regenerate summary for LinkId: {}. Error: {}", linkId, - e.getMessage()); regenerateFailure.increment(); - return null; + log.warn("[AI Server] Regenerate summary failed - linkId={}, reason={}", linkId, e.getMessage()); + throw new BusinessException(ExternalApiErrorCode.EXTERNAL_API_COMMUNICATION_ERROR); } + + if (response == null || response.isEmpty()) { + regenerateEmpty.increment(); + log.warn("[AI Server] Regenerate summary empty response - linkId={}", linkId); + throw new EmptyAiResponseException(); + } + + regenerateSuccess.increment(); + return response.get(0); } } diff --git a/src/main/java/com/sofa/linkiving/domain/link/worker/SummaryWorker.java b/src/main/java/com/sofa/linkiving/domain/link/worker/SummaryWorker.java index f6963117..5dd61f61 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/worker/SummaryWorker.java +++ b/src/main/java/com/sofa/linkiving/domain/link/worker/SummaryWorker.java @@ -20,6 +20,7 @@ import com.sofa.linkiving.domain.link.event.SummaryStatusEvent; import com.sofa.linkiving.domain.link.facade.SummaryWorkerFacade; import com.sofa.linkiving.global.logging.LogContext; +import com.sofa.linkiving.infra.feign.EmptyAiResponseException; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; @@ -95,8 +96,6 @@ private void processQueue() throws InterruptedException { String userEmail = null; log.info("Processing link for summary - linkId: {}", linkId); - generateFailureCounter.increment(); - try { Link link = summaryWorkerFacade.getLinkWithMember(linkId); userEmail = link.getMember().getEmail(); @@ -123,6 +122,7 @@ private void processQueue() throws InterruptedException { )); } } catch (Exception e) { + generateFailureCounter.increment(); log.error("Failed to generate summary for linkId: {}", linkId, e); try { @@ -146,25 +146,20 @@ private void processQueue() throws InterruptedException { } @Retryable( - value = {Exception.class}, + retryFor = {Exception.class}, + noRetryFor = {EmptyAiResponseException.class}, maxAttempts = 3, backoff = @Backoff(delay = 2000) ) public RagInitialSummaryRes callAiServerWithRetry(Link link) { log.info("Attempting summary request to AI server - linkId: {}", link.getId()); - RagInitialSummaryRes res = summaryClient.initialSummary( + return summaryClient.initialSummary( link.getId(), link.getMember().getId(), link.getTitle(), link.getUrl(), link.getMemo() ); - - if (res == null) { - throw new RuntimeException("Received a null response from the AI server."); - } - - return res; } } diff --git a/src/main/java/com/sofa/linkiving/global/config/AsyncConfig.java b/src/main/java/com/sofa/linkiving/global/config/AsyncConfig.java index 5ec86e63..5192db05 100644 --- a/src/main/java/com/sofa/linkiving/global/config/AsyncConfig.java +++ b/src/main/java/com/sofa/linkiving/global/config/AsyncConfig.java @@ -1,6 +1,7 @@ package com.sofa.linkiving.global.config; import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -24,11 +25,19 @@ public AsyncConfig(TaskDecorator taskDecorator) { @Bean public ThreadPoolTaskExecutor applicationTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(10); - executor.setMaxPoolSize(50); - executor.setQueueCapacity(200); + + executor.setCorePoolSize(5); + executor.setMaxPoolSize(20); + executor.setQueueCapacity(100); executor.setThreadNamePrefix("async-"); + + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + executor.setTaskDecorator(taskDecorator); + executor.initialize(); return executor; } diff --git a/src/main/java/com/sofa/linkiving/global/fillter/RequestLoggingFilter.java b/src/main/java/com/sofa/linkiving/global/fillter/RequestLoggingFilter.java index d176165e..034b75cf 100644 --- a/src/main/java/com/sofa/linkiving/global/fillter/RequestLoggingFilter.java +++ b/src/main/java/com/sofa/linkiving/global/fillter/RequestLoggingFilter.java @@ -58,7 +58,8 @@ public class RequestLoggingFilter extends OncePerRequestFilter { "/favicon.ico", "/swagger", "/v3/api-docs", - "/health-check" + "/health-check", + "/h2-console" }; private static final int MAX_BODY_LENGTH = 2000; diff --git a/src/main/java/com/sofa/linkiving/infra/feign/EmptyAiResponseException.java b/src/main/java/com/sofa/linkiving/infra/feign/EmptyAiResponseException.java new file mode 100644 index 00000000..4541aed8 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/infra/feign/EmptyAiResponseException.java @@ -0,0 +1,9 @@ +package com.sofa.linkiving.infra.feign; + +import com.sofa.linkiving.global.error.exception.BusinessException; + +public class EmptyAiResponseException extends BusinessException { + public EmptyAiResponseException() { + super(ExternalApiErrorCode.EXTERNAL_API_INVALID_RESPONSE); + } +} diff --git a/src/test/java/com/sofa/linkiving/domain/chat/ai/RagAnswerClientTest.java b/src/test/java/com/sofa/linkiving/domain/chat/ai/RagAnswerClientTest.java index 24413f65..9caae54d 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/ai/RagAnswerClientTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/ai/RagAnswerClientTest.java @@ -17,6 +17,9 @@ import com.sofa.linkiving.domain.chat.dto.request.RagAnswerReq; import com.sofa.linkiving.domain.chat.dto.response.RagAnswerRes; +import com.sofa.linkiving.global.error.exception.BusinessException; +import com.sofa.linkiving.infra.feign.EmptyAiResponseException; +import com.sofa.linkiving.infra.feign.ExternalApiErrorCode; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; @@ -60,34 +63,32 @@ void shouldReturnFirstElement_WhenGenerateAnswerSuccess() { } @Test - @DisplayName("Feign 요청 중 예외가 발생하면 예외를 잡고 null을 반환한다") - void shouldCatchExceptionAndReturnNull_WhenGenerateAnswerThrowsException() { + @DisplayName("Feign 요청 중 예외가 발생하면 통신 오류 예외로 전환하고 failure 로 집계한다") + void shouldThrowCommunicationError_WhenGenerateAnswerThrowsException() { // given RagAnswerReq req = mock(RagAnswerReq.class); given(ragAnswerFeign.generateAnswer(any(RagAnswerReq.class))) .willThrow(new RuntimeException("AI Server Error")); - // when - RagAnswerRes actualRes = ragAnswerClient.generateAnswer(req); - - // then - assertThat(actualRes).isNull(); + // when & then + assertThatThrownBy(() -> ragAnswerClient.generateAnswer(req)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ExternalApiErrorCode.EXTERNAL_API_COMMUNICATION_ERROR); assertThat(counterCount("failure")).isEqualTo(1.0); } @Test - @DisplayName("Feign 응답이 비어있으면 null 을 반환하고 empty 로 집계한다") - void shouldReturnNullAndCountEmpty_WhenResponseIsEmpty() { + @DisplayName("Feign 응답이 비어있으면 EmptyAiResponseException 을 던지고 empty 로 집계한다") + void shouldThrowEmptyResponse_WhenResponseIsEmpty() { // given RagAnswerReq req = mock(RagAnswerReq.class); given(ragAnswerFeign.generateAnswer(any(RagAnswerReq.class))) .willReturn(Collections.emptyList()); - // when - RagAnswerRes actualRes = ragAnswerClient.generateAnswer(req); - - // then - assertThat(actualRes).isNull(); + // when & then + assertThatThrownBy(() -> ragAnswerClient.generateAnswer(req)) + .isInstanceOf(EmptyAiResponseException.class); assertThat(counterCount("empty")).isEqualTo(1.0); } } diff --git a/src/test/java/com/sofa/linkiving/domain/link/ai/RagSummaryClientTest.java b/src/test/java/com/sofa/linkiving/domain/link/ai/RagSummaryClientTest.java index 124ac3c4..b4b0f79c 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/ai/RagSummaryClientTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/ai/RagSummaryClientTest.java @@ -19,6 +19,9 @@ import com.sofa.linkiving.domain.link.dto.request.RagRegenerateSummaryReq; import com.sofa.linkiving.domain.link.dto.response.RagInitialSummaryRes; import com.sofa.linkiving.domain.link.dto.response.RagRegenerateSummaryRes; +import com.sofa.linkiving.global.error.exception.BusinessException; +import com.sofa.linkiving.infra.feign.EmptyAiResponseException; +import com.sofa.linkiving.infra.feign.ExternalApiErrorCode; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; @@ -74,32 +77,30 @@ void shouldReturnInitialSummaryResWhenSuccess() { } @Test - @DisplayName("최초 요약 요청 시 응답이 비어있으면 null을 반환한다") - void shouldReturnNullWhenInitialSummaryResponseIsEmpty() { + @DisplayName("최초 요약 요청 시 응답이 비어있으면 EmptyAiResponseException 을 던지고 empty 로 집계한다") + void shouldThrowEmptyResponse_WhenInitialSummaryResponseIsEmpty() { // given given(ragSummaryFeign.requestInitialSummary(any(RagInitialSummaryReq.class))) .willReturn(Collections.emptyList()); - // when - RagInitialSummaryRes result = ragSummaryClient.initialSummary(1L, 100L, "Title", "URL", "Memo"); - - // then - assertThat(result).isNull(); + // when & then + assertThatThrownBy(() -> ragSummaryClient.initialSummary(1L, 100L, "Title", "URL", "Memo")) + .isInstanceOf(EmptyAiResponseException.class); assertThat(counterCount("initial", "empty")).isEqualTo(1.0); } @Test - @DisplayName("최초 요약 요청 중 예외 발생 시 로그를 남기고 null을 반환한다") - void shouldReturnNullWhenInitialSummaryThrowsException() { + @DisplayName("최초 요약 요청 중 예외 발생 시 통신 오류 예외로 전환하고 failure 로 집계한다") + void shouldThrowCommunicationError_WhenInitialSummaryThrowsException() { // given given(ragSummaryFeign.requestInitialSummary(any(RagInitialSummaryReq.class))) .willThrow(new RuntimeException("AI Server Error")); - // when - RagInitialSummaryRes result = ragSummaryClient.initialSummary(1L, 100L, "Title", "URL", "Memo"); - - // then - assertThat(result).isNull(); + // when & then + assertThatThrownBy(() -> ragSummaryClient.initialSummary(1L, 100L, "Title", "URL", "Memo")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ExternalApiErrorCode.EXTERNAL_API_COMMUNICATION_ERROR); assertThat(counterCount("initial", "failure")).isEqualTo(1.0); } @@ -130,17 +131,30 @@ void shouldReturnRegenerateSummaryResWhenSuccess() { } @Test - @DisplayName("요약 재생성 요청 중 예외 발생 시 null을 반환한다") - void shouldReturnNullWhenRegenerateSummaryThrowsException() { + @DisplayName("요약 재생성 요청 중 예외 발생 시 통신 오류 예외로 전환하고 failure 로 집계한다") + void shouldThrowCommunicationError_WhenRegenerateSummaryThrowsException() { // given given(ragSummaryFeign.requestRegenerateSummary(any(RagRegenerateSummaryReq.class))) .willThrow(new RuntimeException("Connection Timeout")); - // when - RagRegenerateSummaryRes result = ragSummaryClient.regenerateSummary(1L, 100L, "URL", "Old"); - - // then - assertThat(result).isNull(); + // when & then + assertThatThrownBy(() -> ragSummaryClient.regenerateSummary(1L, 100L, "URL", "Old")) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ExternalApiErrorCode.EXTERNAL_API_COMMUNICATION_ERROR); assertThat(counterCount("regenerate", "failure")).isEqualTo(1.0); } + + @Test + @DisplayName("요약 재생성 요청 시 응답이 비어있으면 EmptyAiResponseException 을 던지고 empty 로 집계한다") + void shouldThrowEmptyResponse_WhenRegenerateSummaryResponseIsEmpty() { + // given + given(ragSummaryFeign.requestRegenerateSummary(any(RagRegenerateSummaryReq.class))) + .willReturn(Collections.emptyList()); + + // when & then + assertThatThrownBy(() -> ragSummaryClient.regenerateSummary(1L, 100L, "URL", "Old")) + .isInstanceOf(EmptyAiResponseException.class); + assertThat(counterCount("regenerate", "empty")).isEqualTo(1.0); + } } diff --git a/src/test/java/com/sofa/linkiving/domain/link/worker/SummaryWorkerTest.java b/src/test/java/com/sofa/linkiving/domain/link/worker/SummaryWorkerTest.java index 8d79c3ac..99888815 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/worker/SummaryWorkerTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/worker/SummaryWorkerTest.java @@ -29,6 +29,7 @@ import com.sofa.linkiving.domain.link.event.SummaryStatusEvent; import com.sofa.linkiving.domain.link.facade.SummaryWorkerFacade; import com.sofa.linkiving.domain.member.entity.Member; +import com.sofa.linkiving.infra.feign.EmptyAiResponseException; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; @@ -276,8 +277,8 @@ void shouldPublishProcessingAndCompletedEvents_WhenSuccess() { } @Test - @DisplayName("AI 응답이 null일 경우 예외가 발생하여 FAILED 이벤트를 발행함") - void shouldPublishFailedEvent_WhenAiResponseIsNull() { + @DisplayName("AI 응답이 비어있어 EmptyAiResponseException 이 발생하면 FAILED 이벤트를 발행함") + void shouldPublishFailedEvent_WhenAiResponseIsEmpty() { // given given(summaryQueue.pollTaskFromQueue()) .willReturn(Optional.of(task(1L))) @@ -285,8 +286,8 @@ void shouldPublishFailedEvent_WhenAiResponseIsNull() { given(summaryWorkerFacade.getLinkWithMember(1L)).willReturn(mockLink); - // AI 응답이 null이면 callAiServerWithRetry 내에서 RuntimeException 발생 - given(summaryClient.initialSummary(anyLong(), anyLong(), any(), any(), any())).willReturn(null); + given(summaryClient.initialSummary(anyLong(), anyLong(), any(), any(), any())) + .willThrow(new EmptyAiResponseException()); // when summaryWorker.startWorker();