댓글 생성 시 다양한 로직이 실행된다.
이러한 작업을 이벤트 방식으로 처리하는게 맞다 판단하여 Spring Event Listener를 도입했다.
@Transactional
public CommentCreateResponse create(final AuthPrincipal authPrincipal, final CommentCreateRequest request) {
validateComponentId(request);
validateParentId(request);
final Comment comment = commentRepository.save(CommentCreationStrategy.createBy(request, authPrincipal.id()))
eventPublisher.publishEvent(CommentImageMoveEvent.from(comment));
eventPublisher.publishEvent(ComponentCommentCountIncreaseEvent.from(comment));
if (isReply(request)) {
eventPublisher.publishEvent(CommentReplyCountIncreaseEvent.from(comment));
eventPublisher.publishEvent(CommentReplyNotificationEvent.from(comment));
}
return CommentCreateResponse.from(comment);
}
문제는 비지니스 로직에 다양한 이벤트 로직이 포함되있어 결합도가 높아졌다. 이로 인해 확장에 불리한 코드 형태가 되었다.
의존성만 고려했을 때 이벤트 리스너와 비지니스 레이어 자체의 결합도는 낮다고 판단할 수 있다. 허나, 수행하는 이벤트가 비지니스 로직에 그대로 노출되있다. 이는 단순한 비동기 처리일 뿐 이벤트 리스너의 장점을 100% 활용한다고 볼 수 없는 구조다.
만약, 댓글 생성시 추가적인 이벤트가 발생한다면? 기존 이벤트에 수정이 생긴다면? 비지니스 로직을 수정해야 되는 상황이 발생한다.
참고
발행해야 하는 이벤트는 이벤트로 인해 달성하려는 목적이 아닌 도메인 이벤트 그 자체
(참고: 우아한테크 회원시스템 이벤트기반 아키텍처 구축하기)
핵심은, 비지니스 로직은 내가 어떤 이벤트를 발행해야 되는지 알 필요가 없다. 단순히, 내가 수행한 이벤트가 뭔지만 나타내면 된다. 이로 인해 어떤 이벤트가 발생할지는 몰라도 된다.
변경된 구조이다. 중간 이벤트 리스너를 두어 비지니스 로직과 이벤트 리스너간 관심사를 분리했다.
@Transactional
public CommentCreateResponse create(final AuthPrincipal authPrincipal, final CommentCreateRequest request) {
validateComponentId(request);
validateParentId(request);
final Comment comment = commentRepository.save(CommentCreationStrategy.createBy(request, authPrincipal.id()));
eventPublisher.publishEvent(CommentCreateEvent.from(comment));
return CommentCreateResponse.from(comment);
}
이벤트 리스너와 비지니스 레이어간 결합도가 낮아졌음을 알 수 있다. 비지니스 레이어는 이후 어떤 이벤트가 발생하는지 알지 못한다.
@Component
@RequiredArgsConstructor
public class CommentEventHandler {
private final ApplicationEventPublisher eventPublisher;
private final CommentRepository commentRepository;
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleCommentCreateEvent(final CommentCreateEvent event) {
final Comment comment = findCommentById(event.commentId());
eventPublisher.publishEvent(CommentImageMoveEvent.from(comment));
eventPublisher.publishEvent(ComponentCommentCountIncreaseEvent.from(comment));
eventPublisher.publishEvent(CommentReplyCountIncreaseEvent.from(comment));
eventPublisher.publishEvent(CommentReplyNotificationEvent.from(comment));
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleCommentDeleteEvent(final CommentDeleteEvent event) {
final Comment comment = findCommentById(event.commentId());
eventPublisher.publishEvent(ComponentCommentCountDecreaseEvent.from(comment));
eventPublisher.publishEvent(CommentReplyCountDecreaseEvent.from(comment));
eventPublisher.publishEvent(CommentImageDeleteEvent.from(comment));
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleCommentUpdateEvent(final CommentUpdateEvent event) {
final Comment comment = findCommentById(event.commentId());
eventPublisher.publishEvent(CommentImageUpdateEvent.of(comment, event.originalImage()));
}
private Comment findCommentById(final Long commentId) {
return commentRepository.findById(commentId)
.orElseThrow(NotFoundCommentException::new);
}
}
중간 이벤트 리스너를 두면서 이벤트 리스너와 비지니스 레이어간 결합도가 낮아지긴 했지만 여러 문제가 존재한다.
이벤트별로 트랜잭션 단계, 비동기 처리, 예외 처리 세분화가 불가능하다. 중간 이벤트 리스너에 너무 종속적인 구조다. 또한, 비지니스 레이어에서 이미 엔티티를 조회하는데 여기서 repository.findById()
를 통해 또 조회하고 있으며 리스너마다 필요한 파라미터가 다르기에 리스너별로 DTO를 정의하고 있다.
사실 이렇게 구현하면 Spring Event Listener를 쓰는 이유가 퇴색된다. 그냥 Bean으로 리스너들을 등록해서 forEach()
문으로 실행하는 것과 뭐가 달라...
이러한 이유들로 중간 리스너를 없애고 비지니스 레이어에서 바로 이벤트 리스너들을 호출하는 구조로 변경하기로 결정했다.
@Transactional
public CommentCreateResponse create(final AuthPrincipal authPrincipal, final CommentCreateRequest request) {
validateComponentId(request); // 이게 필요한가? validateParentId() 에서 검증 가능한데?
validateParentId(request); // 이거 어떻게 수정하려 했더라?
final Comment comment = commentRepository.save(CommentCreationStrategy.createBy(request, authPrincipal.id()));
eventPublisher.publishEvent(CommentCreateApplicationEvent.from(comment)); // 대댓글이면 추가 이벤트를 발행할지? 이런 것들은 비지니스 로직이 결정할 여부가 아니다. 이벤트 담당자가 신경써야지.
return CommentCreateResponse.from(comment);
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleImageMove(final CommentCreateApplicationEvent event) {
final CommentImage image = event.image();
if (image == null || image.isEmpty()) {
return;
}
storageProducer.sendImageMoveMessage(ImageMoveRequest.from(image));
}
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleCommentReplyCountIncreaseEvent(final CommentCreateApplicationEvent event) {
final Long parentId = event.parentId();
if (parentId == null) {
return;
}
final Comment comment = findCommentById(parentId);
comment.increaseReplyCount();
}
중간 리스너가 아닌 Spring Event Dispatcher를 활용해 결합도를 낮췄다. 하나의 이벤트 객체를 사용한다. 물론, 리스너마다 핏한 데이터가 담기진 않지만 다른 이점들이 많으므로 이정도 리스크는 적절하다 판단했다.
S3에 이미지 업로드, 댓글 알림 기능은 트랜잭션 이후로 그 외에 레코드를 수정하는 행위는 트랜잭션에 포함시켰다.
트랜잭션 리소스가 여전히 활성 상태이기 때문에 기존 트랜잭션에 참여하나, 해당 트랜잭션에서 더 이상 새로운 커밋을 수행하지 않으므로 새로운 변경 사항을 반영할 수는 없다.
따라서 동기적으로 처리하는 경우 트랜잭션 전파 옵션 중 하나인 Propagation.REQUIRES_NEW
를 사용해야 한다. 이 옵션을 사용하면 기존에 수행 중인 트랜잭션이 존재하더라도 참여하지 않고 항상 새로운 트랜잭션을 시작하므로 새로운 변경사항이 DB에 정상적으로 반영된다.
이 외에도 @Async를 사용하여 비동기적으로 처리할 수 있는 방법도 있다. 새로운 스레드에서 실행이 되므로 ThreadLocal 값이 공유되지 않는다. 따라서, 이벤트 객체를 통해서 값을 전달하자.