Skip to content

Commit 7170187

Browse files
authored
refactor: 채팅 메시지 커밋 후 방송 (#859)
* refactor: 채팅 메시지 커밋 후 방송 * test: 커밋 전 채팅 방송 회귀 검증
1 parent 85a7b57 commit 7170187

5 files changed

Lines changed: 201 additions & 11 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.example.solidconnection.chat.event;
2+
3+
import com.example.solidconnection.chat.dto.ChatMessageSendResponse;
4+
5+
public record ChatMessageBroadcastEvent(
6+
long roomId,
7+
ChatMessageSendResponse response
8+
) {
9+
10+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.example.solidconnection.chat.event;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.springframework.messaging.simp.SimpMessageSendingOperations;
6+
import org.springframework.stereotype.Component;
7+
import org.springframework.transaction.event.TransactionPhase;
8+
import org.springframework.transaction.event.TransactionalEventListener;
9+
10+
@Component
11+
@RequiredArgsConstructor
12+
@Slf4j
13+
public class ChatMessageBroadcastEventListener {
14+
15+
private final SimpMessageSendingOperations simpMessageSendingOperations;
16+
17+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
18+
public void broadcast(ChatMessageBroadcastEvent event) {
19+
try {
20+
simpMessageSendingOperations.convertAndSend(
21+
"/topic/chat/" + event.roomId(),
22+
event.response()
23+
);
24+
} catch (RuntimeException e) {
25+
log.error("채팅 메시지를 방송하지 못했습니다. roomId={}, messageId={}",
26+
event.roomId(), event.response().messageId(), e);
27+
}
28+
}
29+
}

‎src/main/java/com/example/solidconnection/chat/service/ChatService.java‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import com.example.solidconnection.chat.dto.ChatRoomData;
1919
import com.example.solidconnection.chat.dto.ChatRoomListResponse;
2020
import com.example.solidconnection.chat.dto.ChatRoomResponse;
21+
import com.example.solidconnection.chat.event.ChatMessageBroadcastEvent;
2122
import com.example.solidconnection.chat.repository.ChatMessageRepository;
2223
import com.example.solidconnection.chat.repository.ChatParticipantRepository;
2324
import com.example.solidconnection.chat.repository.ChatReadStatusRepository;
@@ -34,10 +35,9 @@
3435
import java.util.Set;
3536
import java.util.function.Function;
3637
import java.util.stream.Collectors;
37-
import org.springframework.context.annotation.Lazy;
38+
import org.springframework.context.ApplicationEventPublisher;
3839
import org.springframework.data.domain.Pageable;
3940
import org.springframework.data.domain.Slice;
40-
import org.springframework.messaging.simp.SimpMessageSendingOperations;
4141
import org.springframework.stereotype.Service;
4242
import org.springframework.transaction.annotation.Transactional;
4343
import org.springframework.util.StringUtils;
@@ -52,22 +52,22 @@ public class ChatService {
5252
private final SiteUserRepository siteUserRepository;
5353
private final MentorRepository mentorRepository;
5454

55-
private final SimpMessageSendingOperations simpMessageSendingOperations;
55+
private final ApplicationEventPublisher eventPublisher;
5656

5757
public ChatService(ChatRoomRepository chatRoomRepository,
5858
ChatMessageRepository chatMessageRepository,
5959
ChatParticipantRepository chatParticipantRepository,
6060
ChatReadStatusRepository chatReadStatusRepository,
6161
SiteUserRepository siteUserRepository,
6262
MentorRepository mentorRepository,
63-
@Lazy SimpMessageSendingOperations simpMessageSendingOperations) {
63+
ApplicationEventPublisher eventPublisher) {
6464
this.chatRoomRepository = chatRoomRepository;
6565
this.chatMessageRepository = chatMessageRepository;
6666
this.chatParticipantRepository = chatParticipantRepository;
6767
this.chatReadStatusRepository = chatReadStatusRepository;
6868
this.siteUserRepository = siteUserRepository;
6969
this.mentorRepository = mentorRepository;
70-
this.simpMessageSendingOperations = simpMessageSendingOperations;
70+
this.eventPublisher = eventPublisher;
7171
}
7272

7373
@Transactional(readOnly = true)
@@ -230,7 +230,7 @@ public void sendChatMessage(ChatMessageSendRequest chatMessageSendRequest, long
230230

231231
ChatMessageSendResponse chatMessageResponse = ChatMessageSendResponse.of(chatMessage, siteUserId);
232232

233-
simpMessageSendingOperations.convertAndSend("/topic/chat/" + roomId, chatMessageResponse);
233+
eventPublisher.publishEvent(new ChatMessageBroadcastEvent(roomId, chatMessageResponse));
234234
}
235235

236236
@Transactional
@@ -261,7 +261,7 @@ public void sendChatImage(ChatImageSendRequest chatImageSendRequest, long siteUs
261261
chatMessageRepository.save(chatMessage);
262262

263263
ChatMessageSendResponse chatMessageResponse = ChatMessageSendResponse.of(chatMessage, siteUserId);
264-
simpMessageSendingOperations.convertAndSend("/topic/chat/" + roomId, chatMessageResponse);
264+
eventPublisher.publishEvent(new ChatMessageBroadcastEvent(roomId, chatMessageResponse));
265265
}
266266

267267
private String generateThumbnailUrl(String originalUrl) {
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package com.example.solidconnection.chat.event;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatCode;
5+
import static org.junit.jupiter.api.Assertions.assertAll;
6+
import static org.mockito.Mockito.mock;
7+
8+
import ch.qos.logback.classic.Logger;
9+
import ch.qos.logback.classic.spi.ILoggingEvent;
10+
import ch.qos.logback.core.read.ListAppender;
11+
import com.example.solidconnection.chat.domain.MessageType;
12+
import com.example.solidconnection.chat.dto.ChatMessageSendResponse;
13+
import java.util.List;
14+
import org.junit.jupiter.api.AfterEach;
15+
import org.junit.jupiter.api.BeforeEach;
16+
import org.junit.jupiter.api.DisplayName;
17+
import org.junit.jupiter.api.Test;
18+
import org.mockito.BDDMockito;
19+
import org.slf4j.LoggerFactory;
20+
import org.springframework.messaging.simp.SimpMessageSendingOperations;
21+
22+
@DisplayName("채팅 메시지 방송 이벤트 리스너 테스트")
23+
class ChatMessageBroadcastEventListenerTest {
24+
25+
private SimpMessageSendingOperations simpMessageSendingOperations;
26+
private ChatMessageBroadcastEventListener listener;
27+
private ListAppender<ILoggingEvent> listAppender;
28+
private Logger logger;
29+
30+
@BeforeEach
31+
void setUp() {
32+
simpMessageSendingOperations = mock(SimpMessageSendingOperations.class);
33+
listener = new ChatMessageBroadcastEventListener(simpMessageSendingOperations);
34+
35+
logger = (Logger) LoggerFactory.getLogger(ChatMessageBroadcastEventListener.class);
36+
listAppender = new ListAppender<>();
37+
listAppender.start();
38+
logger.addAppender(listAppender);
39+
}
40+
41+
@AfterEach
42+
void tearDown() {
43+
logger.detachAppender(listAppender);
44+
listAppender.stop();
45+
}
46+
47+
@Test
48+
void 방송에_실패하면_예외를_전파하지_않고_로그를_남긴다() {
49+
// given
50+
long roomId = 1L;
51+
long messageId = 2L;
52+
ChatMessageSendResponse response = new ChatMessageSendResponse(
53+
messageId,
54+
"메시지",
55+
3L,
56+
MessageType.TEXT,
57+
List.of()
58+
);
59+
ChatMessageBroadcastEvent event = new ChatMessageBroadcastEvent(roomId, response);
60+
RuntimeException exception = new RuntimeException("broadcast failed");
61+
BDDMockito.willThrow(exception)
62+
.given(simpMessageSendingOperations)
63+
.convertAndSend("/topic/chat/" + roomId, response);
64+
65+
// when & then
66+
assertThatCode(() -> listener.broadcast(event)).doesNotThrowAnyException();
67+
68+
ILoggingEvent logEvent = listAppender.list.stream()
69+
.filter(eventLog -> eventLog.getFormattedMessage().contains("채팅 메시지를 방송하지 못했습니다."))
70+
.findFirst()
71+
.orElseThrow();
72+
assertAll(
73+
() -> assertThat(logEvent.getLevel().toString()).isEqualTo("ERROR"),
74+
() -> assertThat(logEvent.getFormattedMessage()).contains("roomId=1", "messageId=2"),
75+
() -> assertThat(logEvent.getThrowableProxy()).isNotNull(),
76+
() -> assertThat(logEvent.getThrowableProxy().getMessage()).isEqualTo("broadcast failed")
77+
);
78+
}
79+
}

‎src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java‎

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import com.example.solidconnection.chat.fixture.ChatParticipantFixture;
2424
import com.example.solidconnection.chat.fixture.ChatReadStatusFixture;
2525
import com.example.solidconnection.chat.fixture.ChatRoomFixture;
26+
import com.example.solidconnection.chat.repository.ChatMessageRepository;
2627
import com.example.solidconnection.chat.repository.ChatReadStatusRepositoryForTest;
2728
import com.example.solidconnection.common.dto.SliceResponse;
2829
import com.example.solidconnection.common.exception.CustomException;
@@ -43,6 +44,9 @@
4344
import org.springframework.data.domain.Sort;
4445
import org.springframework.messaging.simp.SimpMessagingTemplate;
4546
import org.springframework.test.context.bean.override.mockito.MockitoBean;
47+
import org.springframework.transaction.support.TransactionSynchronization;
48+
import org.springframework.transaction.support.TransactionSynchronizationManager;
49+
import org.springframework.transaction.support.TransactionTemplate;
4650

4751
@TestContainerSpringBootTest
4852
@DisplayName("채팅 서비스 테스트")
@@ -54,6 +58,12 @@ class ChatServiceTest {
5458
@Autowired
5559
private ChatReadStatusRepositoryForTest chatReadStatusRepositoryForTest;
5660

61+
@Autowired
62+
private ChatMessageRepository chatMessageRepository;
63+
64+
@Autowired
65+
private TransactionTemplate transactionTemplate;
66+
5767
@Autowired
5868
private SiteUserFixture siteUserFixture;
5969

@@ -418,9 +428,19 @@ void setUp() {
418428
// given
419429
final String content = "안녕하세요";
420430
ChatMessageSendRequest request = new ChatMessageSendRequest(content);
431+
long messageCount = chatMessageRepository.count();
421432

422433
// when
423-
chatService.sendChatMessage(request, sender.getId(), chatRoom.getId());
434+
transactionTemplate.executeWithoutResult(status -> {
435+
chatService.sendChatMessage(request, sender.getId(), chatRoom.getId());
436+
BDDMockito.then(simpMessagingTemplate).shouldHaveNoInteractions();
437+
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
438+
@Override
439+
public void beforeCommit(boolean readOnly) {
440+
BDDMockito.then(simpMessagingTemplate).shouldHaveNoInteractions();
441+
}
442+
});
443+
});
424444

425445
// then
426446
ArgumentCaptor<String> destinationCaptor = ArgumentCaptor.forClass(String.class);
@@ -431,7 +451,28 @@ void setUp() {
431451
assertAll(
432452
() -> assertThat(destinationCaptor.getValue()).isEqualTo("/topic/chat/" + chatRoom.getId()),
433453
() -> assertThat(payloadCaptor.getValue().content()).isEqualTo(content),
434-
() -> assertThat(payloadCaptor.getValue().siteUserId()).isEqualTo(sender.getId())
454+
() -> assertThat(payloadCaptor.getValue().siteUserId()).isEqualTo(sender.getId()),
455+
() -> assertThat(chatMessageRepository.count()).isEqualTo(messageCount + 1)
456+
);
457+
}
458+
459+
@Test
460+
void 트랜잭션이_롤백되면_메시지를_저장하거나_방송하지_않는다() {
461+
// given
462+
ChatMessageSendRequest request = new ChatMessageSendRequest("롤백 메시지");
463+
long messageCount = chatMessageRepository.count();
464+
465+
// when
466+
transactionTemplate.executeWithoutResult(status -> {
467+
chatService.sendChatMessage(request, sender.getId(), chatRoom.getId());
468+
BDDMockito.then(simpMessagingTemplate).shouldHaveNoInteractions();
469+
status.setRollbackOnly();
470+
});
471+
472+
// then
473+
assertAll(
474+
() -> BDDMockito.then(simpMessagingTemplate).shouldHaveNoInteractions(),
475+
() -> assertThat(chatMessageRepository.count()).isEqualTo(messageCount)
435476
);
436477
}
437478

@@ -473,9 +514,19 @@ void setUp() {
473514
TEST_IMAGE_URL2
474515
);
475516
ChatImageSendRequest request = new ChatImageSendRequest(imageUrls);
517+
long messageCount = chatMessageRepository.count();
476518

477519
// when
478-
chatService.sendChatImage(request, sender.getId(), chatRoom.getId());
520+
transactionTemplate.executeWithoutResult(status -> {
521+
chatService.sendChatImage(request, sender.getId(), chatRoom.getId());
522+
BDDMockito.then(simpMessagingTemplate).shouldHaveNoInteractions();
523+
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
524+
@Override
525+
public void beforeCommit(boolean readOnly) {
526+
BDDMockito.then(simpMessagingTemplate).shouldHaveNoInteractions();
527+
}
528+
});
529+
});
479530

480531
// then
481532
ArgumentCaptor<String> destinationCaptor = ArgumentCaptor.forClass(String.class);
@@ -492,7 +543,28 @@ void setUp() {
492543
() -> assertThat(response.attachments().get(1).url()).isEqualTo(imageUrls.get(1)),
493544
() -> assertThat(response.messageType()).isEqualTo(MessageType.IMAGE),
494545
() -> assertThat(response.siteUserId()).isEqualTo(sender.getId()),
495-
() -> assertThat(response.content()).isEmpty()
546+
() -> assertThat(response.content()).isEmpty(),
547+
() -> assertThat(chatMessageRepository.count()).isEqualTo(messageCount + 1)
548+
);
549+
}
550+
551+
@Test
552+
void 트랜잭션이_롤백되면_이미지_메시지를_저장하거나_방송하지_않는다() {
553+
// given
554+
ChatImageSendRequest request = new ChatImageSendRequest(List.of(TEST_IMAGE_URL));
555+
long messageCount = chatMessageRepository.count();
556+
557+
// when
558+
transactionTemplate.executeWithoutResult(status -> {
559+
chatService.sendChatImage(request, sender.getId(), chatRoom.getId());
560+
BDDMockito.then(simpMessagingTemplate).shouldHaveNoInteractions();
561+
status.setRollbackOnly();
562+
});
563+
564+
// then
565+
assertAll(
566+
() -> BDDMockito.then(simpMessagingTemplate).shouldHaveNoInteractions(),
567+
() -> assertThat(chatMessageRepository.count()).isEqualTo(messageCount)
496568
);
497569
}
498570

0 commit comments

Comments
 (0)