From 537f0a4438ae07b9628a23d61c9ad958d65bcae6 Mon Sep 17 00:00:00 2001 From: Hong Jihyeong <154999028+topograp2@users.noreply.github.com> Date: Wed, 1 Apr 2026 01:42:37 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20=EC=B1=85=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EC=B1=97=EB=B4=87=20SSE=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/measure-bookie-chat.ps1 | 174 ++++++++++++++++++ .../controller/BookieChatController.java | 36 +++- .../service/BookieChatAsyncService.java | 31 ++++ .../Bookie/service/BookieChatService.java | 173 ++++++++++++++--- 4 files changed, 376 insertions(+), 38 deletions(-) create mode 100644 scripts/measure-bookie-chat.ps1 create mode 100644 src/main/java/com/capstone/bszip/Bookie/service/BookieChatAsyncService.java diff --git a/scripts/measure-bookie-chat.ps1 b/scripts/measure-bookie-chat.ps1 new file mode 100644 index 0000000..8136279 --- /dev/null +++ b/scripts/measure-bookie-chat.ps1 @@ -0,0 +1,174 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Token, + + [Parameter(Mandatory = $false)] + [string]$BaseUrl = "http://localhost:8080", + + [Parameter(Mandatory = $false)] + [string]$Message = "힐링되는 소설 추천해줘", + + [Parameter(Mandatory = $false)] + [int]$Iterations = 3 +) + +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.Net.Http + +$jsonBody = @{ message = $Message } | ConvertTo-Json -Compress +$headers = @{ + Authorization = "Bearer $Token" + Accept = "application/json" +} +$streamHeaders = @{ + Authorization = "Bearer $Token" + Accept = "text/event-stream" +} + +function Invoke-JsonChat { + param( + [string]$Url, + [hashtable]$Headers, + [string]$Body + ) + + $client = [System.Net.Http.HttpClient]::new() + try { + foreach ($key in $Headers.Keys) { + if ($key -ieq "Accept") { + $client.DefaultRequestHeaders.Accept.Clear() + $client.DefaultRequestHeaders.Accept.Add( + [System.Net.Http.Headers.MediaTypeWithQualityHeaderValue]::new($Headers[$key]) + ) + } else { + $client.DefaultRequestHeaders.Remove($key) | Out-Null + $client.DefaultRequestHeaders.Add($key, $Headers[$key]) + } + } + + $content = [System.Net.Http.StringContent]::new($Body, [System.Text.Encoding]::UTF8, "application/json") + $sw = [System.Diagnostics.Stopwatch]::StartNew() + $response = $client.PostAsync($Url, $content).GetAwaiter().GetResult() + $payload = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + $sw.Stop() + + [pscustomobject]@{ + StatusCode = [int]$response.StatusCode + ElapsedMs = [math]::Round($sw.Elapsed.TotalMilliseconds, 2) + Body = $payload + } + } + finally { + $client.Dispose() + } +} + +function Invoke-SseChat { + param( + [string]$Url, + [hashtable]$Headers, + [string]$Body + ) + + $handler = [System.Net.Http.HttpClientHandler]::new() + $client = [System.Net.Http.HttpClient]::new($handler) + + try { + foreach ($key in $Headers.Keys) { + if ($key -ieq "Accept") { + $client.DefaultRequestHeaders.Accept.Clear() + $client.DefaultRequestHeaders.Accept.Add( + [System.Net.Http.Headers.MediaTypeWithQualityHeaderValue]::new($Headers[$key]) + ) + } else { + $client.DefaultRequestHeaders.Remove($key) | Out-Null + $client.DefaultRequestHeaders.Add($key, $Headers[$key]) + } + } + + $request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Post, $Url) + $request.Content = [System.Net.Http.StringContent]::new($Body, [System.Text.Encoding]::UTF8, "application/json") + + $sw = [System.Diagnostics.Stopwatch]::StartNew() + $response = $client.SendAsync( + $request, + [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead + ).GetAwaiter().GetResult() + + $stream = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() + $reader = [System.IO.StreamReader]::new($stream) + + $firstEventMs = $null + $lineCount = 0 + $events = New-Object System.Collections.Generic.List[string] + + try { + while (-not $reader.EndOfStream) { + $line = $reader.ReadLine() + if ($null -eq $line) { + continue + } + + if ($line.Length -gt 0 -and -not $firstEventMs) { + $firstEventMs = [math]::Round($sw.Elapsed.TotalMilliseconds, 2) + } + + if ($line.StartsWith("event:")) { + $events.Add($line.Substring(6).Trim()) + } + + $lineCount++ + } + } + finally { + $reader.Dispose() + $stream.Dispose() + $response.Dispose() + } + + $sw.Stop() + + [pscustomobject]@{ + StatusCode = [int]$response.StatusCode + FirstEventMs = $firstEventMs + TotalMs = [math]::Round($sw.Elapsed.TotalMilliseconds, 2) + LineCount = $lineCount + Events = ($events -join ",") + } + } + finally { + $client.Dispose() + $handler.Dispose() + } +} + +$jsonResults = @() +$sseResults = @() + +for ($i = 1; $i -le $Iterations; $i++) { + $jsonResults += Invoke-JsonChat -Url "$BaseUrl/bookie/chat" -Headers $headers -Body $jsonBody + Start-Sleep -Milliseconds 300 + $sseResults += Invoke-SseChat -Url "$BaseUrl/bookie/chat/stream" -Headers $streamHeaders -Body $jsonBody + Start-Sleep -Milliseconds 300 +} + +$jsonAvg = [math]::Round((($jsonResults | Measure-Object -Property ElapsedMs -Average).Average), 2) +$sseFirstAvg = [math]::Round((($sseResults | Measure-Object -Property FirstEventMs -Average).Average), 2) +$sseTotalAvg = [math]::Round((($sseResults | Measure-Object -Property TotalMs -Average).Average), 2) + +Write-Host "" +Write-Host "JSON /bookie/chat" +$jsonResults | Format-Table -AutoSize +Write-Host "Average total: $jsonAvg ms" + +Write-Host "" +Write-Host "SSE /bookie/chat/stream" +$sseResults | Format-Table -AutoSize +Write-Host "Average first event: $sseFirstAvg ms" +Write-Host "Average total: $sseTotalAvg ms" + +Write-Host "" +Write-Host "Interpretation" +Write-Host "- /bookie/chat compares full response completion." +Write-Host "- /bookie/chat/stream first-event compares perceived responsiveness." +Write-Host "- /bookie/chat/stream total compares full stream completion." diff --git a/src/main/java/com/capstone/bszip/Bookie/controller/BookieChatController.java b/src/main/java/com/capstone/bszip/Bookie/controller/BookieChatController.java index be9bffa..4c6d776 100644 --- a/src/main/java/com/capstone/bszip/Bookie/controller/BookieChatController.java +++ b/src/main/java/com/capstone/bszip/Bookie/controller/BookieChatController.java @@ -5,7 +5,6 @@ import com.capstone.bszip.Bookie.dto.response.MemberChatResponses; import com.capstone.bszip.Bookie.service.BookieChatService; import com.capstone.bszip.Member.domain.Member; -import com.capstone.bszip.commonDto.SuccessResponse; import com.fasterxml.jackson.core.JsonProcessingException; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -15,18 +14,23 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @RestController @RequestMapping("/bookie") @RequiredArgsConstructor -@Tag(name="Bookie", description = "chatbot api") +@Tag(name = "Bookie", description = "chatbot api") public class BookieChatController { private final BookieChatService bookieChatService; @GetMapping("/history") - @Operation(summary = "이전 히스토리 가져오기", description = "[로그인 필수] 해당 회원의 이전 기록 최신순으로 반환", + @Operation(summary = "이전 히스토리 조회", description = "[로그인 필수] 해당 회원의 이전 기록을 최신순으로 반환", responses = { @ApiResponse(responseCode = "200", description = "AI 응답 반환 성공", content = @Content(schema = @Schema(implementation = MemberChatResponses.class))), @@ -35,16 +39,15 @@ public class BookieChatController { } ) public ResponseEntity getChatHistory(@AuthenticationPrincipal Member member) { - try{ + try { return ResponseEntity.ok(bookieChatService.getChatHistory(member)); } catch (Exception e) { throw new RuntimeException(e); } } - @PostMapping("/chat") - @Operation(summary = "chat 반환", description = "[로그인 필수] ai 모델의 답 가공하지 않고 바로 반환", + @Operation(summary = "chat 반환", description = "[로그인 필수] ai 모델 응답을 한 번에 반환", responses = { @ApiResponse(responseCode = "200", description = "AI 응답 반환 성공", content = @Content(schema = @Schema(implementation = ChatResponse.class))), @@ -52,11 +55,24 @@ public ResponseEntity getChatHistory(@AuthenticationPrincipal Member member) @ApiResponse(responseCode = "500", description = "서버 오류") } ) - public ResponseEntity getChat(@AuthenticationPrincipal Member member,@RequestBody ChatRequest chatRequest) throws JsonProcessingException { - try{ + public ResponseEntity getChat(@AuthenticationPrincipal Member member, @RequestBody ChatRequest chatRequest) + throws JsonProcessingException { + try { return ResponseEntity.ok(bookieChatService.getChat(member, chatRequest)); - }catch (Exception e) { + } catch (Exception e) { throw new RuntimeException(e); } } + + @PostMapping(value = "/chat/stream", produces = "text/event-stream") + @Operation(summary = "chat stream 반환", description = "[로그인 필수] ai 모델 SSE 응답을 실시간으로 반환", + responses = { + @ApiResponse(responseCode = "200", description = "AI SSE 응답 반환 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청"), + @ApiResponse(responseCode = "500", description = "서버 오류") + } + ) + public SseEmitter streamChat(@AuthenticationPrincipal Member member, @RequestBody ChatRequest chatRequest) { + return bookieChatService.streamChat(member, chatRequest); + } } diff --git a/src/main/java/com/capstone/bszip/Bookie/service/BookieChatAsyncService.java b/src/main/java/com/capstone/bszip/Bookie/service/BookieChatAsyncService.java new file mode 100644 index 0000000..1afae3c --- /dev/null +++ b/src/main/java/com/capstone/bszip/Bookie/service/BookieChatAsyncService.java @@ -0,0 +1,31 @@ +package com.capstone.bszip.Bookie.service; + +import com.capstone.bszip.Bookie.domain.BookieChat; +import com.capstone.bszip.Bookie.dto.request.ChatRequest; +import com.capstone.bszip.Bookie.repository.BookieChatRepository; +import com.capstone.bszip.Member.domain.Member; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class BookieChatAsyncService { + + private final BookieChatRepository bookieChatRepository; + + @Async + @Transactional + public void saveChatToDB(String chatJson, Member member, ChatRequest chatRequest) { + BookieChat bookieChat = BookieChat.builder() + .question(chatRequest.getMessage()) + .answer(chatJson) + .member(member) + .build(); + log.info("{}에 대한 응답 저장", bookieChat.getQuestion()); + bookieChatRepository.save(bookieChat); + } +} diff --git a/src/main/java/com/capstone/bszip/Bookie/service/BookieChatService.java b/src/main/java/com/capstone/bszip/Bookie/service/BookieChatService.java index bbf265f..f9e98b5 100644 --- a/src/main/java/com/capstone/bszip/Bookie/service/BookieChatService.java +++ b/src/main/java/com/capstone/bszip/Bookie/service/BookieChatService.java @@ -3,6 +3,7 @@ import com.capstone.bszip.Bookie.domain.BookieChat; import com.capstone.bszip.Bookie.dto.request.APIChatRequest; import com.capstone.bszip.Bookie.dto.request.ChatRequest; +import com.capstone.bszip.Bookie.dto.response.ChatResponse; import com.capstone.bszip.Bookie.dto.response.MemberChatHistoryResponse; import com.capstone.bszip.Bookie.dto.response.MemberChatResponses; import com.capstone.bszip.Bookie.dto.response.RecommendedBook; @@ -18,45 +19,53 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestTemplate; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Stream; @Service @RequiredArgsConstructor @Slf4j public class BookieChatService { + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .build(); + private final BookieChatRepository bookieChatRepository; + private final BookieChatAsyncService bookieChatAsyncService; + private final ObjectMapper objectMapper; @Value("${ai.base-uri}") String embeddingURI; @Transactional(readOnly = true) public MemberChatResponses getChatHistory(Member member) throws JsonProcessingException { - List bookieChatList = bookieChatRepository.findByMemberAndCreatedDateAfterOrderByCreatedDate(member, LocalDateTime.now().minusDays(3)); + List bookieChatList = bookieChatRepository.findByMemberAndCreatedDateAfterOrderByCreatedDate( + member, + LocalDateTime.now().minusDays(3) + ); List responses = new ArrayList<>(); - ObjectMapper mapper = new ObjectMapper(); for (BookieChat bookieChat : bookieChatList) { responses.add(MemberChatHistoryResponse.fromUserMessage(bookieChat)); - JsonNode node = mapper.readTree(bookieChat.getAnswer()); - String message = node.get("message").asText(); - List books = new ArrayList<>(); - JsonNode booksNode = node.get("books"); - if (booksNode != null && booksNode.isArray()) { - for (JsonNode book : booksNode) { - books.add(RecommendedBook.fromJsonProperty(book.get("title").asText(), book.get("bookId").asText(), book.get("bookImageUrl").asText())); - } - } + JsonNode node = objectMapper.readTree(bookieChat.getAnswer()); responses.add(MemberChatHistoryResponse.builder() - .text(message) + .text(node.path("message").asText()) .type(SpeakerType.system) - .books(books) + .books(extractBooks(node.path("books"))) .createdAt(bookieChat.getCreatedDate()) .build()); } @@ -64,27 +73,135 @@ public MemberChatResponses getChatHistory(Member member) throws JsonProcessingEx } @Transactional - public String getChat(Member member, ChatRequest chatRequest) throws JsonProcessingException { + public String getChat(Member member, ChatRequest chatRequest) { HttpHeaders httpHeaders = new HttpHeaders(); httpHeaders.setContentType(MediaType.APPLICATION_JSON); HttpEntity httpEntity = new HttpEntity<>(APIChatRequest.fromEntity(member, chatRequest), httpHeaders); RestTemplate restTemplate = new RestTemplate(); - String chatJson = restTemplate.postForEntity(embeddingURI+"/chat", httpEntity, String.class).getBody(); - log.info("✅ 응답 : {}", chatJson); - saveChatToDB(chatJson, member, chatRequest); + String chatJson = restTemplate.postForEntity(embeddingURI + "/chat", httpEntity, String.class).getBody(); + log.info("AI 응답: {}", chatJson); + bookieChatAsyncService.saveChatToDB(chatJson, member, chatRequest); return chatJson; } - @Async - @Transactional - public void saveChatToDB(String chatJson, Member member, ChatRequest chatRequest){ - BookieChat bookieChat = BookieChat.builder() - .question(chatRequest.getMessage()) - .answer(chatJson) - .member(member) - .build(); - log.info("{}에 대한 응답 저장", bookieChat.getQuestion()); - bookieChatRepository.save(bookieChat); + public SseEmitter streamChat(Member member, ChatRequest chatRequest) { + SseEmitter emitter = new SseEmitter(0L); + CompletableFuture.runAsync(() -> streamChatResponse(member, chatRequest, emitter)); + return emitter; + } + + private void streamChatResponse(Member member, ChatRequest chatRequest, SseEmitter emitter) { + try { + String requestBody = objectMapper.writeValueAsString(APIChatRequest.fromEntity(member, chatRequest)); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(embeddingURI + "/chat/stream")) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .header(HttpHeaders.ACCEPT, MediaType.TEXT_EVENT_STREAM_VALUE) + .POST(HttpRequest.BodyPublishers.ofString(requestBody, StandardCharsets.UTF_8)) + .build(); + + HttpResponse> response = HTTP_CLIENT + .send(request, HttpResponse.BodyHandlers.ofLines()); + + if (response.statusCode() >= 400) { + String errorBody = readErrorBody(response.body()); + log.error("AI SSE 호출 실패. status={}, requestBody={}, responseBody={}", + response.statusCode(), requestBody, errorBody); + emitError(emitter, "AI 서버 호출에 실패했습니다."); + return; + } + + relaySseStream(response.body(), member, chatRequest, emitter); + emitter.complete(); + } catch (Exception e) { + log.error("SSE 채팅 스트리밍 중 오류", e); + try { + emitError(emitter, "채팅 스트리밍 중 오류가 발생했습니다."); + } catch (IOException ioException) { + emitter.completeWithError(ioException); + } + } + } + + private String readErrorBody(Stream lines) { + try (lines) { + return lines.reduce((left, right) -> left + "\n" + right).orElse(""); + } + } + + private void relaySseStream(Stream lines, Member member, ChatRequest chatRequest, SseEmitter emitter) + throws IOException { + String currentEvent = null; + StringBuilder dataBuffer = new StringBuilder(); + + try (lines) { + for (String line : (Iterable) lines::iterator) { + if (line.startsWith("event:")) { + currentEvent = line.substring(6).trim(); + continue; + } + + if (line.startsWith("data:")) { + if (dataBuffer.length() > 0) { + dataBuffer.append('\n'); + } + dataBuffer.append(line.substring(5).trim()); + continue; + } + + if (line.isBlank()) { + forwardEvent(currentEvent, dataBuffer.toString(), member, chatRequest, emitter); + currentEvent = null; + dataBuffer.setLength(0); + } + } + } + + if (dataBuffer.length() > 0) { + forwardEvent(currentEvent, dataBuffer.toString(), member, chatRequest, emitter); + } } + private void forwardEvent( + String eventName, + String eventData, + Member member, + ChatRequest chatRequest, + SseEmitter emitter + ) throws IOException { + if (eventData == null || eventData.isBlank()) { + return; + } + + String resolvedEvent = (eventName == null || eventName.isBlank()) ? "message" : eventName; + emitter.send(SseEmitter.event() + .name(resolvedEvent) + .data(eventData, MediaType.APPLICATION_JSON)); + + if ("done".equals(resolvedEvent)) { + bookieChatAsyncService.saveChatToDB(eventData, member, chatRequest); + } + } + + private void emitError(SseEmitter emitter, String message) throws IOException { + String payload = objectMapper.writeValueAsString(ChatResponse.fromAPIResponse(message, List.of())); + emitter.send(SseEmitter.event() + .name("error") + .data(payload, MediaType.APPLICATION_JSON)); + emitter.complete(); + } + + private List extractBooks(JsonNode booksNode) { + List books = new ArrayList<>(); + if (booksNode != null && booksNode.isArray()) { + for (JsonNode book : booksNode) { + books.add(RecommendedBook.fromJsonProperty( + book.path("title").asText(), + book.path("bookId").asText(), + book.path("bookImageUrl").asText() + )); + } + } + return books; + } }