DDD START! 도메인 주도 설계 구현과 핵심 개념 익히기

10. 이벤트

Featured image

시스템 간 강결합의 문제

주문과 결제 BOUNDED CONTEXT 간에 강결합에 따른 문제점들로 아래와 같은 이슈가 있을 수 있다.

외부 서비스 통신 문제

트랜잭션 처리

주문 취소에 따른 환불 처리 기능 실행 시, 주문 취소를 위해서는 외부 서비스와 통신을 해야하는데 외부 서비스에 문제가 발생하고 있는 상황이라면 이에 대한 트랜잭션 처리가 애매하다.

게다가 환불 기능 실행 도중 exception이 발생하게 된다면 트랜잭션을 롤백해야 할지 이 또한 애매하다.

성능

만일 외부 시스템 통신 응답 시간이 30초가 소요된다면, 그 다음 처리가 실행되기 까지 대기시간도 늘어나게 된다.

설계상 이슈

주문과 결제 로직이 한 메소드 내에 섞이게 되면서, 즉 도메인 객체에 서로 다른 도메인 로직이 섞이면서 한 도메인 로직에 수정이 필요하게 되면 그에 따른 다른 도메인 로직도 수정이 불가피해지는 현상이 발생할 수 있다.

또한, 새로운 기능을 추가하게 될 때도 점점 로직이 섞이는 문제가 발생하고 트랜잭션 처리는 복잡해질 수 있다.

이러한 문제들을 해겷하기 위한 방법으로 이벤트를 사용하여 모든 연동을 비동기 처리함으로써 해결할 수 있다.

이벤트 개요

이벤트 관련 구성요소

도메인 모델에서 이벤트의 주체는 엔티티, 밸류, 도메인 서비스와 같은 도메인 객체이다. 도메인 객체는 도메인 로직을 실행해서 상태가 바뀌면 관련 이벤트를 발생한다. 이벤트 관련 구성요소

이벤트의 구성

발생한 이벤트는 보통 아래와 같은 정보를 담고 있다.

이벤트는 일반적으로 과거에 벌어진 것을 표현하기 때문에 이벤트 이름에는 과거 시제를 사용한다.

// 이벤트 클래스
public class ShippingInfoChangedEvent {
    private String orderNumber;
    private long timestamp;
    private ShippingInfo newShippingInfo;
    
    //  생성자, getter
}

// 이벤트 생성 주체
public class Order {
    public void changeShippingInfo(ShippingInfo newShippingInfo) {
        verifyNotYetShipped();
        setShippingInfo(newShippingInfo);
        Events.raise(new ShippingInfoChangedEvent(number, newShippingInfo));    //Events.raise() 디스패처 활용
    }
    //...
}

// 이벤트 핸들러
public class ShippingInfoChangedHandler implements EventHandler<ShippingInfoChangedEvent> {
    @Override
    public void handle(ShippingInfoChangedEvent evt) {
        shippingInfoSynchronizer.sync(evt.getOrderNumber(), evt.getNewShippingInfo());
    }
}

이벤트는 이벤트 핸들러가 작업을 수행하는데 필요한 최소한의 데이터를 담아야 한다. 만약 데이터가 부족할 경우, 관련 API를 추가 호출하거나 DB에 추가 조회를 해야 한다.

이벤트 용도

이벤트는 크게 두 가지 용도로 쓰인다.

트리거는 도메인의 상태가 바뀔 때, 다른 후처리가 추가적으로 필요하게 될 때 이벤트를 활용할 수 있다. 시스템 간 동기화는 배송지 변경 후 외부 배송 서비스와 등기화가 필요한 경우, 혹은 CQRS 패턴에서 RDB와 nosql간 동기화가 필요할 때 활용되는 케이스이다.

이벤트 장점

이벤트 핸들러를 추가하여 도메인 로직에 영향 없이 기능 확장

위와 같은 구조로 기능 확장을 하여도 도메인 로직은 수정할 필요가 없게 된다.

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

이벤트 관련 코드 구성은 다음과 같다

이벤트 클래스

상위 클래스 없이 해당 클래스 자체가 이벤트에 활용된다. 클래스 명은 과거 시제를 사용해야 한다. 또한 이벤트 클래스는 이벤트에 필요한 최소한의 데이터를 포함해야 한다.

public class OrderCanceledEvent {
    //  이벤트는 핸들러에서 이벤트를 처리하는 데 필요한 데이터를 포함한다.
    private String orderNumber;
    public OrderCanceledEvent(String number) {
        this.orderNumber = number;
    }
    
    public String getOrderNumber() { return orderNumber; }
}
public abstract class Event {
    private long timestamp;
    
    public Event() {
        this.timestamp = System.currentTimeMillis();
    }
    
    public long getTimestamp() { return timestamp; }
}

// 발생 시간이 필요한 각 이벤트 클래스는 Event를 상속받아 구현한다.
public class OrderCanceledEvent extends Event {
    private String orderNumber;
    public OrderCanceledEvent(String number) {
        super();
        this.orderNumber = number;
    }
}

EventHandler 인터페이스

EventHandler 인터페이스는 이벤트 핸들러를 위한 상위 인터페이스다.

public interface EventHandler<T> {
    void handle(T event);           //  handle 메서드를 구현하여 필요한 이벤트 기능을 구현한다.
    
    default boolean canHandle(Object event) {       // 핸들러가 이벤트 처리 가능한 지 여부를 체크
        Class<?>[] typeArgs = TypeResolver.resolveRawArguments(EventHandler.class, this.getClass());
        return typeArgs[0].isAssignableFrom(event.getClass());
    }
}

TypeResolver는 EventHandler의 파라미터화 타입을 구하는 기능을 제공한다.

EventHandler<T> handler = new EventHandler<PasswordChangedEvent>() {
    @Override
    public void handle(PasswordChangedEvent event) { System.out.println("생략") }
   
}
boolean result = handler.canHandle(new PasswordChangedEvent(someId, newPw));

canHandle 메서드는 파라미터로 받은 event 타입이 T의 파라미터화 타입에 할당 가능하면 true를 리턴한다.

이벤트 디스패처인 Events 구현

도메인을 사용하는 응용 서비스는 이벤트를 받아 처리할 핸들러를 Events.handle()로 등록하고, 도메인 기능을 실행한다.

public class CancelOrderService {
    private OrderRepository orderRepository;
    private RefundService refundService;

    @Transactional
    public void cancel(OrderNo orderNo) {
        Events.handle(  //  OrderCanceledEvent 발생 시, refund() 이벤트 처리
            (OrderCanceledEvent evt) -> refundService.refund(evt.getOrderNumber())
        );

        Order order = findOrder(orderNo);
        order.cancel();
    
        Events.reset();
    }
    //...
}

Events는 내부적으로 핸들러 목록 유지를 위해 ThreadLocal을 사용하는 구조로 처리된다. Events.handle() 메서드는 인자로 전달받은 EventHandler를 List에 보관한다.

public class Order {
    public void cancel() {
        verifyNotYetShipped();
        this.state = OrderState.CANCELED;
        Events.raise(new OrderCanceledEvent(number.getNumber()));   //  이벤트 발생
    }
}

Events.raise()는 이벤트를 처리할 핸들러를 찾아 handle() 메서드를 실행한다.

Events는 핸들러 list를 유지하기 위해 ThreadLocal 변수를 사용한다. 톰캣과 같은 웹 애플리케이션 서버는 thread를 재사용하므로 ThreadLocal에 보관한 값을 제거하지 않으면 기대대로 동작하지 않을 수 있다. 따라서 Events.reset()을 실행하여 ThreadLocal변수를 초기화해야 한다.

Events.reset()을 해주지 않으면 핸들러 객체가 List에 쌓이고 쌓여 OOM이 발생할 수 있다.

동기 이벤트 처리 문제

외부 시스템과의 연동을 동기로 처리하게 되면 성능 저하 및 트랜잭션 범위 문제가 발생할 수 있게 된다. 이를 비동기 처리를 통해 해결할 수 있다.

비동기 이벤트 처리

이벤트를 비동기로 구현할 수 있는 방법은 아래와 같이 다양하다.

로컬 핸들러의 비동기 실행

별도 로컬 스레드를 통해 이벤트 핸들러를 실행할 수 있다.

@Transactional
public void cancel(OrderNo orderNo) {
    Events.handleAsync((OrderCanceledEvent evt) -> refundService.refund(evt.getOrderNumber()));

    Order order = findOrder(orderNo);
    order.cancel(); // Events.raise(new OrderCanceledEvent()) 실행
}

order.cancel()내에서 이벤트를 발생시키는 Events.raise()는 동일 스레드에서 실행되므로 같은 트랜잭션 범위에 묶인다. 하지만 Events.handleAsync()로 등록한 이벤트 핸들러는 별도 스레드로 실행되므로 refundService.refund()는 cancel() 메서드와 관련된 트랜잭션 범위에 묶이지 않게 된다.

스프링 트랜잭션 관리자는 보통 스레드를 이용하여 트랜잭션을 전파한다. 때문에 다른 스레드에서 실행된다면 서로 다른 트랜잭션을 사용하게 된다.

별도 스레드를 이용하여 이벤트 핸들러를 실행한다면 같은 트랜잭션 범위에 묶일 수 없기 때문에, 한 트랜잭션으로 실행해야 하는 이벤트라면 비동기로 처리해서는 안된다.

메시징 시스템을 활용한 비동기 구현

RabbitMQ와 같은 메시징 큐를 활용한 비동기 이벤트 처리 방법도 있다.

  1. 이벤트가 발생하면 이벤트 디스패처는 이벤트를 메시지 큐에 보낸다.
  2. 메시지 큐는 받은 이벤트를 메시지 리스너에 전달한다.
  3. 메시지 리스너는 알맞은 이벤트 핸들러를 이용하여 이벤트를 처리한다.

이 때, 이벤트를 메시지 큐에 저장하는 과정메시지 큐에서 이벤트를 읽어와 처리하는 과정별도 스레드나 프로세스로 처리된다.

만일, 도메인 기능을 실행한 결과를 DB에 반영하고 이 과정에서 발생한 이벤트를 메시지 큐에 저장하는 과정까지 모두 같은 트랜잭션으로 처리가 돼야 한다면, 글로벌 트랜잭션이 필요하다.

장점 : 이벤트를 메시지 큐에 안전하게 전달할 수 있다.
단점 : 전체 성능이 떨어질 수 있다.

글로벌 트랜잭션 지원
클러스터와 고가용성을 지원하므로써 안정적으로 메시지 전달
다양한 개발 언어와 통신 프로토콜 지원

글로벌 트랜잭션 미지원
다른 메시징 시스템과 비교했을 때 높은 성능을 보임

이벤트 저장소를 활용한 비동기 처리

비동기로 이벤트를 처리하기 위한 또 다른 방법으로, 이벤트를 DB(저장소)에 저장 후 별도 프로그램을 이용하여 이벤트 핸들러에 전달하는 방법도 있다.

이벤트 저장소와 포워더를 이용한 비동기 처리

포워더는 별도 스레드를 이용하기 때문에 이벤트 발행과 처리가 비동기로 처리된다.

이벤트를 물리적 저장소에 저장하는 구조이기 때문에 핸들러가 이벤트 처리에 실패할 경우, 포워더는 다시 이벤트 저장소에서 이벤트를 읽어와 핸들러를 실행하면 된다.

API를 이용해서 이벤트를 외부에 제공하는 방법

포워더 방식 : 포워더를 통해 외부로 이벤트를 전달하는 방식. 따라서 이벤트를 어디까지 처리했는지 추적하는 역할을 포워더가 갖고 있다.
API 방식 : 외부 핸들러가 API 서버를 통해 이벤트 목록을 가져오는 방식. 따라서 이벤트 목록을 요구하는 외부 핸들러가 이벤트 처리 내역을 알고 있어야 한다.

이벤트 적용 시 추가 고려사항