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()
        );
    }
}

이벤트 용도

  1. 트리거 : 도메인의 상태가 바뀔 때 다른 후처리를 실행. 주문을 취소하면 환불을 처리해야 하는데 이때 환불 처리를 위한 트리거로 주문 취소 이벤트 사용.
  2. 서로 다른 시스템 간의 데이터 동기화 : 배송지를 변경하면 외부 배송 서비스에 바뀐 배송지 정보를 전송해야 한다. 주문 도메인은 배송지 변경 이벤트를 발생시키고 이벤트 핸들러는 외부 배송 서비스와 배송지 정보를 동기화.

이벤트 장점

  • 서로 다른 도메인 로직이 섞이는 것을 방지할 수 있다.
  • 기능 확장에 용이하다.

이벤트, 핸들러, 디스패치 구현

  • 이벤트 클래스 : 이벤트를 표현한다.
  • 디스패처 : 스프링이 제공하는 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를 호출하여 이벤트를 차례대로 처리)

이벤트 적용 시 고려 사항

  1. 이벤트 발생 주체 정보를 추가할 것인가
  2. 포워더에서 전송 실패를 얼마나 허용할 것인가(재전송 횟수 제한)
  3. 로컬 핸들러를 이용해서 비동기 이벤트 처리할 경우 이벤트 손실
  4. 메시징 시스템을 사용할 경우 사용 기술에 따른 이벤트 발생 순서와 메시지 전달 순서
  5. 이벤트 재처리 방식

이벤트 처리와 DB 트랜잭션 고려

  • 이벤트를 처리할 때는 DB 트랜잭션을 함께 고려해야 한다.
  • 이벤트 처리를 동기로 하든 비동기로 하든 이벤트 처리 실패와 트랜잭션 실패를 함께 고려해야 한다.
  • 트랜잭션이 성공할 때만 이벤트 핸들러를 실행하자.
// 트랜잭션 커밋에 성공한 뒤 핸들러 메서드 실행
@TransactionalEventListener(
        classes = OrderCanceledEvent.class,
        phase = TransactionPhase.AFTER_COMMIT
)
public void handle(OrderCanceledEvent event) {
    refundService.refund(event.getOrderNumber());
}
Reference
  • 도메인 주도 개발 시작하기, 최범균 지음

Categories:

Updated:

Comments