From 93b67d43d15916081ab243264cf0b206d971840d Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Sun, 1 Nov 2026 21:59:37 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20=EC=B1=84=ED=8C=85=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=BB=A4=EB=B0=8B=20=ED=9B=84=20=EB=B0=A9?= =?UTF-8?q?=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/event/ChatMessageBroadcastEvent.java | 10 +++ .../ChatMessageBroadcastEventListener.java | 29 +++++++ .../chat/service/ChatService.java | 14 ++-- ...ChatMessageBroadcastEventListenerTest.java | 79 +++++++++++++++++++ .../chat/service/ChatServiceTest.java | 66 +++++++++++++++- 5 files changed, 187 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/chat/event/ChatMessageBroadcastEvent.java create mode 100644 src/main/java/com/example/solidconnection/chat/event/ChatMessageBroadcastEventListener.java create mode 100644 src/test/java/com/example/solidconnection/chat/event/ChatMessageBroadcastEventListenerTest.java diff --git a/src/main/java/com/example/solidconnection/chat/event/ChatMessageBroadcastEvent.java b/src/main/java/com/example/solidconnection/chat/event/ChatMessageBroadcastEvent.java new file mode 100644 index 000000000..72c06f57f --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/event/ChatMessageBroadcastEvent.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.chat.event; + +import com.example.solidconnection.chat.dto.ChatMessageSendResponse; + +public record ChatMessageBroadcastEvent( + long roomId, + ChatMessageSendResponse response +) { + +} diff --git a/src/main/java/com/example/solidconnection/chat/event/ChatMessageBroadcastEventListener.java b/src/main/java/com/example/solidconnection/chat/event/ChatMessageBroadcastEventListener.java new file mode 100644 index 000000000..3a3c65040 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/event/ChatMessageBroadcastEventListener.java @@ -0,0 +1,29 @@ +package com.example.solidconnection.chat.event; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ChatMessageBroadcastEventListener { + + private final SimpMessageSendingOperations simpMessageSendingOperations; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void broadcast(ChatMessageBroadcastEvent event) { + try { + simpMessageSendingOperations.convertAndSend( + "/topic/chat/" + event.roomId(), + event.response() + ); + } catch (RuntimeException e) { + log.error("채팅 메시지를 방송하지 못했습니다. roomId={}, messageId={}", + event.roomId(), event.response().messageId(), e); + } + } +} diff --git a/src/main/java/com/example/solidconnection/chat/service/ChatService.java b/src/main/java/com/example/solidconnection/chat/service/ChatService.java index 11560f434..66ab3476a 100644 --- a/src/main/java/com/example/solidconnection/chat/service/ChatService.java +++ b/src/main/java/com/example/solidconnection/chat/service/ChatService.java @@ -18,6 +18,7 @@ import com.example.solidconnection.chat.dto.ChatRoomData; import com.example.solidconnection.chat.dto.ChatRoomListResponse; import com.example.solidconnection.chat.dto.ChatRoomResponse; +import com.example.solidconnection.chat.event.ChatMessageBroadcastEvent; import com.example.solidconnection.chat.repository.ChatMessageRepository; import com.example.solidconnection.chat.repository.ChatParticipantRepository; import com.example.solidconnection.chat.repository.ChatReadStatusRepository; @@ -34,10 +35,9 @@ import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; -import org.springframework.context.annotation.Lazy; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; -import org.springframework.messaging.simp.SimpMessageSendingOperations; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -52,7 +52,7 @@ public class ChatService { private final SiteUserRepository siteUserRepository; private final MentorRepository mentorRepository; - private final SimpMessageSendingOperations simpMessageSendingOperations; + private final ApplicationEventPublisher eventPublisher; public ChatService(ChatRoomRepository chatRoomRepository, ChatMessageRepository chatMessageRepository, @@ -60,14 +60,14 @@ public ChatService(ChatRoomRepository chatRoomRepository, ChatReadStatusRepository chatReadStatusRepository, SiteUserRepository siteUserRepository, MentorRepository mentorRepository, - @Lazy SimpMessageSendingOperations simpMessageSendingOperations) { + ApplicationEventPublisher eventPublisher) { this.chatRoomRepository = chatRoomRepository; this.chatMessageRepository = chatMessageRepository; this.chatParticipantRepository = chatParticipantRepository; this.chatReadStatusRepository = chatReadStatusRepository; this.siteUserRepository = siteUserRepository; this.mentorRepository = mentorRepository; - this.simpMessageSendingOperations = simpMessageSendingOperations; + this.eventPublisher = eventPublisher; } @Transactional(readOnly = true) @@ -230,7 +230,7 @@ public void sendChatMessage(ChatMessageSendRequest chatMessageSendRequest, long ChatMessageSendResponse chatMessageResponse = ChatMessageSendResponse.of(chatMessage, siteUserId); - simpMessageSendingOperations.convertAndSend("/topic/chat/" + roomId, chatMessageResponse); + eventPublisher.publishEvent(new ChatMessageBroadcastEvent(roomId, chatMessageResponse)); } @Transactional @@ -261,7 +261,7 @@ public void sendChatImage(ChatImageSendRequest chatImageSendRequest, long siteUs chatMessageRepository.save(chatMessage); ChatMessageSendResponse chatMessageResponse = ChatMessageSendResponse.of(chatMessage, siteUserId); - simpMessageSendingOperations.convertAndSend("/topic/chat/" + roomId, chatMessageResponse); + eventPublisher.publishEvent(new ChatMessageBroadcastEvent(roomId, chatMessageResponse)); } private String generateThumbnailUrl(String originalUrl) { diff --git a/src/test/java/com/example/solidconnection/chat/event/ChatMessageBroadcastEventListenerTest.java b/src/test/java/com/example/solidconnection/chat/event/ChatMessageBroadcastEventListenerTest.java new file mode 100644 index 000000000..2cbad42c1 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/event/ChatMessageBroadcastEventListenerTest.java @@ -0,0 +1,79 @@ +package com.example.solidconnection.chat.event; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.mock; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.example.solidconnection.chat.domain.MessageType; +import com.example.solidconnection.chat.dto.ChatMessageSendResponse; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.slf4j.LoggerFactory; +import org.springframework.messaging.simp.SimpMessageSendingOperations; + +@DisplayName("채팅 메시지 방송 이벤트 리스너 테스트") +class ChatMessageBroadcastEventListenerTest { + + private SimpMessageSendingOperations simpMessageSendingOperations; + private ChatMessageBroadcastEventListener listener; + private ListAppender listAppender; + private Logger logger; + + @BeforeEach + void setUp() { + simpMessageSendingOperations = mock(SimpMessageSendingOperations.class); + listener = new ChatMessageBroadcastEventListener(simpMessageSendingOperations); + + logger = (Logger) LoggerFactory.getLogger(ChatMessageBroadcastEventListener.class); + listAppender = new ListAppender<>(); + listAppender.start(); + logger.addAppender(listAppender); + } + + @AfterEach + void tearDown() { + logger.detachAppender(listAppender); + listAppender.stop(); + } + + @Test + void 방송에_실패하면_예외를_전파하지_않고_로그를_남긴다() { + // given + long roomId = 1L; + long messageId = 2L; + ChatMessageSendResponse response = new ChatMessageSendResponse( + messageId, + "메시지", + 3L, + MessageType.TEXT, + List.of() + ); + ChatMessageBroadcastEvent event = new ChatMessageBroadcastEvent(roomId, response); + RuntimeException exception = new RuntimeException("broadcast failed"); + BDDMockito.willThrow(exception) + .given(simpMessageSendingOperations) + .convertAndSend("/topic/chat/" + roomId, response); + + // when & then + assertThatCode(() -> listener.broadcast(event)).doesNotThrowAnyException(); + + ILoggingEvent logEvent = listAppender.list.stream() + .filter(eventLog -> eventLog.getFormattedMessage().contains("채팅 메시지를 방송하지 못했습니다.")) + .findFirst() + .orElseThrow(); + assertAll( + () -> assertThat(logEvent.getLevel().toString()).isEqualTo("ERROR"), + () -> assertThat(logEvent.getFormattedMessage()).contains("roomId=1", "messageId=2"), + () -> assertThat(logEvent.getThrowableProxy()).isNotNull(), + () -> assertThat(logEvent.getThrowableProxy().getMessage()).isEqualTo("broadcast failed") + ); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java b/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java index 614769e84..2cfe3ec19 100644 --- a/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java +++ b/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java @@ -23,6 +23,7 @@ import com.example.solidconnection.chat.fixture.ChatParticipantFixture; import com.example.solidconnection.chat.fixture.ChatReadStatusFixture; import com.example.solidconnection.chat.fixture.ChatRoomFixture; +import com.example.solidconnection.chat.repository.ChatMessageRepository; import com.example.solidconnection.chat.repository.ChatReadStatusRepositoryForTest; import com.example.solidconnection.common.dto.SliceResponse; import com.example.solidconnection.common.exception.CustomException; @@ -43,6 +44,7 @@ import org.springframework.data.domain.Sort; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.support.TransactionTemplate; @TestContainerSpringBootTest @DisplayName("채팅 서비스 테스트") @@ -54,6 +56,12 @@ class ChatServiceTest { @Autowired private ChatReadStatusRepositoryForTest chatReadStatusRepositoryForTest; + @Autowired + private ChatMessageRepository chatMessageRepository; + + @Autowired + private TransactionTemplate transactionTemplate; + @Autowired private SiteUserFixture siteUserFixture; @@ -418,9 +426,13 @@ void setUp() { // given final String content = "안녕하세요"; ChatMessageSendRequest request = new ChatMessageSendRequest(content); + long messageCount = chatMessageRepository.count(); // when - chatService.sendChatMessage(request, sender.getId(), chatRoom.getId()); + transactionTemplate.executeWithoutResult(status -> { + chatService.sendChatMessage(request, sender.getId(), chatRoom.getId()); + BDDMockito.then(simpMessagingTemplate).shouldHaveNoInteractions(); + }); // then ArgumentCaptor destinationCaptor = ArgumentCaptor.forClass(String.class); @@ -431,7 +443,28 @@ void setUp() { assertAll( () -> assertThat(destinationCaptor.getValue()).isEqualTo("/topic/chat/" + chatRoom.getId()), () -> assertThat(payloadCaptor.getValue().content()).isEqualTo(content), - () -> assertThat(payloadCaptor.getValue().siteUserId()).isEqualTo(sender.getId()) + () -> assertThat(payloadCaptor.getValue().siteUserId()).isEqualTo(sender.getId()), + () -> assertThat(chatMessageRepository.count()).isEqualTo(messageCount + 1) + ); + } + + @Test + void 트랜잭션이_롤백되면_메시지를_저장하거나_방송하지_않는다() { + // given + ChatMessageSendRequest request = new ChatMessageSendRequest("롤백 메시지"); + long messageCount = chatMessageRepository.count(); + + // when + transactionTemplate.executeWithoutResult(status -> { + chatService.sendChatMessage(request, sender.getId(), chatRoom.getId()); + BDDMockito.then(simpMessagingTemplate).shouldHaveNoInteractions(); + status.setRollbackOnly(); + }); + + // then + assertAll( + () -> BDDMockito.then(simpMessagingTemplate).shouldHaveNoInteractions(), + () -> assertThat(chatMessageRepository.count()).isEqualTo(messageCount) ); } @@ -473,9 +506,13 @@ void setUp() { TEST_IMAGE_URL2 ); ChatImageSendRequest request = new ChatImageSendRequest(imageUrls); + long messageCount = chatMessageRepository.count(); // when - chatService.sendChatImage(request, sender.getId(), chatRoom.getId()); + transactionTemplate.executeWithoutResult(status -> { + chatService.sendChatImage(request, sender.getId(), chatRoom.getId()); + BDDMockito.then(simpMessagingTemplate).shouldHaveNoInteractions(); + }); // then ArgumentCaptor destinationCaptor = ArgumentCaptor.forClass(String.class); @@ -492,7 +529,28 @@ void setUp() { () -> assertThat(response.attachments().get(1).url()).isEqualTo(imageUrls.get(1)), () -> assertThat(response.messageType()).isEqualTo(MessageType.IMAGE), () -> assertThat(response.siteUserId()).isEqualTo(sender.getId()), - () -> assertThat(response.content()).isEmpty() + () -> assertThat(response.content()).isEmpty(), + () -> assertThat(chatMessageRepository.count()).isEqualTo(messageCount + 1) + ); + } + + @Test + void 트랜잭션이_롤백되면_이미지_메시지를_저장하거나_방송하지_않는다() { + // given + ChatImageSendRequest request = new ChatImageSendRequest(List.of(TEST_IMAGE_URL)); + long messageCount = chatMessageRepository.count(); + + // when + transactionTemplate.executeWithoutResult(status -> { + chatService.sendChatImage(request, sender.getId(), chatRoom.getId()); + BDDMockito.then(simpMessagingTemplate).shouldHaveNoInteractions(); + status.setRollbackOnly(); + }); + + // then + assertAll( + () -> BDDMockito.then(simpMessagingTemplate).shouldHaveNoInteractions(), + () -> assertThat(chatMessageRepository.count()).isEqualTo(messageCount) ); } From b82f21a9717589dd96609e84997b3de206f2e553 Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Sun, 1 Nov 2026 22:25:56 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test:=20=EC=BB=A4=EB=B0=8B=20=EC=A0=84=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EB=B0=A9=EC=86=A1=20=ED=9A=8C=EA=B7=80=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/service/ChatServiceTest.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java b/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java index 2cfe3ec19..88c88a493 100644 --- a/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java +++ b/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java @@ -44,6 +44,8 @@ import org.springframework.data.domain.Sort; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionTemplate; @TestContainerSpringBootTest @@ -432,6 +434,12 @@ void setUp() { transactionTemplate.executeWithoutResult(status -> { chatService.sendChatMessage(request, sender.getId(), chatRoom.getId()); BDDMockito.then(simpMessagingTemplate).shouldHaveNoInteractions(); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void beforeCommit(boolean readOnly) { + BDDMockito.then(simpMessagingTemplate).shouldHaveNoInteractions(); + } + }); }); // then @@ -512,6 +520,12 @@ void setUp() { transactionTemplate.executeWithoutResult(status -> { chatService.sendChatImage(request, sender.getId(), chatRoom.getId()); BDDMockito.then(simpMessagingTemplate).shouldHaveNoInteractions(); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void beforeCommit(boolean readOnly) { + BDDMockito.then(simpMessagingTemplate).shouldHaveNoInteractions(); + } + }); }); // then