From 764a07e18c2d9a7b4a2d5a911008b5d2bcd2597b Mon Sep 17 00:00:00 2001 From: Goder-0 Date: Sun, 21 Jun 2026 21:58:12 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=20=EC=8B=9C=EC=9E=91?= =?UTF-8?q?=20=EC=86=8C=EC=BC=93=20=EC=97=B0=EA=B2=B0=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(#247)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SockJS handshake 경로를 인증 예외로 열어 채팅방 생성 후 첫 질문이 소켓으로 전송되도록 합니다. 메시지가 아직 없는 채팅방도 목록에서 조회되도록 유지해 실패 상태를 확인할 수 있게 합니다. --- .../domain/chat/facade/ChatFacade.java | 28 ++++++++++----- .../chat/repository/ChatRepository.java | 4 +-- .../auth/config/SecurityConstants.java | 5 ++- .../security/config/SecurityConfig.java | 7 ++++ .../domain/chat/facade/ChatFacadeTest.java | 23 +++++++++++++ .../integration/ChatApiIntegrationTest.java | 34 +++++++++++++++++++ .../chat/repository/ChatRepositoryTest.java | 9 ++--- 7 files changed, 94 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/sofa/linkiving/domain/chat/facade/ChatFacade.java b/src/main/java/com/sofa/linkiving/domain/chat/facade/ChatFacade.java index d95b4a1c..60d44002 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/facade/ChatFacade.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/facade/ChatFacade.java @@ -5,6 +5,7 @@ import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import com.sofa.linkiving.domain.chat.ai.TitleClient; @@ -65,25 +66,34 @@ public void deleteChat(Member member, Long chatId) { chatService.delete(chat); } - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void generateAnswer(Long chatId, Member member, String message) { + try { + chatService.getChat(chatId, member); + } catch (RuntimeException ex) { + log.error("채팅 답변 시작 중 오류 발생 - chatId: {}, error: {}", chatId, ex.getMessage(), ex); + sendNotification(chatId, member, AnswerRes.error(chatId, message)); + return; + } - CompletableFuture task = ragChatService.generateAnswer(chatId, member, message); + Long effectiveChatId = chatId; - taskManager.put(chatId, task); + CompletableFuture task = ragChatService.generateAnswer(effectiveChatId, member, message); + + taskManager.put(effectiveChatId, task); task.whenComplete((result, ex) -> { - taskManager.remove(chatId); + taskManager.remove(effectiveChatId); if (task.isCancelled() || ex != null) { if (ex != null) { - log.error("AI 답변 생성 중 오류 발생 - chatId: {}, error: {}", chatId, ex.getMessage(), ex); + log.error("AI 답변 생성 중 오류 발생 - chatId: {}, error: {}", effectiveChatId, ex.getMessage(), ex); } else { - log.info("AI 답변 생성 작업 취소됨 - chatId: {}", chatId); + log.info("AI 답변 생성 작업 취소됨 - chatId: {}", effectiveChatId); } - sendNotification(chatId, member, AnswerRes.error(chatId, message)); + sendNotification(effectiveChatId, member, AnswerRes.error(effectiveChatId, message)); return; } @@ -92,8 +102,8 @@ public void generateAnswer(Long chatId, Member member, String message) { return; } - log.error("AI 답변 생성 결과가 null 입니다 - chatId: {}", chatId); - sendNotification(chatId, member, AnswerRes.error(chatId, message)); + log.error("AI 답변 생성 결과가 null 입니다 - chatId: {}", effectiveChatId); + sendNotification(effectiveChatId, member, AnswerRes.error(effectiveChatId, message)); }); } diff --git a/src/main/java/com/sofa/linkiving/domain/chat/repository/ChatRepository.java b/src/main/java/com/sofa/linkiving/domain/chat/repository/ChatRepository.java index 0842b0aa..3696e1af 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/repository/ChatRepository.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/repository/ChatRepository.java @@ -16,10 +16,10 @@ public interface ChatRepository extends JpaRepository { @Query(""" SELECT c FROM Chat c - JOIN Message m ON m.chat = c + LEFT JOIN Message m ON m.chat = c WHERE c.member = :member GROUP BY c - ORDER BY MAX(m.createdAt) DESC + ORDER BY COALESCE(MAX(m.createdAt), c.createdAt) DESC """) List findAllByMemberOrderByLastMessageDesc(@Param("member") Member member); diff --git a/src/main/java/com/sofa/linkiving/security/auth/config/SecurityConstants.java b/src/main/java/com/sofa/linkiving/security/auth/config/SecurityConstants.java index 04e2cae9..373f1290 100644 --- a/src/main/java/com/sofa/linkiving/security/auth/config/SecurityConstants.java +++ b/src/main/java/com/sofa/linkiving/security/auth/config/SecurityConstants.java @@ -21,7 +21,10 @@ public abstract class SecurityConstants { "/oauth2/**", /* auth */ - "/v1/auth/reissue" + "/v1/auth/reissue", + + /* websocket handshake */ + "/ws/chat/**", "/ws/link/**" }; private static final String[] SEMI_PERMIT_URLS = { diff --git a/src/main/java/com/sofa/linkiving/security/config/SecurityConfig.java b/src/main/java/com/sofa/linkiving/security/config/SecurityConfig.java index 0d04cc99..0fd621b8 100644 --- a/src/main/java/com/sofa/linkiving/security/config/SecurityConfig.java +++ b/src/main/java/com/sofa/linkiving/security/config/SecurityConfig.java @@ -91,7 +91,14 @@ public CorsConfigurationSource corsConfigurationSource() { config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); + CorsConfiguration webSocketConfig = new CorsConfiguration(); + webSocketConfig.setAllowedOriginPatterns(List.of("*")); + webSocketConfig.setAllowedMethods(List.of("GET", "POST", "OPTIONS")); + webSocketConfig.setAllowedHeaders(List.of("*")); + webSocketConfig.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/ws/**", webSocketConfig); source.registerCorsConfiguration("/**", config); return source; } diff --git a/src/test/java/com/sofa/linkiving/domain/chat/facade/ChatFacadeTest.java b/src/test/java/com/sofa/linkiving/domain/chat/facade/ChatFacadeTest.java index c691ffd1..add643d0 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/facade/ChatFacadeTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/facade/ChatFacadeTest.java @@ -186,6 +186,29 @@ void shouldSendNotificationWhenAnswerGeneratedSuccessfully() { ); } + @Test + @DisplayName("채팅방 조회에 실패하면 비동기 작업을 시작하지 않고 에러 알림 전송") + void shouldSendErrorNotificationWhenChatLookupFails() { + // given + Long chatId = 999L; + String userMessage = "질문입니다"; + member = mock(Member.class); + given(member.getEmail()).willReturn("test@test.com"); + given(chatService.getChat(chatId, member)).willThrow(new RuntimeException("chat not found")); + + // when + chatFacade.generateAnswer(chatId, member, userMessage); + + // then + verify(ragChatService, never()).generateAnswer(anyLong(), any(), anyString()); + verify(taskManager, never()).put(anyLong(), any()); + verify(messagingTemplate).convertAndSendToUser( + eq(member.getEmail()), + eq("/queue/chat"), + any(AnswerRes.class) + ); + } + @Test @DisplayName("답변 생성 중 예외가 발생하면 에러 알림 전송") void shouldSendErrorNotificationWhenExceptionOccurs() { diff --git a/src/test/java/com/sofa/linkiving/domain/chat/integration/ChatApiIntegrationTest.java b/src/test/java/com/sofa/linkiving/domain/chat/integration/ChatApiIntegrationTest.java index e0db1931..0686ca07 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/integration/ChatApiIntegrationTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/integration/ChatApiIntegrationTest.java @@ -1,12 +1,15 @@ package com.sofa.linkiving.domain.chat.integration; import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import java.util.List; +import java.util.concurrent.CompletableFuture; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -24,6 +27,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.sofa.linkiving.domain.chat.ai.TitleClient; import com.sofa.linkiving.domain.chat.dto.request.CreateChatReq; +import com.sofa.linkiving.domain.chat.dto.response.AnswerRes; import com.sofa.linkiving.domain.chat.dto.response.MessageRes; import com.sofa.linkiving.domain.chat.dto.response.MessagesRes; import com.sofa.linkiving.domain.chat.entity.Chat; @@ -32,6 +36,7 @@ import com.sofa.linkiving.domain.chat.facade.ChatFacade; import com.sofa.linkiving.domain.chat.repository.ChatRepository; import com.sofa.linkiving.domain.chat.repository.MessageRepository; +import com.sofa.linkiving.domain.chat.service.RagChatService; import com.sofa.linkiving.domain.link.dto.response.LinkCardRes; import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.domain.link.entity.Summary; @@ -85,6 +90,9 @@ public class ChatApiIntegrationTest { @MockitoBean private RedisService redisService; + @MockitoBean + private RagChatService ragChatService; + private UserDetails testUserDetails; private Member testMember; @@ -96,6 +104,13 @@ void setUp() { .build()); testUserDetails = new CustomMemberDetail(testMember, Role.USER); + + given(ragChatService.generateAnswer(anyLong(), any(Member.class), anyString())) + .willAnswer(invocation -> { + Long chatId = invocation.getArgument(0); + String message = invocation.getArgument(2); + return CompletableFuture.completedFuture(AnswerRes.error(chatId, message)); + }); } @Test @@ -206,6 +221,25 @@ void shouldCreateChatSuccessfullyWhenValidRequest() throws Exception { .andExpect(jsonPath("$.data.firstChat").value(firstChatContent)); } + @Test + @DisplayName("메시지가 없는 새 채팅방도 목록 조회에 포함된다") + void shouldIncludeChatWithoutMessagesInChatList() throws Exception { + // given + chatRepository.save(Chat.builder() + .member(testMember) + .title("빈 채팅방") + .build()); + + // when & then + mockMvc.perform(get(BASE_URL) + .with(user(testUserDetails))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.chats").isArray()) + .andExpect(jsonPath("$.data.chats[0].title").value("빈 채팅방")); + } + @Test @DisplayName("첫 대화 내용 누락 시 400 Bad Request 반환") void shouldReturnBadRequestWhenFirstChatIsBlank() throws Exception { diff --git a/src/test/java/com/sofa/linkiving/domain/chat/repository/ChatRepositoryTest.java b/src/test/java/com/sofa/linkiving/domain/chat/repository/ChatRepositoryTest.java index 45f8914c..83e9abd2 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/repository/ChatRepositoryTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/repository/ChatRepositoryTest.java @@ -48,11 +48,11 @@ void setUp() { } @Test - @DisplayName("메시지가 있는 채팅방만 조회되며, 최신 메시지 순으로 정렬됨") - void shouldReturnOnlyChatsWithMessagesOrderByLastMessageTime() throws InterruptedException { + @DisplayName("빈 채팅방도 조회되며, 마지막 메시지 또는 생성 시각 기준으로 최신순 정렬됨") + void shouldReturnChatsIncludingEmptyRoomsOrderByLastActivity() throws InterruptedException { // given - // 메시지 없는 채팅방 -> 조회되지 않아야 함 + // 메시지 없는 채팅방 -> 생성 시각 기준으로 함께 조회되어야 함 chatRepository.save(Chat .builder() .member(member) @@ -90,9 +90,10 @@ void shouldReturnOnlyChatsWithMessagesOrderByLastMessageTime() throws Interrupte List result = chatRepository.findAllByMemberOrderByLastMessageDesc(member); // then - assertThat(result).hasSize(2); + assertThat(result).hasSize(3); assertThat(result.get(0).getTitle()).isEqualTo("New Msg Chat"); assertThat(result.get(1).getTitle()).isEqualTo("Old Msg Chat"); + assertThat(result.get(2).getTitle()).isEqualTo("No Msg Chat"); } @Test