DDD 이벤트
이벤트 개요
비동기 이벤트를 사용하면 두 시스템 간의 결합을 크게 낮출 수 있다.
이벤트 구성요소
도메인 모델에 이벤트를 도입하려면 네 개의 구성요소를 구현해야 한다.
- 이벤트 : 과거에 벌어진 어떤 것. 예를 들어 주문을 취소했다면 ‘주문을 취소했음 이벤트’가 벌어졌다고 할 수 있다.
- 이벤트 생성 주체 : 엔티티, 밸류, 도메인 서비스와 같은 도메인 객체이다. 이들 도메인 객체는 도메인 로직을 실행해서 상태가 바뀌면 관련 이벤트를 발생시킨다.
- 이벤트 핸들러(구독자) : 이벤트 생성 주체가 발생한 이벤트에 반응한다. 생성 주체가 발생한 이벤트를 전달받아 이벤트에 담긴 데이터를 이용해서 원하는 기능을 실행한다.
- 이벤트 디스패처(퍼블리셔) : 이벤트 생성 주체는 이벤트를 생성해서 디스패처에 이벤트를 전달한다. 이벤트를 전달받은 디스패처는 해당 이벤트를 처리할 수 있는 핸들러에 이벤트를 전파한다. 이벤트 디스패처의 구현 방식에 따라 이벤트 생성과 처리를 동기나 비동기로 실행하게 된다.
이벤트의 구성
이벤트는 발생한 이벤트에 대한 정보를 담는다.
- 이벤트 종류 : 클래스 이름으로 이벤트 종류를 표현
- 이벤트 발생 시간
- 추가 데이터 : 주문번호, 신규 배송지 정보 등 이벤트와 관련된 정보
// 현재 기준으로 과거에 벌어진 것을 표현하기 때문에 클래스 이름은 과거시제를 사용
public class ShippingInfoChangedEvent {
private String orderNumber;
private long timestamp;
private ShippingInfo newShippingInfo;
...
}
// 이벤트를 발생시키는 주체 Order 애그리거트
public class Order {
public void changeShippingInfo(ShippingInfo newShippingInfo) {
verifyNotYetShipped();
setShippingInfo(newShippingInfo);
Events.raise(new ShippingInfoChangedEvent(number, newShippingInfo));
}
...
}
// 디스패처로부터 이벤트를 전달받아 필요한 작업을 수행
public class ShippingInfoChangedHandler {
@EventListener(ShippingInfoChangedEvent.class)
public void handle(ShippingInfoChangedEvent event) {
// 이벤트가 필요한 데이터를 담고 있지 않다면
// 레포지토리, 조회 API, 직접 DB 접근 등 필요한 데이터를 조회해야 한다.
Order order = orderRepository.findById(event.getOrderNo());
shippingInfoSynchronizer.sync(
event.getOrderNumber(),
event.getNewShippingInfo()
);
}
}
이벤트 용도
- 트리거 : 도메인의 상태가 바뀔 때 다른 후처리를 실행. 주문을 취소하면 환불을 처리해야 하는데 이때 환불 처리를 위한 트리거로 주문 취소 이벤트 사용.
- 서로 다른 시스템 간의 데이터 동기화 : 배송지를 변경하면 외부 배송 서비스에 바뀐 배송지 정보를 전송해야 한다. 주문 도메인은 배송지 변경 이벤트를 발생시키고 이벤트 핸들러는 외부 배송 서비스와 배송지 정보를 동기화.
이벤트 장점
- 서로 다른 도메인 로직이 섞이는 것을 방지할 수 있다.
- 기능 확장에 용이하다.
이벤트, 핸들러, 디스패치 구현
- 이벤트 클래스 : 이벤트를 표현한다.
- 디스패처 : 스프링이 제공하는
ApplicationEventPublisher
를 이용한다. - Events : 이벤트를 발행한다. 이벤트 발행을 위해
ApplicationEventPublisher
를 사용한다. - 이벤트 핸들러 : 이벤트를 수신해서 처리한다. 스프링이 제공하는 기능을 사용한다.
-
이벤트 클래스
// 이벤트를 위한 공통 추상 클래스
public abstract class Event {
private long timestamp;
public Event() {
this.timestamp = System.currentTimeMillis();
}
...
}
// 클래스 이름은 과거 시제 사용
public class OrderCanceledEvent extends Event {
// 이벤트를 처리하는 데 필요한 최소한의 데이터만 포함
private String orderNumber;
public OrderCanceledEvent(String number) {
super();
this.orderNumber = number;
}
}
Events 클래스와 ApplicationEventPublisher
public class Events {
// 이벤트 발생과 출판을 위해 스프링이 제공하는 ApplicationEventPublisher 사용
private static ApplicationEventPublisher publisher;
static void setPublisher(ApplicationEventPublisher publisher) {
Events.publisher = publisher;
}
public static void raise(Object event) {
if (publisher != null) {
publisher.publishEvent(event);
}
}
}
@Configuration
public class EventsConfiguration {
@Autowired
private ApplicationContext applicationContext;
@Bean
public InitializingBean eventsInitializer() {
return () -> Events.setPublisher(applicationContext);
}
}
이벤트 발생과 이벤트 핸들러
public class Order {
public void cancel() {
verifyNotYetShipped();
this.state = OrderState.CANCELED;
Events.raise(new OrderCanceledEvent(number.getNumber()));
}
...
}
@Service
public class OrderCanceledEventHandler {
private RefundService refundService;
public OrderCanceledEventHandler(RefundService refundService) {
this.refundService = refundService;
}
@EventListener(OrderCanceledEvent.class)
public void handle(OrderCanceledEvent event) {
refundService.refund(event.getOrderNumber());
}
}
ApplicationEventPublisher.publishEvent()
메서드를 실행할 때 OrderCanceledEvent 타입 객체를 전달하면, OrderCanceledEvent.class 값을 갖는 @EventListener 애너테이션을 붙인 메서드를 찾아 실행한다.
비동기 이벤트 처리
- 로컬 핸들러를 비동기로 실행 : 스프링에서 @Async 애너테이션을 사용하여 이벤트 핸들러를 비동기로 실행
- 메시지 큐 사용 : 카프카, 래빗MQ, AWS SQS와 같은 메시징 시스템을 사용
- 이벤트 저장소와 이벤트 포워더 사용 : 이벤트를 DB에 저장한 뒤 별도 프로그램을 이용해서 이벤트 핸들러에 전달 (스케줄러 활용)
- 이벤트 저장소와 이벤트 제공 API 사용 : 이벤트 저장소를 이용하여 이벤트를 외부에 제공하는 API를 사용(포워더 방식과 차이점은 이벤트를 전달하는 방식, API를 호출하여 이벤트를 차례대로 처리)
이벤트 적용 시 고려 사항
- 이벤트 발생 주체 정보를 추가할 것인가
- 포워더에서 전송 실패를 얼마나 허용할 것인가(재전송 횟수 제한)
- 로컬 핸들러를 이용해서 비동기 이벤트 처리할 경우 이벤트 손실
- 메시징 시스템을 사용할 경우 사용 기술에 따른 이벤트 발생 순서와 메시지 전달 순서
- 이벤트 재처리 방식
이벤트 처리와 DB 트랜잭션 고려
- 이벤트를 처리할 때는 DB 트랜잭션을 함께 고려해야 한다.
- 이벤트 처리를 동기로 하든 비동기로 하든 이벤트 처리 실패와 트랜잭션 실패를 함께 고려해야 한다.
- 트랜잭션이 성공할 때만 이벤트 핸들러를 실행하자.
// 트랜잭션 커밋에 성공한 뒤 핸들러 메서드 실행
@TransactionalEventListener(
classes = OrderCanceledEvent.class,
phase = TransactionPhase.AFTER_COMMIT
)
public void handle(OrderCanceledEvent event) {
refundService.refund(event.getOrderNumber());
}
Reference
- 도메인 주도 개발 시작하기, 최범균 지음
Comments