diff --git a/build.gradle b/build.gradle index 23b241e..e5bc3d6 100644 --- a/build.gradle +++ b/build.gradle @@ -82,6 +82,8 @@ jacocoTestCoverageVerification { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' + implementation('org.springframework.boot:spring-boot-starter-webflux') + implementation 'org.apache.httpcomponents.client5:httpclient5:5.3.1' implementation 'org.springframework.boot:spring-boot-starter-security' diff --git a/src/main/java/com/ureca/ufit/domain/chatbot/client/ChatClient.java b/src/main/java/com/ureca/ufit/domain/chatbot/client/ChatClient.java deleted file mode 100644 index 09bfc17..0000000 --- a/src/main/java/com/ureca/ufit/domain/chatbot/client/ChatClient.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.ureca.ufit.domain.chatbot.client; - -import com.ureca.ufit.domain.chatbot.dto.request.CreateUserQuerySummaryRequest; -import com.ureca.ufit.domain.chatbot.dto.response.QuestionSummaryDto; - -public interface ChatClient { - QuestionSummaryDto getSummary(CreateUserQuerySummaryRequest request, long chatRoomId); -} \ No newline at end of file diff --git a/src/main/java/com/ureca/ufit/domain/chatbot/controller/ChatBotController.java b/src/main/java/com/ureca/ufit/domain/chatbot/controller/ChatBotController.java index bcae2f2..df50bfa 100644 --- a/src/main/java/com/ureca/ufit/domain/chatbot/controller/ChatBotController.java +++ b/src/main/java/com/ureca/ufit/domain/chatbot/controller/ChatBotController.java @@ -1,7 +1,5 @@ package com.ureca.ufit.domain.chatbot.controller; -import java.util.concurrent.CompletableFuture; - import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -20,6 +18,7 @@ import com.ureca.ufit.global.dto.CursorPageResponse; import lombok.RequiredArgsConstructor; +import reactor.core.publisher.Mono; @RestController @RequiredArgsConstructor @@ -52,13 +51,25 @@ public ResponseEntity createChatBotReview(CreateCha } @Override - public ResponseEntity> createChatBotMessage( + public ResponseEntity createChatBotMessage( + CustomUserDetails userDetails, + CreateChatBotMessageRequest request) { + + Long userId = (userDetails != null) ? userDetails.userId() : nonUserId; + + CreateChatBotMessageResponse response = chatBotMessageService.createChatBotMessage(request, + userId); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @Override + public ResponseEntity> createChatBotMessageWithWebClient( CustomUserDetails userDetails, CreateChatBotMessageRequest request) { Long userId = (userDetails != null) ? userDetails.userId() : nonUserId; - CompletableFuture response = chatBotMessageService.createChatBotMessage(request, + Mono response = chatBotMessageService.createChatBotMessageWithWebClient(request, userId); return ResponseEntity.status(HttpStatus.CREATED).body(response); } diff --git a/src/main/java/com/ureca/ufit/domain/chatbot/controller/ChatBotControllerApiSpec.java b/src/main/java/com/ureca/ufit/domain/chatbot/controller/ChatBotControllerApiSpec.java index 97269c4..604fe51 100644 --- a/src/main/java/com/ureca/ufit/domain/chatbot/controller/ChatBotControllerApiSpec.java +++ b/src/main/java/com/ureca/ufit/domain/chatbot/controller/ChatBotControllerApiSpec.java @@ -1,7 +1,5 @@ package com.ureca.ufit.domain.chatbot.controller; -import java.util.concurrent.CompletableFuture; - import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; @@ -31,6 +29,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import reactor.core.publisher.Mono; @Tag(name = "ChatBot API", description = "챗봇 관련 API") @RequestMapping("/api/chats") @@ -109,7 +108,22 @@ public ResponseEntity createChatBotReview( content = @Content(schema = @Schema(implementation = CreateChatBotMessageResponse.class)) )) @PostMapping("/message") - public ResponseEntity> createChatBotMessage( + public ResponseEntity createChatBotMessage( + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody @Valid CreateChatBotMessageRequest request + ); + + @Operation( + summary = "챗봇 메시지 저장 API(WebClient)", + description = "사용자 메시지를 저장하고, AI 답변을 반환한다." + ) + @ApiResponses(@ApiResponse( + responseCode = "201", description = "메시지 저장 & 답변 완료", + content = @Content(schema = @Schema(implementation = CreateChatBotMessageResponse.class)) + )) + @PostMapping("/message/webclient") + public ResponseEntity> createChatBotMessageWithWebClient( @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails, @RequestBody @Valid CreateChatBotMessageRequest request diff --git a/src/main/java/com/ureca/ufit/domain/chatbot/service/ChatBotMessageService.java b/src/main/java/com/ureca/ufit/domain/chatbot/service/ChatBotMessageService.java index 068eda7..0332f65 100644 --- a/src/main/java/com/ureca/ufit/domain/chatbot/service/ChatBotMessageService.java +++ b/src/main/java/com/ureca/ufit/domain/chatbot/service/ChatBotMessageService.java @@ -3,13 +3,12 @@ import static com.ureca.ufit.global.profanity.BanwordFilterPolicy.*; import java.util.Set; -import java.util.concurrent.CompletableFuture; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Pageable; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; import com.ureca.ufit.domain.chatbot.dto.ChatMessageMapper; import com.ureca.ufit.domain.chatbot.dto.request.CreateAIAnswerRequest; @@ -26,6 +25,7 @@ import com.ureca.ufit.global.profanity.ProfanityService; import lombok.RequiredArgsConstructor; +import reactor.core.publisher.Mono; @Service @RequiredArgsConstructor @@ -37,6 +37,7 @@ public class ChatBotMessageService { private final ChatBotMessageRepository chatBotMessageRepository; private final ChatRoomRepository chatRoomRepository; private final RestTemplate restTemplate; + private final WebClient webClient; public CursorPageResponse getChatMessages(Long chatRoomId, Pageable pageable, String lastMessageId) { @@ -45,8 +46,7 @@ public CursorPageResponse getChatMessages(Long chatRoomId, Pagea return chatBotMessageRepository.findMessagesPage(findChatRoom, pageable, lastMessageId); } - @Async - public CompletableFuture createChatBotMessage(CreateChatBotMessageRequest request, + public CreateChatBotMessageResponse createChatBotMessage(CreateChatBotMessageRequest request, Long userId) { Set policies = Set.of(NUMBERS, WHITESPACES); @@ -60,13 +60,35 @@ public CompletableFuture createChatBotMessage(Crea CreateAIAnswerRequest createAIAnswerRequest = ChatMessageMapper.toCreateAIAnswerRequest(request, userId); try { - CreateChatBotMessageResponse response = restTemplate.postForObject( + return restTemplate.postForObject( fastApiUrl, createAIAnswerRequest, CreateChatBotMessageResponse.class ); - return CompletableFuture.completedFuture(response); + } catch (Exception e) { + throw new RestApiException(ChatBotErrorCode.LLM_TIMEOUT); + } + } + public Mono createChatBotMessageWithWebClient(CreateChatBotMessageRequest request, + Long userId) { + + Set policies = Set.of(NUMBERS, WHITESPACES); + + if (profanityService.containsBannedWord(request.content(), policies)) { + throw new RestApiException(ChatBotErrorCode.CONTENT_RESTRICTED_WORD); + } + + final String fastApiUrl = String.format("%s/api/chats/message/ai", llmBaseUrl); + + CreateAIAnswerRequest createAIAnswerRequest = ChatMessageMapper.toCreateAIAnswerRequest(request, userId); + + try { + return webClient.post() + .uri(fastApiUrl) + .bodyValue(createAIAnswerRequest) + .retrieve() + .bodyToMono(CreateChatBotMessageResponse.class); } catch (Exception e) { throw new RestApiException(ChatBotErrorCode.LLM_TIMEOUT); } diff --git a/src/main/java/com/ureca/ufit/global/config/HttpClientConfig.java b/src/main/java/com/ureca/ufit/global/config/HttpClientConfig.java new file mode 100644 index 0000000..1f008da --- /dev/null +++ b/src/main/java/com/ureca/ufit/global/config/HttpClientConfig.java @@ -0,0 +1,61 @@ +package com.ureca.ufit.global.config; + +import java.time.Duration; + +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +import io.netty.channel.ChannelOption; +import reactor.netty.http.client.HttpClient; +import reactor.netty.resources.ConnectionProvider; + +@Configuration +public class HttpClientConfig { + @Bean + public RestTemplate restTemplate() { + PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(); + cm.setMaxTotal(100); + cm.setDefaultMaxPerRoute(100); + + RequestConfig requestConfig = RequestConfig.custom() + .setConnectTimeout(Timeout.ofMilliseconds(3_000)) + .setResponseTimeout(Timeout.ofSeconds(60)) + .build(); + + CloseableHttpClient http = HttpClients.custom() + .setConnectionManager(cm) + .setDefaultRequestConfig(requestConfig) + .evictIdleConnections(TimeValue.ofSeconds(30)) + .build(); + + return new RestTemplate(new HttpComponentsClientHttpRequestFactory(http)); + } + + @Bean + public WebClient webClient() { + ConnectionProvider provider = ConnectionProvider.builder("ufit-pool") + .maxConnections(100) + .maxIdleTime(Duration.ofSeconds(30)) + .pendingAcquireTimeout(Duration.ofSeconds(5)) + .build(); + + HttpClient http = HttpClient.create(provider) + .responseTimeout(Duration.ofSeconds(60)) + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3_000); + + return WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(http)) + .build(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/ureca/ufit/global/config/RestTemplateConfig.java b/src/main/java/com/ureca/ufit/global/config/RestTemplateConfig.java deleted file mode 100644 index 84053f1..0000000 --- a/src/main/java/com/ureca/ufit/global/config/RestTemplateConfig.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.ureca.ufit.global.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.client.RestTemplate; - -@Configuration -public class RestTemplateConfig { - @Bean - public RestTemplate restTemplate() { - return new RestTemplate(); - } -} \ No newline at end of file