ApplicationEventPublisher 적용과 그 안에서의 삽질
ApplicationEventPublisher
이벤트 핸들러는 이벤트 생성 주체가 발생한 이벤트에 반응하고,
이벤트 핸들러는 생성 주체가 발행한 이벤트를 전달받아 이벤트에 담긴 정보(데이터)를 기반으로 해당 기능을 수행
놀토에서 ApplicationEventPublisher
우리는 사용자에게 어떤 특수한 이벤트가 발생했을 때 Event 테이블에 해당 이벤트 정보를 저장하여야 한다.
ex) 자신의 피드에 댓글이 달렸을 때, 자신의 피드에 좋아요가 눌렸을 때
이제부터 우리 알림 기능을 ApplicationEventPublisher
로 구현해야하는 이유를,
이를 사용하지 않을 시 문제점을 보며 나열해본다.
ApplicationEventPublisher를 쓰지 않았을 때의 문제
기존 로직에 결합도가 높아진다
현재 어떤 유저가 다른 유저의 어떤 피드에 좋아요를 누를 수 있는 기능이 존재한다.
그런데 여기서 어떤 유저가 피드에 좋아요를 누르면, 해당 유저의 알림 탭에 이벤트가 발생했음을 알리려고 한다.
그러면 우리는 당연하게 기존 서비스 코드에 아래와 같은 로직을 추가할 것이다.
@Transactional
public void addLike(User user, Long feedId) {
// 기존 로직 - 좋아요
Feed findFeed = feedService.findEntityById(feedId);
if (user.isLiked(findFeed)) {
throw new BadRequestException(ErrorType.ALREADY_LIKED);
}
user.addLike(new Like(user, findFeed));
// 추가할 로직 - 이벤트 데이터 추가
eventService.add(좋아요_이벤트);
}
만약 이렇게 구현한다면 LikeService에서는 이벤트 관련 서비스도 의존하게 될 것이고,
추후에 좋아요에 필요한 로직이 추가된다면 점점 다 높은 복잡도를 가지게 될 것이다.
트랜잭션의 문제
위의 예제를 다시 생각해보자.
현재 좋아요 기능, 알림을 위해 이벤트를 추가하는 기능이 한 트랜잭션에서 동작한다.
그런데 좋아요는 정상적으로 수행되었지만, 알람을 추가하는 과정에서 예외가 발생한다면,
성공적으로 수행된 좋아요도 롤백이 되어버린다.
알림이 실패할 경우 원자성을 위해 좋아요까지 롤백이 되는 것이 맞을까?
알림을 추가하는 것이 실패되어도 좋아요는 정상적으로 동작되어야하는 것이 아닐까?
다시 돌아가서...
우리는 이러한 문제를 해결하기 위해 ApplicationEventPublisher를 도입한다!
그러면 ApplicationEventPublisher를 사용하는 과정을 기록해보자.
ApplicationEvent를 상속하는 이벤트 클래스 생성
이벤트 데이터를 저장하기 위한 이벤트 클래스를 만든다.
@Getter
public class NotificationEvent {
private final User listener;
private final Feed feed;
private final Comment comment;
private final User publisher;
private final NotificationType notificationType;
public Notification toEntity() {
return new Notification(listener, feed, comment, publisher, notificationType);
}
public static NotificationEvent commentOf(Feed feed, Comment comment, boolean isHelper) {
return new NotificationEvent(feed.getAuthor(), feed, comment, comment.getAuthor(), NotificationType.findCommentBy(isHelper));
}
public static NotificationEvent replyOf(Comment comment, Comment reply) {
return new NotificationEvent(comment.getAuthor(), comment.getFeed(), comment, reply.getAuthor(), NotificationType.REPLY);
}
public static NotificationEvent likeOf(Feed feed, User publisher) {
return new NotificationEvent(feed.getAuthor(), feed, null, publisher, NotificationType.LIKE);
}
public boolean validatePublisher() {
return !publisher.sameAs(listener);
}
}
EventPublisher
위에서 만든 이벤트에 대한 퍼블리셔를 설정한다.
퍼블리셔는 이벤트를 구성하고 이를 Listen하고 있는 모든 객체에게 퍼블리셔한다.
이벤트를 퍼블리쉬하기 위해 이벤트를 발생시킬 곳에서 ApplicationEventPublisher 클래스 내에 있는 publishEvent()
를 사용하면 된다.
@Transactional
@Service
public class LikeService {
private final LikeRepository likeRepository;
private final FeedService feedService;
private final ApplicationEventPublisher applicationEventPublisher;
public void addLike(User user, Long feedId) {
Feed findFeed = feedService.findEntityById(feedId);
if (user.isLiked(findFeed)) {
throw new BadRequestException(ErrorType.ALREADY_LIKED);
}
user.addLike(new Like(user, findFeed));
applicationEventPublisher.publishEvent(NotificationEvent.likeOf(findFeed, user));
}
// ....
}
우리는 좋아요가 눌러졌을 때, 좋아요 이벤트를 저장하는 이벤트를 발생시킨다.
ApplicationEventPublisher를 쓰지 않았을 때의 문제로 다시 돌아가면,
기존에는 LikeService에서 이벤트 서비스 관련 빈들을 의존하게 되었지만, ApplicationEventPublisher를 가지게 되면서
좀 더 약한 의존 관계를 가지게 된다.
Listener 생성
스프링 4.2 부터는 이벤트 리스너는 어노테이션 하나로도 등록할 수 있다.
우리는 여기서 @TransactionalEventListener
을 사용할 것이다.
@Component
public class NotificationEventHandler {
private final NotificationService notificationService;
@TransactionalEventListener
public void saveNotification(NotificationEvent notificationEvent) {
notificationService.save(notificationEvent);
}
// ...
}
현재 notificationService.save()에서는 Notification이라는 엔티티를 저장하는 로직을 가지고 있다.
즉 해당 Notifiaction 이벤트가 발생하면, 알림 테이블에 알림 정보를 저장한다.
이벤트 리스너에 관한 어노테이션은 @EventListener
, @TransactionalEventListener
이 있는데,
이에 대해 간단히 정리하고 우리가 왜 @TransactionalEventListener를 사용하게 되었는지 살펴보자.
Listener 어노테이션 별 차이
@EventListener
이벤트를 발행하는 시점에 바로 리스닝을 진행한다.
즉 이벤트를 퍼블리싱 한 이후 바로 리스너가 동작하는 것이다.
또 좋아요 기능이 수행되고 이벤트 추가 기능이 실패하면 이 두 기능은 하나의 트랜잭션으로 같이 롤백되어 버린다.
@TransactionalEventListener
해당 트랜잭션이 Commit된 이후 리스너가 동작한다.
만약 좋아요가 실패한다면 해당 트랜잭션이 Commit되지 않기 때문에 해당 리스너가 동작하지 않는다.
또 좋아요가 성공하고 이벤트 추가가 실패해도 좋아요는 이미 Commit이 되었기 때문에 문제가 발생하지 않는다.
➕ 여기에는 여러가지 옵션을 줄 수 있다.
옵션
- AFTER_COMMIT (default) : 트랜잭션이 성공한 후 이벤트 발생
- AFTER_ROLLBACK : 트랜잭션이 롤백된 경우 발생
- AFTER_COMPLETION : 트랜잭션이 완료된 경우 발생 (AFTER_COMMIT, AFTER_ROLLBACK)
- BEFORE_COMMIT : 트랜잭션 커밋 직전에 이벤트 발생
좋아요가 성공하고 알림 저장이 실패하더라도 좋아요에 대한 것은 롤백하고 싶지 않기 때문에
좋아요 저장 로직에 대해서 커밋을 먼저 시킬 수 있는 @TransactionalEventListener를 걸어주었다.
하지만 여기서, 우리 로직에는 문제가 발생한다.
알림이 제대로 저장되는지 확인하는 인수테스트가 제대로 성공하지 못하고 있는 걸 확인하고,
때문에 실제 알림 이벤트가 제대로 저장되지 않는 문제를 확인했다.
왜일까 백엔드끼리 한참을 삽질을 하다가... 결국 해답을 찾게 되었다.
The transaction will have been committed already, but the transactional resources might still be active and accessible. As a consequence, any data access code triggered at this point will still "participate" in the original transaction, allowing to perform some cleanup (with no commit following anymore!), unless it explicitly declares that it needs to run in a separate transaction. Hence: Use {@code PROPAGATION_REQUIRES_NEW} for any transactional operation that is called from here.
원인은 이미 LikeService의 메서드가 끝나고 COMMIT을 찍은 후 이벤트에 대한 로직이 수행되는데,
이미 COMMIT이 찍힌 트랜잭션이므로 NottificationService를 실행할 시점에서 새로운 트랜잭션을 실행한다고 명시적으로 나타내지 않았기 때문이었다.
관련 자료에서 말하듯, NottificationService에서 새로운 트랜잭션을 사용하려면 해당 메서드에 @Transactional(propagation = Propagation.REQUIRES_NEW)를 추가해주니 해결되었다.
@Service
@Transactional
public class NotificationService {
private final NotificationRepository notificationRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(NotificationEvent notificationEvent) {
Notification notification = notificationEvent.toEntity();
if (notificationEvent.validatePublisher()) {
notificationRepository.save(notification);
}
}
// ...
}
➕Propagation.REQUIRES_NEW
PROPAGATION_REQUIRES_NEW starts a new, independent "inner" transaction for the given scope. This transaction will be committed or rolled back completely independent from the outer transaction, having its own isolation scope, its own set of locks, etc. The outer transaction will get suspended at the beginning of the inner one, and resumed once the inner one has completed. ...
주어진 범위에 대해 새롭고 독립적인 "내부" 트랜잭션을 시작한다. 이 트랜잭션은 독립적인 범위, 자체적인 락킹 등을 갖는 외부 트랜잭션과 완전히 독립적으로 커밋되거나 롤백된다. 외부 트랜잭션은 내부 트랜잭션의 시작 부분에서 일시 중단되고 내부 트랜잭션이 완료되면 재개된다.
참고자료
- @TransactionalEventListener
- https://brunch.co.kr/@springboot/422#comment
- Spring Event + Async + AOP 적용해보기
[번외] 우리는 삭제 이벤트도 만들었어요!
🔥 주의 🔥
사실 이 부분은 추후에 아예 삭제에 대한 로직이 바뀔 것이기도 하고(논리적 삭제, 이렇게 되면 애초에 이런 문제는 고려 대상이 아님)
좋은 방법인지도 잘 모르겠어서, 그냥 가볍게 우리의 행적을 남기는 용도로 작성한다. 나중에 보면 코딩 흑역사가 될지도...
말 그대로 문제 상황은 알람 테이블에 저장되어 있는 댓글을 삭제하려고 할 때,
알람 테이블에 comment의 외래키가 걸려있어 삭제가 되지 않았다.
그러면 정답은 하나인데 댓글이 삭제될 때, 그 댓글을 가지고 있는 알람들도 삭제해주면 되는 것이다.
하지만 이를 구현하는 방법은 두가지가 존재한다.
하나는 CommentService에서 NotificationRepository를 가지는 것이고, 다른 하나는 삭제 이벤트를 구현하는 것이다.
우리는 여기서 결국 삭제 이벤트를 구현하는 방식을 가져갔는데, 그 이유는 다음과 같다.
애초에 이벤트 리스너를 사용한 이유가 서로 간의 의존성의 최대한 떼어내기 위함이 아니야?
NotificationRepository를 사용한다면, 결국 떼어내려던 의존을 맺게 되잖아!
때문에 피드나 댓글이 삭제될 때 알람 삭제를 담당하는 이벤트들을 만들고 해당 Listener도 만들어주었다.
여기서 Listener는 @TransactionalEventListener가 아닌 @EventListener를 사용하였다.
그 이유는 댓글 삭제 이벤트가 발생할 시점에 해당 Comment를 받아서 진행하는데,
다른 트랜잭션에서 동작한다면 Comment는 영속성 컨텍스트에 존재하지 않기 때문에 에러가 나고,
해당 이벤트를 호출한 CommentService에서 댓글 삭제를 COMMIT하면, 또 결국 외래키 오류로 돌아가기 때문이다.
@Service
@Transactional
public class NotificationService {
private final NotificationRepository notificationRepository;
// ...
public void deleteNotificationRelatedToFeed(NotificationFeedDeleteEvent notificationFeedDeleteEvent) {
Feed feed = notificationFeedDeleteEvent.getFeed();
notificationRepository.deleteAllByFeed(feed);
}
public void deleteNotificationRelatedToComment(NotificationCommentDeleteEvent notificationCommentDeleteEvent) {
Comment comment = notificationCommentDeleteEvent.getComment();
notificationRepository.deleteAllByComment(comment);
}
}