
먼저 맞추면 이기는 채팅 퀴즈 게임 <빨랐죠>
https://github.com/gyehyun-bak/ppalatjyo
개인 프로젝트를 진행하는 중 Spring 프레임워크로 웹소켓 메시지를 통해 사용자에게 채팅 메시지를 비롯해 다양한 이벤트를 전달하는 시스템을 구현하였습니다.
그러나 트랜잭션이 진행 중인 메서드 안에서 SimpMessagingTemplate 등을 이용해 메시지를 발행하면 트랜잭션이 롤백되더라도 메시지가 발행되어버리는 문제점을 발견하였습니다.
이를 위해서 웹소켓 메시지를 현재 트랜잭션 커밋 후에 발행할 수 있도록 구현하는 방법을 설명하고, AOP를 활용해 이를 보다 나은 코드로 개선하여 10개 넘는 클래스를 하나의 Aspect 클래스로 줄인 경험을 정리해봅니다.
(글을 다 쓰고, 나중에서야 "트랜잭셔널 아웃박스 패턴"이라는 게 따로 있음을 알게 되었습니다... 이벤트-트랜잭션 간 정합성 문제 해결을 원하시는 분은 관련해서 검색해보시기 바랍니다!)
다음은 설명을 위해 기존 프로젝트 코드를 단순화한 예제입니다. 다른 요구사항을 빼고, 메시지를 저장하고 채팅방에 보내는 기능만 포함합니다.
public void sendChatMessage(Long memberId, Long chatRoomId, String content) {
Member member = memberRepository.findById(memberId).orElseThrow();
ChatRoom chatRoom = chatRoomRepository.findById(chatRoomId).orElseThrow();
Message chatMessage = Message.createChatMessage(member, chatRoom, content);
messageRepository.save(chatMessage);
simpMessagingTemplate.convertAndSend(CHAT_DESTINATION_PREFIX + chatRoomId, new MessageResponseDto(chatMessage));
}
흔히 볼 수 있는 Spring-WebSocket과 STOMP를 사용해 메시지를 보내는 방법입니다. 단순하고 직관적입니다. 설명을 보태자면 다음과 같이 동작합니다.
SimpMessagingTemplate 클래스가 제공하는 convertAndSend() 메서드를 이용해 원하는 토픽으로 데이터를 전송합니다. 토픽에 구독하고 있는 클라이언트는 메시지를 실시간으로 받게 됩니다.아래는 이 동작을 확인할 수 있는 테스트 화면입니다.


화면에도 메시지가 표시되고, DB에도 보내진 메시지가 다 저장되어 있는 걸 볼 수 있습니다. 하지만 이 구현에는 문제점이 있습니다. 그것은 메서드가 끝난 시점, 그러니까 SimpMessagingTemplate에 의해 브로커로 메시지가 발행되고 난 뒤에도 트랜잭션이 롤백될 수 있다는 것입니다.
LazyInitializationException 처럼 JPA에서 발생하는 예외, 데이터베이스 제약 위반처럼 DB에 쿼리를 날리고 나서야 발생하는 예외, AOP 등에 의해서 발생하는 예외 등은 @Transactional 메서드가 끝나버리고 나는 시점까지도 알 수 없는 롤백이 발생하는 경우입니다.
이러한 롤백 현상을 구현하기 위해 다소 과격하지만 서버가 뜬 시점에 Message 테이블을 아예 지워보겠습니다.

메시지 테이블이 없어졌으므로, 앞으로 보내지는 메시지는 저장되지 않습니다. DB에서 예외가 발생했기 때문에 해당 트랜잭션이 커밋되지 않았다는 것은 메서드 종료 시점에 알 수 없습니다.

그럼에도 불구하고 여전히 메시지가 정상적으로 오가는 것을 볼 수 있습니다. 이는 JPA가 현재 트랜잭션이 완전히 끝나야 flush()를 통해 실제 쿼리를 날리기 때문입니다.

사용자 사이에서는 메시지가 오갔음에도 불구하고, 서버 로그에는 에러로 난리가 난 것을 볼 수 있습니다. 만약 이 서비스가 채팅방 메시지를 불러오는 기능을 제공한다고 하면, 내가 보낸 메시지가 나중에 보니 없는 문제가 발생합니다.
메시지 유실에 너그러운 시스템의 경우 큰 문제가 안 될 수 있지만, 제가 현재 개발하고 있는 웹게임 같은 경우, 보다 복합적인 게임 상태를 가지며 모든 사용자가 동일한 상태를 유지하는 것이 중요하기 때문에 메시지 유실이 치명적입니다. 반드시 정상적으로 트랜잭션이 끝난 데이터에 대한 메시지가 발행될 필요가 있습니다.
이런 문제를 해결하기 위해서는 메시지 발행이라는 메서드가 트랜잭션이 커밋되고 나서 호출될 필요가 있습니다.
스프링의 TransactionSynchronizationManager는 이러한 기능을 제공합니다. registerSynchronization()으로 TransactionSynchronization 인터페이스 구현체를 넘겨서 구현할 수 있습니다. TransactionSynchronization 는 afterCommit()을 비롯해 다양한 트랜잭션 시점에 대한 메서드를 제공합니다. 여기에 @Override로 제공된 코드는 그 시점에 호출됩니다.
아래와 같이 기존 코드를 수정할 수 있습니다.
public void sendChatMessage(Long memberId, Long chatRoomId, String content) {
Member member = memberRepository.findById(memberId).orElseThrow();
ChatRoom chatRoom = chatRoomRepository.findById(chatRoomId).orElseThrow();
Message chatMessage = Message.createChatMessage(member, chatRoom, content);
messageRepository.save(chatMessage);
// 트랜잭션 커밋 시 발행하도록 등록
if (TransactionSynchronizationManager.isActualTransactionActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
simpMessagingTemplate.convertAndSend(CHAT_DESTINATION_PREFIX + chatRoomId, new MessageResponseDto(chatMessage));
}
});
}
}
이제 다시 동일한 테스트를 해봅니다.



테이블 파괴!

메시지가 가지 않습니다. 영상이라 아니라서 엔터치는 걸 못 보여드려서 그렇지 정말로 가지 않습니다.

개발자 도구에서 웹소켓 주고 받은 데이터를 보아도 메시지는 보내졌으나 오는 메시지는 없는 걸 볼 수 있습니다.
위 방법은 단순하지만 서비스 레이어에 트랜잭션 관련 로직이 많이 늘어나는 단점이 있습니다. 단순한 메시지 송수신 메서드 2개일 때는 괜찮지만, 제 프로젝트에서는 각각 고유한 이벤트 메시지를 발행하는 메서드가 10개 가까이 되고 지금도 늘어나고 있습니다.
해당 기능을 제공하는 유틸리티 클래스를 만들 수도 있습니다. 하지만 이렇게 해도 모든 서비스 클래스와 트랜잭션 동기화 및 메시지 발행 관련 로직이 강하게 결합되어 있는 문제는 해결되지 않습니다. 트랜잭션 전략 혹은 메시지 발행 전략이 달라지면 어떡하죠? 메서드가 늘어나면? 온 동네방네 서비스 클래스를 돌아다니면서 고쳐야합니다. 아주 골치 아픕니다.
ApplicationEventPublisher를 이용해 이벤트를 발행하는 방식으로 이러한 로직을 서비스 레이어로부터 추상화할 수 있습니다. 이렇게 발행한 이벤트를 @TransactionalEventListener로 받아서 TransactionSynchronizationManager를 직접 호출하는 것과 동일한 효과를 얻을 수 있습니다.
public record ChatMessageSentEvent(Long chatRoomId, Message message) {}
발행할 이벤트를 정의합니다. 주로 반환될 데이터 Dto를 만들고 메시지를 발행하는 데 필요한 데이터를 담습니다.
private final ApplicationEventPublisher eventPublisher; // 이벤트 발행을 지원하는 스프링 클래스
...
public void sendChatMessage(Long memberId, Long chatRoomId, String content) {
Member member = memberRepository.findById(memberId).orElseThrow();
ChatRoom chatRoom = chatRoomRepository.findById(chatRoomId).orElseThrow();
Message chatMessage = Message.createChatMessage(member, chatRoom, content);
messageRepository.save(chatMessage);
// 도메인 이벤트 발행
applicationEventPublisher.publishEvent(new ChatMessageSentEvent(chatRoomId, chatMessage));
}
이제 서비스는 웹소켓 및 트랜잭션 동기화 로직으로부터 자유로워졌습니다. 메시지 발행 과정에 더 많은 의존성이 늘어나더라도 서비스 레이어는 이벤트만 발행하므로 수정될 필요가 없습니다.
@Component
@RequiredArgsConstructor
public class MessageEventHandler {
private final SimpMessagingTemplate simpMessagingTemplate;
// 커밋 이후에 처리
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleChatMessageSent(ChatMessageSentEvent event) {
simpMessagingTemplate.convertAndSend(
CHAT_DESTINATION_PREFIX + event.chatRoomId(),
new MessageResponseDto(event.message())
);
}
}
ApplicationEventPublisher로 인해 발행된 이벤트를 핸들링하는 MessageEventHandler 및 이벤트 리스너 메서드를 구현합니다. @TransactionalEventListener의 phase 옵션을 통해 트랜잭션의 어느 시점에 메서드를 호출할지 결정할 수 있습니다. 이벤트의 데이터를 Dto로 변환하여 SimpMessagingTemplate으로 발행합니다. 이렇게까지 하면 전보다 서비스 클래스가 꽤 깔끔해집니다.
문제는 서비스 클래스만 꽤 깔끔해진다는 점입니다. 서비스 클래스의 비즈니스적 순수함을 위해서 각 상황별 이벤트 클래스, 이벤트 리스너 메서드를 포함하는 이벤트 핸들러 클래스, 반환할 DTO 클래스까지 해서(이거는 이벤트를 그냥 반환해버리거나, 이벤트에 Dto를 넣는 방식을 쓸 수도 있습니다) 클래스가 어마어마하게 늘어납니다.
이벤트 클래스를 AfterCommitEvent와 같은 공통 클래스로 통일 시킬 수 있습니다. String destination과 T data를 받아서 하나의 이벤트 핸들러 클래스의 하나의 이벤트 리스너 메서드에서 처리하게 할 수 있습니다.
public class AfterCommitEvent<T> {
private final String destination;
private final T data;
}
@Component
@RequiredArgsConstructor
public class AfterCommitEventHandler {
private final MessageBrokerService messageBrokerService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleAfterCommitEvent(AfterCommitEvent<?> event) {
messageBrokerService.publish(event.getDestination(), new PublicationDto<>(event.getData()));
}
}
private final ApplicationEventPublisher eventPublisher; // 이벤트 발행을 지원하는 스프링 클래스
...
public void sendChatMessage(Long memberId, Long chatRoomId, String content) {
Member member = memberRepository.findById(memberId).orElseThrow();
ChatRoom chatRoom = chatRoomRepository.findById(chatRoomId).orElseThrow();
Message chatMessage = Message.createChatMessage(member, chatRoom, content);
messageRepository.save(chatMessage);
// 도메인 이벤트 발행
applicationEventPublisher.publishEvent(new ChatMessageSentEvent(chatRoomId, chatMessage));
}
서비스 클래스는 바뀌지 않습니다.
여기서 만족할 수 없습니다!
현재 프로젝트는 트랜잭션에 따른 메시지 발행이라는 요구사항 때문에 이벤트 발행과 핸들링이라는 추상 레이어가 하나 통째로 추가되어 있는 상태입니다.
Controller -> Service -> Event -> EventHandler -> Broker
불필요한 추상 계층이 늘어나면 코드 복잡도가 훅 늘어납니다. 당장 서비스 레이어 로직을 디버깅하기 위해서 이벤트 클래스와 이벤트 핸들러 클래스를 모두 살펴봐야 합니다.
현재 문제는 핵심 비즈니스 로직에 트랜잭션 동기화와 메시지 발행이라는 부가 로직이 포함되어서 생기는 것입니다.
이 생각을 하면서 곰곰히 생각하다 보니 AOP로 해결하기 딱 좋지 않나 하는 생각이 들었습니다!
spring-starter-websocket 패키지는 @SendTo 라는 어노테이션을 지원합니다. 이는 메서드가 리턴하는 값을 @SendTo("/topic/messages") 와 같은 식으로 원하는 경로에 발행할 수 있게 해줍니다. 하지만 이는 단순히 메시징 발행을 단순화 시킨 것이고, 트랜잭션과 관계 없이 발행됩니다.
그래서 @SendAfterCommit 같은 어노테이션이 있으면 좋겠다하는 생각이 들었습니다. 지원하는 Dto를 만들어서 이벤트처럼 destination과 data를 지정하여 리턴만 하면 나머지는 알아서 처리되는 것입니다.
그래서 만들어보았습니다!
/**
* 메서드가 {@link SendAfterCommitDto}를 반환하면
* 현재 트랜잭션이 커밋된 후에 Dto의 {@code destination}으로 {@code data}를 발행합니다.
* {@link SendAfterCommitAspect} 참조.
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SendAfterCommit {
}
@Aspect
@Component
@RequiredArgsConstructor
public class SendAfterCommitAspect {
private final SimpMessagingTemplate simpMessagingTemplate;
/**
* {@link SendAfterCommit} 어노테이션이 붙은 메서드가 반환하는 {@link SendAfterCommitDto} 데이터를 추출하여
* 현재 트랜잭션이 있는 경우 이를 커밋 후에 Dto의 {@code destination}으로 {@code data}를 발행합니다.
*/
@Around("@annotation(SendAfterCommit)")
public Object sendAfterCommit(ProceedingJoinPoint joinPoint) throws Throwable {
Object result = joinPoint.proceed();
if (result instanceof SendAfterCommitDto<?> dto
&& TransactionSynchronizationManager.isActualTransactionActive()
) {
String destination = dto.getDestination();
Object data = dto.getData();
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
simpMessagingTemplate.convertAndSend(destination, data);
}
});
}
return result;
}
}
@SendAfterCommit
public SendAfterCommitDto<MessageResponseDto> sendChatMessage(Long memberId, Long chatRoomId, String content) {
Member member = memberRepository.findById(memberId).orElseThrow();
ChatRoom chatRoom = chatRoomRepository.findById(chatRoomId).orElseThrow();
Message chatMessage = Message.createChatMessage(member, chatRoom, content);
messageRepository.save(chatMessage);
return new SendAfterCommitDto<>("/topic/chat-rooms/" + chatRoomId, new MessageResponseDto(chatMessage));
}
@SendAfterCommit
public SendAfterCommitDto<MessageResponseDto> sendSystemMessage(Long memberId, Long chatRoomId, String content) {
Member member = memberRepository.findById(memberId).orElseThrow();
ChatRoom chatRoom = chatRoomRepository.findById(chatRoomId).orElseThrow();
Message systemMessage = Message.createSystemMessage(member, chatRoom, content);
messageRepository.save(systemMessage);
return new SendAfterCommitDto<>("/topic/chat-rooms/" + chatRoomId, new MessageResponseDto(systemMessage));
}
테스트 결과는 스크린샷을 찍고 보니 앞이랑 완전히 같아서 생략합니다.
이렇게 서비스 로직에서 ApplicationEventPublisher 종속성까지 없애버리고, Event와 EventHandler 클래스까지 모두 없앴습니다. @SendAfterCommit 어노테이션을 사용하는 서비스 메서드는 리턴하는 값이 지정한 경로로 발행된다는 사실만 알고 있으면 그만입니다!
AOP를 배워놓고도 내가 쓸 일이 있을까 생각을 하고 있었습니다. 그런데 이렇게 빨리 써먹어볼 수 있어서, 그리고 나름 괜찮은 구현을 결과로 낼 수 있어서 좋았습니다.
현재는 하나의 경로에 대해 하나의 Dto만 보낼 수 있게 되어있지만, 어드바이스를 개선하면 리스트를 반환하도록 하여, 다수의 경로에 각각 원하는 데이터를 반환할 수 있도록도 만들 수 있습니다. 다음에 요구 사항이 생기면 그렇게 개선해보고자 합니다.