diff --git a/src/main/java/com/yd/vibecode/domain/chat/application/usecase/ChatResponsePersister.java b/src/main/java/com/yd/vibecode/domain/chat/application/usecase/ChatResponsePersister.java new file mode 100644 index 0000000..5fe038f --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/chat/application/usecase/ChatResponsePersister.java @@ -0,0 +1,43 @@ +package com.yd.vibecode.domain.chat.application.usecase; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.yd.vibecode.domain.chat.application.dto.response.SendMessageResponse; +import com.yd.vibecode.domain.chat.domain.service.PromptMessageService; +import com.yd.vibecode.domain.chat.domain.service.PromptSessionService; +import com.yd.vibecode.domain.exam.domain.service.ExamParticipantService; + +import lombok.RequiredArgsConstructor; + +/** + * AI 응답 사후 저장 전용 컴포넌트. + * AI HTTP 호출(무 트랜잭션) 이후의 DB 쓰기를 하나의 트랜잭션으로 묶어 원자성을 보장한다. + */ +@Component +@RequiredArgsConstructor +public class ChatResponsePersister { + + private final PromptMessageService promptMessageService; + private final PromptSessionService promptSessionService; + private final ExamParticipantService examParticipantService; + + @Transactional + public void persistAiResponse(Long sessionId, Long examId, Long participantId, + SendMessageResponse aiResponse) { + Integer tokenCount = aiResponse.tokenCount(); + + promptMessageService.create( + sessionId, + aiResponse.turnId(), + aiResponse.role() != null ? aiResponse.role() : "AI", + aiResponse.content() != null ? aiResponse.content() : "", + tokenCount != null ? tokenCount : 0, + null); + + if (tokenCount != null && tokenCount > 0) { + promptSessionService.addTokens(sessionId, tokenCount); + examParticipantService.addTokenUsed(examId, participantId, tokenCount); + } + } +} diff --git a/src/main/java/com/yd/vibecode/domain/chat/application/usecase/SaveChatMessageUseCase.java b/src/main/java/com/yd/vibecode/domain/chat/application/usecase/SaveChatMessageUseCase.java index c45cb7b..1cf5c25 100644 --- a/src/main/java/com/yd/vibecode/domain/chat/application/usecase/SaveChatMessageUseCase.java +++ b/src/main/java/com/yd/vibecode/domain/chat/application/usecase/SaveChatMessageUseCase.java @@ -4,9 +4,11 @@ import java.util.Map; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import com.yd.vibecode.domain.chat.application.dto.request.AISendMessageRequest; +import com.yd.vibecode.domain.chat.domain.entity.PromptMessage; import com.yd.vibecode.domain.chat.application.dto.request.SaveChatMessageRequest; import com.yd.vibecode.domain.chat.application.dto.response.SendMessageResponse; import com.yd.vibecode.domain.chat.domain.entity.PromptSession; @@ -23,8 +25,14 @@ /** * 채팅 메시지 저장 UseCase - * - 사용자 메시지 저장 - * - 사용자 메시지인 경우 AI 서버로 전송 및 AI 응답 저장 + * + * 트랜잭션 경계: + * - 사전 저장(검증/세션/사용자 메시지): 각 서비스의 개별 트랜잭션에서 즉시 커밋 + * - AI HTTP 호출: 트랜잭션 밖 (DB 커넥션 비점유) ← 커넥션 풀 고갈 방지 + * - 사후 저장(AI 메시지/토큰): ChatResponsePersister 단일 트랜잭션 + * + * 주의: execute()가 REQUIRED 트랜잭션을 시작/전파받으면 AI 응답(최대 90초)을 기다리는 동안 + * DB 커넥션을 점유하여 풀이 고갈될 수 있으므로, NOT_SUPPORTED로 외부 트랜잭션을 명시적으로 중단한다. */ @Service @RequiredArgsConstructor @@ -34,13 +42,13 @@ public class SaveChatMessageUseCase { private final PromptMessageService promptMessageService; private final ExamParticipantService examParticipantService; private final AIChatService aiChatService; + private final ChatResponsePersister chatResponsePersister; - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public SendMessageResponse execute(SaveChatMessageRequest request) { - // 1. ExamParticipant 조회하여 specId 가져오기 + // 1. 참가자 검증 (읽기 트랜잭션 즉시 종료) ExamParticipant examParticipant = examParticipantService.findByExamIdAndParticipantId( request.examId(), request.participantId()); - if (examParticipant == null) { throw new RestApiException(GlobalErrorStatus._NOT_FOUND); } @@ -48,117 +56,63 @@ public SendMessageResponse execute(SaveChatMessageRequest request) { throw new RestApiException(ProblemErrorStatus.NO_ASSIGNED_PROBLEM); } - // 2. 세션 가져오기 또는 생성 (별도 트랜잭션으로 즉시 커밋) - // AI 서버에서 세션을 조회할 수 있도록 별도 트랜잭션으로 처리하여 즉시 커밋 + // 2. 세션 확보 (REQUIRES_NEW로 즉시 커밋 → AI 서버에서 조회 가능) PromptSession session; if (request.sessionId() != null) { - // sessionId가 제공되면 해당 세션 사용 (별도 트랜잭션으로 조회) session = promptSessionService.findByIdWithNewTransaction(request.sessionId()); - // 세션이 요청한 examId와 participantId와 일치하는지 검증 - if (!session.getExamId().equals(request.examId()) || - !session.getParticipantId().equals(request.participantId())) { + if (!session.getExamId().equals(request.examId()) + || !session.getParticipantId().equals(request.participantId())) { throw new RestApiException(GlobalErrorStatus._NOT_FOUND); } promptSessionService.ensureSessionSpecId(session.getId(), examParticipant.getSpecId()); session = promptSessionService.findByIdWithNewTransaction(request.sessionId()); } else { - // sessionId가 없으면 examId와 participantId로 세션 조회/생성 (별도 트랜잭션으로 즉시 커밋) session = promptSessionService.getOrCreateSessionWithNewTransaction( request.examId(), request.participantId(), examParticipant.getSpecId()); } - // 3. 다음 turn 계산 (사용자가 보낸 turn은 무시하고 DB에서 자동 계산) - Integer nextTurn = promptMessageService.getNextTurn(session.getId()); - - // 4. 사용자 메시지 저장 - promptMessageService.create( - session.getId(), - nextTurn, - request.role(), - request.content(), - request.tokenCount(), - request.meta() - ); + // USER 메시지가 아니면 처리하지 않음 (FE는 USER만 전송) + if (!"USER".equalsIgnoreCase(request.role())) { + throw new RestApiException(GlobalErrorStatus._BAD_REQUEST); + } - // 4. 토큰 카운트가 있으면 세션에 누적 + // 3. 사용자 메시지 저장 — turn 계산 + INSERT를 단일 트랜잭션으로 처리 (레이스 컨디션 방지) + PromptMessage savedUserMessage = promptMessageService.createWithNextTurn( + session.getId(), request.role(), request.content(), + request.tokenCount(), request.meta()); + Integer nextTurn = savedUserMessage.getTurn(); if (request.tokenCount() != null && request.tokenCount() > 0) { promptSessionService.addTokens(session.getId(), request.tokenCount()); } - // 5. 사용자 메시지인 경우 AI 서버로 전송 - if ("USER".equalsIgnoreCase(request.role()) || "user".equalsIgnoreCase(request.role())) { - try { - // AI 요청 구성 - Map context = new HashMap<>(); - context.put("problemId", examParticipant.getAssignedProblemId()); // 문제 ID - context.put("specVersion", examParticipant.getSpecId()); // 스펙 ID - - AISendMessageRequest aiRequest = AISendMessageRequest.builder() - .sessionId(session.getId()) - .participantId(request.participantId()) - .turnId(nextTurn) // 계산된 turn 사용 - .role("USER") - .content(request.content()) - .context(context) - .build(); - - // AI 호출 (Sync) - SendMessageResponse aiResponse = aiChatService.sendMessage(aiRequest); - - if (aiResponse == null) { - throw new RuntimeException("AI 서버 응답이 null입니다."); - } - - // AI 메시지 저장 및 토큰 업데이트 - // AI 서버는 turn을 자동으로 증가시켜서 반환 (사용자 메시지 turn + 1) - Integer totalCount = aiResponse.totalCount(); - Integer aiTurnId = aiResponse.turnId(); // AI 서버가 반환한 turn (사용자 turn + 1) - Integer tokenCount = aiResponse.tokenCount(); // 현재 턴 토큰 수 - - // AI 응답 메시지 저장 - promptMessageService.create( - session.getId(), - aiTurnId, - aiResponse.role() != null ? aiResponse.role() : "AI", // AI 서버가 반환한 role - aiResponse.content() != null ? aiResponse.content() : "", - tokenCount != null ? tokenCount : 0, // 현재 턴 토큰 수 저장 - null // 메타데이터 저장 필요시 변환 - ); - - // 세션 토큰 사용량 업데이트 - // tokenCount는 현재 턴에서 사용한 토큰 수이므로 이를 세션에 누적 - if (tokenCount != null && tokenCount > 0) { - promptSessionService.addTokens(session.getId(), tokenCount); - } - - // ExamParticipant의 tokenUsed도 업데이트 (토큰 한도 체크용) - // tokenCount는 현재 턴에서 사용한 토큰 수이므로 이를 추가 - // 주의: totalCount는 전체 누적 값이므로 사용하지 않음 - if (tokenCount != null && tokenCount > 0) { - examParticipantService.addTokenUsed( - request.examId(), - request.participantId(), - tokenCount); - } - - // 참고: FastAPI에서 반환하는 totalToken은 전체 누적 토큰 수이며, - // FE에서 토큰 표시용으로 사용됩니다. 서버에서는 tokenCount만 누적합니다. - - // AI 응답 반환 - return aiResponse; - - } catch (Exception e) { - // AI 호출 실패 시 로그 남기고 예외 전파 - // 예외는 상위로 전파되어 500 에러로 반환됨 - throw new RuntimeException( - String.format("AI 서버 호출 실패: %s", e.getMessage()), e); - } + // 4. AI 호출 — 트랜잭션 밖 (DB 커넥션을 잡지 않음) + Map context = new HashMap<>(); + context.put("problemId", examParticipant.getAssignedProblemId()); + context.put("specVersion", examParticipant.getSpecId()); + + AISendMessageRequest aiRequest = AISendMessageRequest.builder() + .sessionId(session.getId()) + .participantId(request.participantId()) + .turnId(nextTurn) + .role("USER") + .content(request.content()) + .context(context) + .build(); + + SendMessageResponse aiResponse; + try { + aiResponse = aiChatService.sendMessage(aiRequest); + } catch (Exception e) { + throw new RuntimeException(String.format("AI 서버 호출 실패: %s", e.getMessage()), e); + } + if (aiResponse == null) { + throw new RuntimeException("AI 서버 응답이 null입니다."); } - // 사용자 메시지가 아닌 경우 (예: assistant 메시지를 직접 저장하는 경우) - // FE에서는 USER 메시지만 보내므로 이 경우는 발생하지 않지만, 안전을 위해 처리 - // null 반환 시 BaseResponse.onSuccess(null)이 호출되어 result가 null이 됨 - // FE에서는 이를 처리할 수 있지만, 명시적으로 에러를 반환하는 것이 좋음 - throw new RestApiException(GlobalErrorStatus._BAD_REQUEST); + // 5. 사후 저장 — 단일 트랜잭션 (원자성 보장) + chatResponsePersister.persistAiResponse( + session.getId(), request.examId(), request.participantId(), aiResponse); + + return aiResponse; } } diff --git a/src/main/java/com/yd/vibecode/domain/chat/domain/service/PromptMessageService.java b/src/main/java/com/yd/vibecode/domain/chat/domain/service/PromptMessageService.java index e274e14..1975a47 100644 --- a/src/main/java/com/yd/vibecode/domain/chat/domain/service/PromptMessageService.java +++ b/src/main/java/com/yd/vibecode/domain/chat/domain/service/PromptMessageService.java @@ -46,4 +46,16 @@ public Integer getNextTurn(Long sessionId) { Optional maxTurn = promptMessageRepository.findMaxTurnBySessionId(sessionId); return maxTurn.map(turn -> turn + 1).orElse(1); } + + /** + * turn 계산과 메시지 저장을 단일 트랜잭션으로 처리. + * getNextTurn + create를 별도 트랜잭션으로 분리하면 동시 요청이 같은 turn 값을 + * 읽어 (session_id, turn) 유니크 제약 위반이 발생할 수 있으므로 반드시 이 메서드를 사용한다. + */ + @Transactional + public PromptMessage createWithNextTurn(Long sessionId, String role, String content, + Integer tokenCount, String meta) { + Integer nextTurn = getNextTurn(sessionId); + return create(sessionId, nextTurn, role, content, tokenCount, meta); + } } diff --git a/src/main/java/com/yd/vibecode/domain/chat/ui/ChatMessageAsyncDispatcher.java b/src/main/java/com/yd/vibecode/domain/chat/ui/ChatMessageAsyncDispatcher.java new file mode 100644 index 0000000..c262e43 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/chat/ui/ChatMessageAsyncDispatcher.java @@ -0,0 +1,47 @@ +package com.yd.vibecode.domain.chat.ui; + +import java.util.Map; + +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import com.yd.vibecode.domain.chat.application.dto.request.SaveChatMessageRequest; +import com.yd.vibecode.domain.chat.application.dto.response.SendMessageResponse; +import com.yd.vibecode.domain.chat.application.usecase.SaveChatMessageUseCase; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 채팅 메시지 비동기 처리기. + * STOMP 인바운드 스레드를 즉시 반환시키기 위해 AI 처리를 별도 스레드 풀에서 수행하고, + * 결과(또는 에러)를 사용자 큐로 push 한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ChatMessageAsyncDispatcher { + + private final SaveChatMessageUseCase saveChatMessageUseCase; + private final SimpMessagingTemplate messagingTemplate; + + @Async("chatAsyncExecutor") + public void dispatch(SaveChatMessageRequest request) { + if (request == null || request.participantId() == null) { + log.warn("[WS Chat] Invalid chat request: participantId is null"); + return; + } + String userId = request.participantId().toString(); + try { + SendMessageResponse response = saveChatMessageUseCase.execute(request); + messagingTemplate.convertAndSendToUser(userId, "/queue/chat", response); + log.info("[WS Chat] AI response sent: userId={}, turnId={}", userId, response.turnId()); + } catch (Exception e) { + log.error("[WS Chat] Failed to process message: userId={}, error={}", userId, e.getMessage(), e); + messagingTemplate.convertAndSendToUser( + userId, "/queue/chat-error", + Map.of("error", true, "message", "AI 응답 처리 실패")); + } + } +} diff --git a/src/main/java/com/yd/vibecode/domain/chat/ui/ChatWebSocketController.java b/src/main/java/com/yd/vibecode/domain/chat/ui/ChatWebSocketController.java index 2cdb085..2294aeb 100644 --- a/src/main/java/com/yd/vibecode/domain/chat/ui/ChatWebSocketController.java +++ b/src/main/java/com/yd/vibecode/domain/chat/ui/ChatWebSocketController.java @@ -1,68 +1,44 @@ package com.yd.vibecode.domain.chat.ui; -import com.yd.vibecode.domain.chat.application.dto.request.SaveChatMessageRequest; -import com.yd.vibecode.domain.chat.application.dto.response.SendMessageResponse; -import com.yd.vibecode.domain.chat.application.usecase.SaveChatMessageUseCase; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import java.util.Map; + import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.core.task.TaskRejectedException; import org.springframework.stereotype.Controller; -import java.util.Map; +import com.yd.vibecode.domain.chat.application.dto.request.SaveChatMessageRequest; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @Slf4j @Controller @RequiredArgsConstructor public class ChatWebSocketController { - private final SaveChatMessageUseCase saveChatMessageUseCase; + private final ChatMessageAsyncDispatcher chatMessageAsyncDispatcher; private final SimpMessagingTemplate messagingTemplate; /** * 채팅 메시지 전송 (WebSocket) * 클라이언트 전송 경로: /app/chat.send - * 클라이언트 구독 경로: /user/queue/chat + * 클라이언트 구독 경로: /user/queue/chat (응답), /user/queue/chat-error (에러) + * + * STOMP 인바운드 스레드를 블로킹하지 않도록 즉시 비동기 디스패치한다. */ @MessageMapping("/chat.send") public void sendMessage(@Payload SaveChatMessageRequest request) { - log.info("[WS Chat] Message received: userId={}, content={}", - request.participantId(), request.content()); - + log.info("[WS Chat] Message received: userId={}", request.participantId()); try { - // 메시지 저장 및 AI 응답 생성 (기존 REST UseCase 재사용) - SendMessageResponse response = saveChatMessageUseCase.execute(request); - - // 응답을 보낸 사용자에게만 전송 (/user/queue/chat) - // Spring STOMP의 convertAndSendToUser는 사용자 식별자가 필요함. - // 여기서는 participantId를 String으로 변환하여 사용하거나, - // SecurityContext가 연동되어 있으면 Principal을 사용함. - // 일단 participantId를 명시적으로 사용하여 특정 큐로 보냄. - - String destination = "/queue/chat"; - messagingTemplate.convertAndSendToUser( - request.participantId().toString(), - destination, - response - ); - - log.info("[WS Chat] AI response sent: userId={}, turnId={}", - request.participantId(), response.turnId()); - - } catch (Exception e) { - log.error("[WS Chat] Failed to process message: {}", e.getMessage(), e); - // 에러 응답을 FE에 전송하여 무한 로딩 방지 + chatMessageAsyncDispatcher.dispatch(request); + } catch (TaskRejectedException e) { + log.error("[WS Chat] Executor queue full, rejecting request: userId={}", request.participantId()); if (request.participantId() != null) { - try { - messagingTemplate.convertAndSendToUser( - request.participantId().toString(), - "/queue/chat-error", - Map.of("error", true, "message", e.getMessage() != null ? e.getMessage() : "AI 응답 처리 실패") - ); - } catch (Exception sendError) { - log.error("[WS Chat] 에러 응답 전송 실패: {}", sendError.getMessage()); - } + messagingTemplate.convertAndSendToUser( + request.participantId().toString(), "/queue/chat-error", + Map.of("error", true, "message", "서버가 혼잡합니다. 잠시 후 다시 시도해주세요.")); } } } diff --git a/src/main/java/com/yd/vibecode/global/config/AsyncConfig.java b/src/main/java/com/yd/vibecode/global/config/AsyncConfig.java index 79bc4b1..bde9055 100644 --- a/src/main/java/com/yd/vibecode/global/config/AsyncConfig.java +++ b/src/main/java/com/yd/vibecode/global/config/AsyncConfig.java @@ -25,4 +25,15 @@ public Executor sseAsyncExecutor() { executor.initialize(); return executor; } + + @Bean(name = "chatAsyncExecutor") + public Executor chatAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(16); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("chat-async-"); + executor.initialize(); + return executor; + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 313328f..06a88cd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,6 +4,14 @@ spring: config: import: optional:classpath:application-secret.yml + # DB 커넥션 풀 설정 (HikariCP 기본값 10개 → 명시적 설정) + datasource: + hikari: + pool-name: vibecode-hikari + maximum-pool-size: ${DB_POOL_MAX_SIZE:20} + minimum-idle: ${DB_POOL_MIN_IDLE:5} + connection-timeout: 20000 # 풀 고갈 시 무한 대기 대신 20초 후 빠른 실패 + # Redis 설정 data: redis: diff --git a/src/test/java/com/yd/vibecode/domain/chat/application/usecase/SaveChatMessageUseCaseTxTest.java b/src/test/java/com/yd/vibecode/domain/chat/application/usecase/SaveChatMessageUseCaseTxTest.java new file mode 100644 index 0000000..c991761 --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/chat/application/usecase/SaveChatMessageUseCaseTxTest.java @@ -0,0 +1,86 @@ +package com.yd.vibecode.domain.chat.application.usecase; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import com.yd.vibecode.domain.chat.application.dto.request.SaveChatMessageRequest; +import com.yd.vibecode.domain.chat.application.dto.response.SendMessageResponse; +import com.yd.vibecode.domain.chat.domain.entity.PromptMessage; +import com.yd.vibecode.domain.chat.domain.repository.PromptMessageRepository; +import com.yd.vibecode.domain.chat.domain.service.PromptMessageService; +import com.yd.vibecode.domain.chat.domain.service.PromptSessionService; +import com.yd.vibecode.domain.chat.infrastructure.AIChatService; +import com.yd.vibecode.domain.exam.domain.entity.ExamParticipant; +import com.yd.vibecode.domain.exam.domain.repository.ExamParticipantRepository; +import com.yd.vibecode.domain.exam.domain.service.ExamParticipantService; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** + * SaveChatMessageUseCase 의 트랜잭션 경계 검증. + * + * 핵심: 동기 AI HTTP 호출이 DB 트랜잭션(=커넥션) 밖에서 일어나야 한다. + * 그렇지 않으면 AI 응답(최대 90초)을 기다리는 동안 커넥션을 점유해 풀이 고갈된다. + */ +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +// application-secret.yml 이 상속시키는 default_schema(ai_vibe_coding_test)를 비워 H2 기본 스키마 사용 +@TestPropertySource(properties = "spring.jpa.properties.hibernate.default_schema=") +@Import({SaveChatMessageUseCase.class, ChatResponsePersister.class, + PromptSessionService.class, PromptMessageService.class, ExamParticipantService.class}) +class SaveChatMessageUseCaseTxTest { + + @Autowired private SaveChatMessageUseCase saveChatMessageUseCase; + @Autowired private ExamParticipantRepository examParticipantRepository; + @Autowired private PromptMessageRepository promptMessageRepository; + + @MockBean private AIChatService aiChatService; + + @Test + @DisplayName("AI 호출 시점에 활성 트랜잭션이 없어야 한다(커넥션 비점유)") + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void aiCallRunsOutsideTransaction() { + // given: 참가자 시드 (specId, assignedProblemId 필수) + examParticipantRepository.save( + ExamParticipant.builder() + .examId(1L).participantId(10L) + .specId(100L).tokenLimit(20000).tokenUsed(0) + .assignedProblemId(1000L) + .build()); + + AtomicBoolean txActiveDuringAiCall = new AtomicBoolean(true); + given(aiChatService.sendMessage(any())).willAnswer(inv -> { + txActiveDuringAiCall.set(TransactionSynchronizationManager.isActualTransactionActive()); + return new SendMessageResponse(1L, 2, "AI", "answer", 50, 150); + }); + + SaveChatMessageRequest request = new SaveChatMessageRequest( + null, 1L, 10L, 1, "user", "질문입니다", null, null); + + // when + SendMessageResponse response = saveChatMessageUseCase.execute(request); + + // then: AI 호출 중 활성 트랜잭션이 없어야 한다 + assertThat(txActiveDuringAiCall.get()).isFalse(); + assertThat(response.content()).isEqualTo("answer"); + + // 사용자 메시지 + AI 메시지 모두 영속화 + List messages = promptMessageRepository.findAll(); + assertThat(messages).extracting(PromptMessage::getRole) + .containsExactlyInAnyOrder("user", "AI"); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/chat/ui/ChatMessageAsyncDispatcherTest.java b/src/test/java/com/yd/vibecode/domain/chat/ui/ChatMessageAsyncDispatcherTest.java new file mode 100644 index 0000000..871feab --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/chat/ui/ChatMessageAsyncDispatcherTest.java @@ -0,0 +1,51 @@ +package com.yd.vibecode.domain.chat.ui; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.yd.vibecode.domain.chat.application.dto.request.SaveChatMessageRequest; +import com.yd.vibecode.domain.chat.application.dto.response.SendMessageResponse; +import com.yd.vibecode.domain.chat.application.usecase.SaveChatMessageUseCase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.simp.SimpMessagingTemplate; + +@ExtendWith(MockitoExtension.class) +class ChatMessageAsyncDispatcherTest { + + @InjectMocks private ChatMessageAsyncDispatcher dispatcher; + @Mock private SaveChatMessageUseCase saveChatMessageUseCase; + @Mock private SimpMessagingTemplate messagingTemplate; + + private SaveChatMessageRequest request() { + return new SaveChatMessageRequest(null, 1L, 10L, 1, "user", "질문", null, null); + } + + @Test + @DisplayName("성공 시 /queue/chat 으로 응답을 push 한다") + void dispatchSuccess() { + SendMessageResponse response = new SendMessageResponse(1L, 2, "AI", "answer", 50, 150); + given(saveChatMessageUseCase.execute(any())).willReturn(response); + + dispatcher.dispatch(request()); + + verify(messagingTemplate).convertAndSendToUser(eq("10"), eq("/queue/chat"), eq(response)); + } + + @Test + @DisplayName("실패 시 /queue/chat-error 로 에러를 push 한다") + void dispatchFailure() { + given(saveChatMessageUseCase.execute(any())) + .willThrow(new RuntimeException("Could not open JPA EntityManager for transaction")); + + dispatcher.dispatch(request()); + + verify(messagingTemplate).convertAndSendToUser(eq("10"), eq("/queue/chat-error"), any(Object.class)); + } +}