Spring의 이벤트 발행 - 소비 패턴
Order가 완료되면 OrderCompletedEvent를 발행하고, 이 이벤트를 소비하는 로직이 따로 존재한다고 가정하자.
AbstractAggregateRoot 없는 순수 Spring Event
이벤트 발행
@Service
@RequiredArgsConstructor
public class OrderService {
private final ApplicationEventPublisher publisher;
private final OrderRepository orderRepository;
@Transactional
public void complete(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.complete();
publisher.publishEvent(new OrderCompletedEvent(order.getId()));
}
}
ApplicationEventPublisher의 publishEvent를 통해 OrderCompletedEvent 타입의 이벤트를 발행하는 것을 볼 수 있다. 후술하지만 Spring에서 이벤트는 타입을 기준으로 소비된다.
이벤트 소비
@Component
public class OrderEventHandler {
@EventListener
public void sendMail(OrderCompletedEvent event) {
// 메일 발송
}
@EventListener
public void updateStats(OrderCompletedEvent event) {
// 통계 갱신
}
}
여기서 확인할 수 있는 @EventListener는 메서드 파라미터 타입을 기준으로 이벤트를 소비한다. 여기서는 OrderCompletedEvent 타입을 받으므로, OrderCompletedEvent 또는 하위 타입의 이벤트를 소비한다.
이는 앞으로 나올 @TransactionalEventListener, @ApplicationModuleListener 에서도 동일하게 작동한다.
AbstractAggregateRoot 있는 Spring Data Domain Event
엔티티
@Entity
public class Order extends AbstractAggregateRoot<Order> {
@Id
@GeneratedValue
private Long id;
public void complete() {
// 도메인 상태 변경
// ...
registerEvent(new OrderCompletedEvent(id));
}
}
엔티티는 Spring Data에서 제공하는 AbstractAggregateRoot를 상속하도록 한다. 이를 통해 해당 클래스의 registerEvent를 호출할 수 있으며, 이는 매개변수로 받는 인자 타입의 이벤트를 발행한다.
이벤트 발행
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
@Transactional
public void complete(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.complete();
orderRepository.save(order);
}
}
여기서 왜 save() 를 명시적으로 호출하였을까? 어차피 영속성 컨텍스트에서 관리되고 있기 때문에 dirty checking이 일어나면 order의 변경사항은 자동으로 반영될 것이다. 그런데 왜?
registerEvent를 통한 도메인 이벤트 발행은 무조건 save를 강제한다. 패키지 내 코드가 spring data repository의 save를 통하지 않은 이벤트 발행은 정의하지 않았기 때문이다.
dirty checking은 entity manager를 통해 flush로써 트리거되기 때문에, 도메인 이벤트를 발행하지 못한다.
이벤트 소비
@Component
public class OrderEventHandler {
@TransactionalEventListener(AFTER_COMMIT)
public void sendMail(OrderCompletedEvent event) {
// 메일 발송
}
@TransactionalEventListener(AFTER_COMMIT)
public void updateStats(OrderCompletedEvent event) {
// 통계 갱신
}
}
@TransactionalEventListener는 Spring Transaction과 연동되는 이벤트 리스너다.
BEFORE_COMMIT의 경우, 이벤트를 발행한 트랜잭션 커밋 직전 실행하며 실패 시 전체 트랜잭션을 롤백한다.
AFTER_COMMIT의 경우, 이벤트를 발행한 트랜잭션 커밋 후 실행하며, 별개의 트랜잭션에서 실행하므로 소비자 스레드가 실패해도 이전 트랜잭션은 롤백되지 않는다.
AbstractAggregateRoot + Spring Modulith
엔티티, 이벤트 발행 부분은 앞선 Spring Data Domain Event과 동일하여 생략한다.
이벤트 소비
@Component
public class OrderEventHandler {
@ApplicationModuleListener
void sendMail(OrderCompletedEvent event) {
// 메일 발송
}
@ApplicationModuleListener
void updateStats(OrderCompletedEvent event) {
// 통계 갱신
}
}
@ApplicationModuleListener는 Spring Modulith에서 제공하는 이벤트 리스너다. AFTER_COMMIT으로 동작한다.
앞선 이벤트 발행 로직의 트랜잭션이 커밋되면, 이벤트가 발행되어 해당 타입에 맞는 @ApplicationModuleListener를 탐색한다. 각 리스너에 대해, Spring Modulith를 통해 생성된 EventPublication 테이블에 이벤트를 기록하고 리스너를 실행한다.
EventPublication을 통해 얻을 수 있는 장점은 실패한 이벤트, 데드 레터에 대해서 DB를 조회해 후처리가 가능하다는 점이다.
추가로 알아볼 점
EventPublication의 저장 시점
시나리오를 살펴보면, EventPublication은 소비자를 알아야 하기 때문에 이전 트랜잭션 커밋 이후에 호출되지 않을까 우려된다. 만약 이렇게 되면, 이벤트를 발행하는 주체인 비즈니스 변경은 반영되는데 EventPublication이 생성되지 못할 가능성이 존재한다. 이렇게 되면 재시도 로직에도 잡히지 않기 때문에 문제가 발생할 것이라 추정된다.
EventPublication의 listener_id 불안정성
listener_id는 모듈명+클래스+메서드명 을 토대로 생성된다고 한다. 만약 유실 이벤트가 EventPublication에 반영된 상태에서, 새로운 배포 버전으로 인해 메서드명이 바뀐다면 해당 메서드는 EventPublication을 수신하지 못하는 문제가 발생한다.