시스템 간 강결합 문제

  • 쇼핑몰에서 구매를 취소하면 환불을 처리해야 한다. 이떄 환불 기능을 하는 주체는 주문 도메인 엔티티가 될 수 있다.

  • 도메인 서비스를 파라미터로 전달받고 취소 도메인 기능에서 도메인 서비스를 실행하게 된다.
public class Order {
  public void cancel(RefundService refundService) {
    verifyNotYetShipped(); // 주문 로직
    this.state = OrderState.CANCELED; // 주문 로직

    this.refundStatus = State.REFUND_STARTED; // 결제 로직
    try { // 결제 로직
      refundSvc.refund(getPaymentId()); // 결제 로직
      this.refundStatus = State.REFUND_COMPLETED; // 결제 로직
    } catch(Exception ex) { // 결제 로직
      ... // 결제 로직
    } // 결제 로직
  }
}

출처: https://minkukjo.github.io/dev/2020/11/20/DDD-10/

  • 또한 응용 서비스에서 환불 기능을 실행할 수도 있다.

  • 보통 결제 시스템은 외부에 존재하므로 RefundService는 외부의 환불 시스템 서비스를 호출하는데, 이때 두 가지 문제를 발생한다.
  • 첫 번째 문제는 외부 서비스가 정상이 아닐경우 트랜잭션 처리를 어떻게 해야할지 애매하다는것
    • 환불처리중 익셉션이 생겼을 경우 롤백을 해야하는가 안해야 하는가 ?
      • 무조건 롤백이라고 생각할수 있지만 주문은 취소상태로 변경하고 환불만 나중에 다시 시도하는 방식으로 처리할 수 있다.
  • 두번째는 성능에 관한 문제
    • 외부시스템의 응답이 길어지면 그만큼 대기 시간이 발생한다.
    • 환불 처리기능이 30초 걸리면 주문 취소 기능은 30초+@가 된다.
    • 즉, 외부 서비스 성능에 직접적인 영향을 받는 문제가 있다.

  • 두가지 문제 외에 도메인 객체에 서비스를 전달하면 추가로 설계상 문제가 나타날 수 있다.
  • 주문 로직과 결제 로직이 섞이는 문제가 있다.
    • Order는 주문을 표현하는 도메인인데 결제 도메인의 환불 관련 로직이 섞이게 된다.
    • 의존성이 생겨 변경시 같이 변경해야되는 문제가 발생함.

  • 또하나의 문제는 기능을 추가할 때 발생한다.
  • 만약 주문을 취소한 뒤에 환불뿐만 아니라 취소했다는 내용을 통지해야 한다면 ?
  • 환불 도메인 서비스와 동일하게 파라미터로 통지 서비스를 받도록 구현하면 앞서 언급한 로직이 섞이는 문제가 더 커지고 트랜잭션 처리가 더 복잡해진다.
  • 게다가 영향을 주는 외부서비스가 두개나 증가해버렸다.

  • 지금까지 언급한 문제가 발생하는 이유는 주문 Bounded Context와 결제 Bounded Context 간의 강결합 때문이다.
  • 주문이 결제와 강하게 결합되어 있어서 주문 Bounded Context가 결제 Bounded Context에 영향을 받는것이다.
  • 이러한 결합을 없애는 방법이 있는데 그것은 바로 이벤트를 사용하는 것이다.
  • 특히 비동기 이벤트를 사용하면 두 시스템 간의 결합을 크게 낮출 수 있다.

이벤트 개요

  • 여기서 말하는 이벤트란 "과거에 벌어진 어떤 것" 을 의미한다.
  • 예를 들어 사용자가 비밀번호를 변경한 것을 "암호를 변경했음 이벤트" 라고 부를 수 있다.
  • 주문을 취소했다면 ? "주문을 취소했음 이벤트"

  • 이벤트가 발생한다는 것은 상태가 변경됐다는 것을 의미한다.
  • 이벤트는 발생한 것에서 끝나지 않는다.
  • 이벤트가 발생하면 그 이벤트에 반응하여 원하는 동작을 수행하는 기능을 구현한다.

  • 도메인 모델에서도 UI 컴포넌트와 유사하게 도메인의 상태 변경을 이벤트로 표현 할수있다.

이벤트 관련 구성 요소

  • 도메인 모델에서 이벤트 주체는 엔티티, 밸류, 도메인 서비스와 같은 도메인 객체이다.
  • 이들 도메인 객체는 도메인 로직을 실행해서 상태가 바뀌면 관련 이벤트를 발생한다.
  • 이벤트 핸들러는 이벤트 생성 주체가 발생한 이벤트에 반응 한다.
  • 이벤트 핸들러는 생성 주체가 발생한 이벤트를 전달받아 이벤트에 담긴 데이터를 이용해서 원하는 기능을 실행한다.
  • 예를 들어 주문이 취소됐을 경우 "주문 취소됨 이벤트"를 받는 이벤트 핸들러는 해당 주문의 주문자에게 SMS로 주문 취소 사실을 통지할 수 있다.

  • 이벤트 생성 주체와 이벤트 핸들러를 연결해 주는것이 이벤트 디스패처이다.
  • 이벤트 생성 주체는 이벤트를 생성해서 디스패처에 이벤트를 전달한다.
  • 이벤트를 전달받은 디스패처는 해당 이벤트를 처리할 수 있는 핸들러에 이벤트를 전파한다.
  • 이벤트 디스패처의 구현 방식에 따라 이벤트 생성과 처리를 동기나 비동기로 실행하게 된다.

이벤트의 구성

  • 이벤트는 발생한 이벤트에 대한 정보를 담는다.
    • 이벤트 종류: 클래스 이름으로 이벤트 종류를 표현
    • 이벤트 발생 시간
    • 추가 데이터: 주문번호, 신규 배송지 정보등 이벤트와 관련된 정보

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

  • 위 이벤트를 발생시킨 주체는 Order 애그리것이다.
  • Order 애그리것의 배송지 변경 기능을 구현한 메서드는 다음 코드처럼 배송지 정보를 변경한 뒤에 이벤트 디스패처를 이용해서 이벤트를 발생시킬 것이다.

public class ShippingInfoChangedHandler implement EventHandler<ShppingInfoChangedEvent> {

  @Override
  public void handle(ShppingInfoChangedEvent evt) {
    shippingInfoSynchronizer.sync(
      evt.getOrderNumber(),
      evt,getNewShippingInfo();
    )
  }
}
...

  • 변경된 배송지 정보를 물류 서비스에 재전송하는 핸들러는 다음과 같다.
  • ShippingInfoChangedEvent 를 처리하는 핸들러는 디스패처로부터 이벤트를 전달받아 필요한 작업을 수행한다.

  • 이벤트는 이벤트 핸들러가 작업을 수행하는 데 필요한 최소한의 데이터를 담아야 한다.
  • 이 데이터가 부족할 경우 핸들러는 필요한 데이터를 읽기 위해 관련 API를 호출하거나 DB에서 데이터를 직접 읽어와야 한다.
  • 그렇다고 이벤트 자체와 관련 없는 데이터를 포함할 필요는 없다.
  • 바뀐 배송지 정보를 포함하는 것은 맞지만 배송지 정보 변경과 관련없는 주문 상품번호와 개수를 담을 필요는 없다.

이벤트 용도

  • 이벤트는 크게 두 가지 용도로 쓰인다.
  • 첫번쨰는 트리거의 용도로 쓰이는데 도메인의 상태가 바뀔때 다른 후처리를 해야 할 경우 후처리를 실행하기 위한 트리거로 이벤트를 사용할 수 있다.

  • 예매 결과를 SMS로 통지할 떄도 이벤트를 트리거로 사용할 수 있다.
  • 예매 도메인은 예매 완료 이벤트를 발생시키고 이 이벤트 핸들러에서 SMS를 발송시키는 방식으로 구현

  • 이벤트의 두번째 용도는 서로 다른 시스템간의 데이터 동기화이다.
  • 배송지를 변경하면 외부 배송 서비스에 바뀐 배송지 정보를 전송해야 한다.
  • 이 경우 주문 도메인은 배송지 변경 이벤트를 발생시키고 이벤트 핸들러는 외부 배송 서비스와 배송지 정보를 동기화 한다.

이벤트 장점

  • 이벤트를 사용하면 첫장에서 말한 서로 다른 도메인 로직이 섞이는 것을 방지할 수 있다.
  • 또한 기능 확장도 용이해진다. 구매 취소시 환불과 함께 이메일을 보내고 싶다면 이메일 발송을 처리하는 핸들러를 구현하고 디스패처에 등록하면 된다.
  • 기능을 확장해도 도메인 로직은 수정할 필요가 없다.

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

  • 이벤트와 관련된 코드는 다음과 같다.
    • 이벤트 클래스
    • EventHandler: 이벤트 핸들러를 위한 상위 타입으로 모든 핸들러는 이 인터페이스를 구현한다.
    • Events: 이벤트 디스패처. 이벤트 발행, 이벤트 핸들러 등록, 이벤트를 핸들러에 등록하는 등의 기능을 제공한다.

이벤트 클래스

  • 이벤트 자체를 위한 상위 타입은 존재하지 않는다. 원하는 클래스를 이벤트로 사용할 것이다.
  • 이벤트클래스의 이름을 결정할때는 과거 시제를 사용해야 한다.
  • OrderCanceledEvent와 같이 클래스 이름뒤에 접미사로 Event를 사용해서 Event로 사용하는 클래스 라는것을 명시적으로 표현할 수 있고 OrderCanceld처럼 간결함을 위해 과거 시제만 사용할 수도 있다.

public class OrderCanceledEvent extends Event {
		// 이벤트는 핸들러에서 이벤트를 처리하는 데 필요한 데이터를 포함한다.
    private String orderNumber;
    
    public OrderCanceledEvent(String number) {
        super();
        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;
    }   
}
  • 모든 이벤트가 공통으로 갖는 프로퍼티가 존재한다면 관련 상위 클래스를 만들 수도 있다.
  • 위와 같은 상위 클래스를 만들고 각 이벤트 클래스가 상속받도록 할 수 있다.

EventHandler 인터페이스

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

public interface EventHandler<T> {
    
    // handle 메서드를 이용해서 필요한 기능을 구현한다. 
    void handle(T event);

    // 핸들러가 이벤트를 처리할 수 있는지 여부를 검사한다.
    default boolean canHandle(Object event) {
        Class<?>[] typeArgs = TypeResolver.resolveRawArguments(
            EventHandler.class, this.getClass()
        );
        
        return typeArgs[0].isAssignableForm(event.getClass());
    }
}
  • EventHandler 인터페이스를 상속받는 클래스는 handle() 메서드를 이용해서 필요한 내용을 구현하면 된다.
  • canHandler() 메서드는 핸들러가 이벤트를 처리할 수 있는지 여부를 검사한다.
  • Event의 타입이 T의 파라미터화 타입에 할당 가능하면 true를 리턴한다.

이벤트 디스패처인 Events 구현

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

    @Transactional
    public void cancel(OrderNo orderNo) {
        // handle 메서드에 전달한 EventHandler를 이용해서 이벤트를 처리하게 된다.
        Events.handle(
            (OrderCanceledEvent evt) -> refundService.refund(evt.getOrderNumber())
        );

        Order order = findOrder(orderNo);
        order.cancel();
    
        // ThreadLocal 변수를 초기화해서 OOME가 발생하지 않도록 한다.
        Events.reset();
    }
}
  • 도메인을 사용하는 응용 서비스는 이벤트를 받아 처리할 핸들러를 Events.handle()로 등록하고 도메인 기능을 실행한다.
  • 위 코드는 OrderCancledEvent가 발생하면 Events.handle() 메서드에 전달한 EventHandler를 이용해서 이벤트를 처리하게 된다.
  • Events는 내부적으로 핸들러 목록을 유지하기 위해 ThreadLocal을 사용한다.
  • Events.handle() 메서드는 인자로 전달받은 EventHandler를 List에 보관한다.
  • 이벤트가 발생하면 이벤트를 처리할 EventHandler를 list에서 찾아 EventHandler의 handle() 메서드를 호출해서 이벤트를 처리 한다.

public class Order {
    
    public void cancel() {
        verifyNotYetShipped();
        this.state = OrderState.CANCELED;
        Events.raise(new OrderCanceledEvent(number.getNumber()));
    }
}
  • Events.raise()를 이용해서 이벤트를 발생시키면 Events.raise() 메서드는 이벤트를 처리할 핸들러를 찾아 handle() 메서드를 실행한다.

public class Events {
    
    // EventHandler 목록을 보관하는 ThreadLocal 변수를 생성한다.
    private static ThreadLocal<List<EventHandler<?>>> handlers = 
        new ThreadLocal<>();

    // 이벤트를 처리 중인지 여부를 판단하는 ThreadLocal 변수를 생성한다.
    private static ThreadLocal<Boolean> publishing = 
        new ThreadLocal<Boolean>() {
            @Override
            protected Boolean initialValue() {
                return Boolean.FALSE;
            }   
        };

    // 파라미터로 전달받은 이벤트를 처리한다.
    public static void raise(Object event) {
        // 이벤트를 처리 중이면 진행하지 않는다.
        if (publishing.get()) return;
    
        try {
            // 이벤트 처리 중 상태를 true로 변경한다.
            publishing.set(Boolean.TRUE);
            
            // handlers에 담긴 EventHandler가 파라미터로 전달받은 이벤트를 처리할 수 있는지 확인하고 
            List<EventHandler<?>> eventHandlers = handlers.get();
            if (eventHandlers == null) return;
            for (EventHandler handler: eventHandlers) {
                // handlers에 담긴 EventHandler가 파라미터로 전달받은 이벤트를 처리할 수 있는지 확인한다.
                if (handler.canHandle(event)) {
                    // 처리 가능하면 핸들러의 handle() 메서드에 이벤트 객체를 전달한다.
                    handler.handle(event);
                }
            }
        } finally {
            // 핸들러의 이벤트 처리가 끝나면 처리 중 상태를 False로 변경한다.
            publishing.get(Boolean.FALSE);
        }
    }

    // 이벤트 핸들러를 등록하는 메서드
    public static void handle(EventHandler<?> handler) {
        // 이벤트를 처리 중이면 등록하지 않는다.
        if (publishing.get()) return;
        
        List<EventHandler<?>> eventHandlers = handlers.get();
        if (eventHandlers == null) {
            eventHandlers = new ArrayList<>();
            handlers.set(eventHandlers);
        }
        eventHandlers.add(handler);
    }

    // handlers에 보관된 List 객체를 삭제한다.
    public static void reset() {
        if (!publishing.get()) {
            handlers.remove();
        }
    }
}

출처: https://kjgleh.github.io/ddd/2019/08/18/ddd_start_10.html

public class Order {
    
    public void cancel() {
        verifyNotYetShipped();
        this.state = OrderState.CANCELED;
        
        Events.raise(new OrderCanceledEvent(number.getNumber()));
    }
}

  • Events는 핸들러 목록을 유지하기 위해 ThreadLocal 변수를 사용한다.
  • 톰캣과 같은 웹 애플리케이션 서버는 스레드를 재사용하므로 ThreadLocal에 보관한 값을 제거하지 않으면 기대했던것과 다르게 코드가 동작할수 있다.
  • 예를 들어 사용자의 요청을 처리한 뒤 Events.reset()을 실행하지 않으면 스레드 handlers가 담고 있는 List에 계속 핸들러 객체가 쌓이게 되어 결국 메모리 부족 에러가 발생하게 된다.
  • 따라서 이벤트 핸들러를 등록하는 응용 서비스는 다음과 같이 마지막에 Events.reset() 메서드를 실행해야 한다.

흐름 정리

출처: https://kjgleh.github.io/ddd/2019/08/18/ddd_start_10.html

  1. 이벤트 처리에 필요한 이벤트 핸들러 생성
  1. 이벤트 발생 전에 이벤트 핸들러를 Events.handle() 메서드를 이용해서 등록
  1. 이벤트를 발생하는 도메인 기능을 실행
  1. 도메인은 Events.raise()를 이용해서 이벤트 발생
  1. Events.raise()는 등록된 핸들러의 canHandle()를 이용해서 이벤트를 처리할수 있는지 확인
  1. 핸들러가 처리할 수 있다면 handle()메서드를 이용해서 이벤트를 처리
  1. Events.raise() 실행을 끝내고 리턴
  1. 도메인 기능 실행을 끝내고 리턴
  1. Events.reset()를 이용해서 ThreadLocal을 초기화 한다

  • 코드 흐름을 보면 응용 서비스와 동일한 트랜잭션 범위에서 핸들러의 handle()이 실행되는것을 알수 있다.
  • 즉 도메인의 상태변경과 이벤트 핸들러는 같은 트랜잭션 범위에서 실행된다.

AOP를 이용한 Events.reset() 실행

  • 응용 서비스가 끝나면 ThreadLocal에 등록된 핸들러 목록을 초기화 하기 위해
  • Events.reset() 메서드를 실행한다.
  • 모든 응용 서비스마다 메서드 말미에 Events.reset()을 실행하는 코드를 넣는것은 중복에 해당한다.
  • 이런 류의 중복을 없앨 떄 적합한 것이 바로 AOP다.
  • 코드는 아래와 같다.
@Aspect
// 우선순위를 0으로 지정한다. 이를 통해 트랜잭션 관련 AOP보다 우선순위를 높여 이 AOP가 먼저 적용되도록 한다.
@Order(0)
@Component
public class EventsResetProcessor {
		//서비스 메서드의 중첩 실행 개수를 저장하기 위한 ThreadLocal 변수를 생성한다.
    private ThreadLocal<Integer> nestedCount = new ThreadLocal<Integer>() {
        @Override
        protected Integer initialValue() {
            return new Integer(0);
        }
    };

		// @Around Aspect를 이용해서 AOP를 구현한다.
		// 적용 대상은 com.myshop 패키지 및 그 하위 패키지에 위치한 @Service가 붙은 빈 객체다
		@Around(
			"@target(org.springframework.stereotype.Service) and within(com.myshop..*)")
    public Object doReset(ProceedingJoinPoint joinPoint) throws Throwable {
				// 중첩 실행 횟수를 1 증가한다.
        nestedCount.set(nestedCount.get() + 1);
        try {
            // 대상 메서드를 실행한다.
            return joinPoint.proceed();
        } finally {
            // 중첩 실행 횟수를 1 감소한다.
            nestedCount.set(nestedCount.get() - 1);
            // 중첩 실행횟수가 0이면 Events.reset()을 실행한다.
            if (nestedCount.get() == 0) {
                Events.reset();
            }
        }
    }
}
  • Service 애노테이션을 이용해서 응용 서비스를 지정했는데 @Service를 사용하지 않을 경우
  • @Around의 포인트 컷에 @Target대신 execution() 명시자를 사용해도 된다.

@Around("execution(public * com.myshop..*Service.*(..))")
    public Object doReset(ProceedingJoinPoint joinPoint) throws Throwable {
        nestedCount.set(nestedCount.get() + 1);
        try {
            return joinPoint.proceed();
        } finally {
            nestedCount.set(nestedCount.get() - 1);
            if (nestedCount.get() == 0) {
                Events.reset();
            }
        }
    }

동기 이벤트 처리 문제

  • 이벤트를 사용해서 강결합 문제는 해소 했지만 외부 서비스에 영향을 받는 문제는 해결하지 못했다.

		@Transactional // 외부 연동 과정에서 익셉션이 발생하면 트랜잭션 처리는 ?
    public void cancel(OrderNo orderNo) {
        Events.handle(
				// refundService.refund()가 오래 걸리면 ?
            (OrderCanceledEvent evt) -> refundService.refund(evt.getOrderNumber())
        );

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

  • refundService.refund()가 외부와 연동으로 인해 느려졌다고 가정하면 나머지 후속 호출도 느려지게 된다.
  • 외부서비스의 성능 저하가 바로 내 시스템의 성능 저하로 연결된다는 것을 의미.
  • 성능 저하 뿐만아니라 트랜잭션도 문제가 된다.
  • refundServicerefund()에서 익셉션이 발생하면 cancel() 메서드의 트랜잭션을 롤백해야 할까 ? 롤백하면 구매 취소기능을 롤백하는것이므로 구매 취소에 실패하는 것이다.
  • 하지만 외부의 환불 서비스 실행에 실패했다고해서 반드시 트랜잭션을 롤백해야 하는지에 대한 의문이다
  • 일단 구매 취소 자체는 처리하고 환불만 재처리하거나 수동으로 처리할 수도 있다.

  • 외부 시스템과의 연동을 동기로 처리할 때 발생하는 성능과 트랜잭션 범위 문제를 해소하는 방법중 하나가 이벤트를 비동기로 처리하는 것이다.

비동기 이벤트 처리

  • 우리가 구현해야 할 것 중에 "A하면 이어서 B하라" 는 내용을 담고 있는 요구사항은 실제로
  • "A하면 최대 언제까지 B하라" 인 경우가 많다.
  • 즉 후속조치를 바로 할 필요없이 일정 시간 안에만 처리하면 되는 경우가 적지 않다.
  • 게다가 "A하면 이어서 B하라" 는 요구사항을 B를 하는데 실패하면 일정 간격으로 재시도를 하거나 수동 처리를 해도 상관이 없는 경우가 있다.
  • "A하면 일정 시간안에 B하라" 는 요구사항에서 "A하면" 은 이벤트로 볼수도 있다.
  • "A하면 이어서 B하라" 는 요구사항 중에서 "A하면 최대 언제까지 B하라"로 바꿀수 있는 요구 사항은 이벤트를 비동기 처리하는 방식으로 구현할 수 있다.
  • 다시 말해서, A 이벤트가 발생하면 별도 스레드로 B를 수행하는 핸들러를 실행하는 방식으로 요구사항을 구현할 수 있는 것이다.

  • 이벤트를 비동기로 구현하는 4가지 방법
    1. 로컬 핸들러를 비동기로 실행하기
    1. 메시지 큐를 사용하기
    1. 이벤트 저장소와 이벤트 포워더 사용하기
    1. 이벤트 저장소와 이벤트 제공 API 사용하기

로컬 핸들러를 비동기로 실행하기

  • 로컬핸들러를 비동기로 실행하는 방법은 이벤트 핸들러를 별도 스레드로 실행하는 것이다.
  • 앞서 구현한 Events 클래스에 비동기로 핸들러를 실행하는 기능을 구현한다
    • 비동기로 이벤트 핸들러를 실행할떄 ExecutorService를 사용하고 ExecutorService를 초기화하고 셧다운 기능을 구현

public class Events {
    private static ThreadLocal<List<EventHandler<?>>> handlers =
            new ThreadLocal<>();
    private static ThreadLocal<List<EventHandler<?>>> asyncHandlers =
            new ThreadLocal<>();
    private static ThreadLocal<Boolean> publishing =
            new ThreadLocal<Boolean>() {
                @Override
                protected Boolean initialValue() {
                    return Boolean.FALSE;
                }
            };

    private static ExecutorService executor;

    public static void init(ExecutorService executor) {
        Events.executor = executor;
    }

    public static void close() {
        if (executor != null) {
            executor.shutdown();
            try {
                executor.awaitTermination(10, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
            }
        }
    }

    public static void raise(Object event) {
        if (publishing.get()) return;

        try {
            publishing.set(Boolean.TRUE);

            List<EventHandler<?>> asyncEvtHandlers = asyncHandlers.get();
            if (asyncEvtHandlers != null) {
                for (EventHandler handler : asyncEvtHandlers) {
                    if (handler.canHandle(event)) {
                        executor.submit(() -> handler.handle(event));
                    }
                }
            }
            List<EventHandler<?>> eventHandlers = handlers.get();
            if (eventHandlers == null) return;
            for (EventHandler handler : eventHandlers) {
                if (handler.canHandle(event)) {
                    handler.handle(event);
                }
            }
        } finally {
            publishing.set(Boolean.FALSE);
        }
    }

    public static void handle(EventHandler<?> handler) {
        if (publishing.get()) return;

        List<EventHandler<?>> eventHandlers = handlers.get();
        if (eventHandlers == null) {
            eventHandlers = new ArrayList<>();
            handlers.set(eventHandlers);
        }
        eventHandlers.add(handler);
    }

    public static void handleAsync(EventHandler<?> handler) {
        if (publishing.get()) return;

        List<EventHandler<?>> eventHandlers = asyncHandlers.get();
        if (eventHandlers == null) {
            eventHandlers = new ArrayList<>();
            asyncHandlers.set(eventHandlers);
        }
        eventHandlers.add(handler);
    }

    public static void reset() {
        if (!publishing.get()) {
            handlers.remove();
            asyncHandlers.remove();
        }
    }
}

  • 비동기로 실행할것은 executor.submit을 이용
  • 동기로 실행할것은 handler.handle로 바로 실행

  • Events.handleAsync()로 등록한 이벤트 핸들러는 별도 스레드로 실행되고
  • 그냥 등록한것들은 같은 스레드에서 실행된다.
  • 한 트랜잭션으로 실행해야하는 이벤트 핸들러는 비동기로 처리하면 안된다
  • 비동기로 처리하면 한 트랜잭션 범위에 묶을수 없기 떄문이다.

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

  • 비동기로 이벤트를 처리해야 할 때 사용하는 또 다른 방법은 RabbitMQ와 같은 메시징 큐를 사용하는 것이다.

출처: https://minkukjo.github.io/dev/2020/11/20/DDD-10/

  • 이벤트가 발생하면 위와 같이 이벤트를 메시지 큐에 보낸다.
  • 메시지 큐는 이벤트를 메시지 리스너에 전달하고 메시지 리스너는 알맞은 이벤트 핸들러를 이용해서 이벤트를 처리한다.
  • 이때 이벤트를 메시지 큐에 저장하는 과정과 메시지 큐에서 이벤트를 읽어와 처리하는 과정은 별도 스레드나 프로세스로 처리된다.
  • 필요하다면 이벤트를 발생하는 도메인 기능과 메시지 큐에 이벤트를 저장하는 절차를 한 트랜잭션으로 묶어야 한다.
  • 도메인 기능을 실행한 결과를 DB에 반영하고 이 과정에서 발생한 이벤트를 메시지 큐에 저장하는 것을 같은 트랜잭션 범위에서 실행하려면 글로벌 트랜잭션이 필요하다.
  • 글로벌 트랜잭션을 사용하면 안전하게 이벤트를 메시지 큐에 전달할 수 있는 장점이 있지만 반대로 글로벌 트랜잭션으로 인해 성능이 떨어지는 단점도 있다.

  • 많은 경우 메시지 큐를 사용하면 보통 이벤트를 발생하는 주체와 이벤트 핸들러가 별도 프로세스에서 동작한다.
  • 이는 자바의 경우 이벤트 발생 JVM과 이벤트 처리 JVM이 다르다는 것을 의미한다.
  • 물론 한 JVM에서 비동기 처리를 위해 메시지 큐를 사용하는 것은 시스템을 복잡하게 만들 뿐이다.

  • RabbitMQ처럼 많이 사용되는 메시징 시스템은 글로벌 트랜잭션 지원과 함께 클러스터와 고가용성을 지원하기 때문에 안정적으로 메시지를 전달할 수 있는 장점이 있다.
  • 또한 다양한 개발언어와 통신 프로토콜을 지원하고 있다.
  • 카프카 또한 많이 사용된다.

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

출처: https://minkukjo.github.io/dev/2020/11/20/DDD-10/

  • 비동기 이벤트를 처리하기위한 또다른 방법은 이벤트를 일단 DB에 저장한 다음 별도 프로그램을 이용해서 이벤트 핸들러에 전달하는 것이다.
  • 이벤트가 발생하면 핸들러는 이벤트 스토리지에 이벤트를 저장한다.
  • 포워더는 별도 스레드를 이용하기 떄문에 이벤트 발행과 처리가 비동기로 처리된다.
  • 이 방식은 도메인의 상태와 이벤트 저장소로 동일한 DB를 사용한다
  • 즉 도메인의 상태 변화와 이벤트 저장이 로컬 트랜잭션으로 처리된다.
  • 이벤트를 물리적 저장소에 보관하기 때문에 핸들러가 이벤트 처리에 실패할 경우 포워더는 다시 이벤트 저장소에서 이벤트를 읽어와 핸들러를 실행하면 된다.

출처: https://minkukjo.github.io/dev/2020/11/20/DDD-10/

  • 또다른 저장소를 이용한 방법은 위와 같이 이벤트를 외부에 제공하는 API를 사용 하는 것이다.
  • API방식과 포워더 방식의 차이점은 이벤트를 전달하는 방식에 있다
  • 포워더 방식에서는 포워더를 이용해 이벤트를 외부에 전달하고
  • API방식은 외부 핸들러가 API 서버를 통해 이벤트 목록을 가져오는 것이다.
    • PUSH와 PULL의 차이인듯
  • 포워더 방식은 이벤트를 어디까지 처리했는지 추적하는 역할이 포워더에 있다면
  • API 방식에서는 이벤트 목록을 요구하는 외부 핸들러가 자신이 어디까지 이벤트를 처리했는지 기억해야 한다.

이벤트 저장소 구현

  • 포워더 방식과 API방식 모두 이벤트 저장소를 사용하므로 이벤트를 저장할 저장소가 필요하다.

출처: https://developer-jang.tistory.com/26

  • EventEntry
    • 이벤트 저장소에 보관할 데이터
    • EventEntry는 이벤트를 식별하기 위한 id, 이벤트 타입인 type, 직렬화한 데이터인 contentType, 이벤트 데이터 자체인 payload, 이벤트 시간인 timestamp를 갖는다.
  • EventStore
    • 이벤트를 저장하고 조회하는 인터페이스를 제공한다.
  • JdbcEventStore
    • JDBC를 이용한 EventStore 구현 클래스이다.
  • EventApi
    • Rest API를 이용해서 이벤트 목록을 제공하는 컨트롤러이다.

소스참조: https://github.com/madvirus/ddd-start/tree/master/src/main/java/com/myshop/eventstore/api

이벤트 저장을 위한 이벤트 핸들러 구현

소스참조: https://github.com/madvirus/ddd-start/tree/master/src/main/java/com/myshop/common/event

Rest API 구현

소스참조: https://github.com/madvirus/ddd-start/tree/master/src/main/java/com/myshop/eventstore/ui

포워더 구현

소스참조: https://github.com/madvirus/ddd-start/tree/master/src/main/java/com/myshop/integration

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

  1. 이벤트 소스를 EventEntry에 추가할지 여부다
    • 위에서 구현한 EventEntry는 이벤트 발생 주체가 없다
    • 따라서 특정 주체가 발생한 이벤트만 조회하는 기능을 구현할 수 없다.
    • (알아서 직접구현하라고 함)
  1. 포워더에서 전송 실패를 얼마나 허용할 것이냐 에 대한 여부
    • 특정이벤트에서 계속 전송을 실패하게되면 이 이벤트 때문에 나머지 이벤트를 전송할수 없게된다.
    • 이럴때 실패한 이벤트의 재전송 횟수에 제한을 두어야 한다.
  1. 이벤트 손실에 대한 여부
    • 이벤트 저장소를 사용하는 방식은 이벤트 발생과 이벤트 저장을 한 트랜잭션으로 처리하기 떄문에 트랜잭션에 성공하면 이벤트가 저장소에 보관된다는 것을 보장할 수 있다.
    • 반면 로컬 핸들러를 이용해서 이벤트를 비동기로 처리할 경우 이벤트 처리에 실패하면 이벤트를 유실하게 된다.
  1. 이벤트 순서에 대한 여부
    • 이벤트를 발생 순서대로 외부 시스템에 전달해야 할 경우 이벤트 저장소를 사용하는것이 좋다.
    • 이벤트 저장소는 일단 저장소에 이벤트를 발생순서대로 저장하고 그 순서대로 이벤트 목록을 제공하기 떄문이다.
    • 반면 메시징 시스템은 사용 기술에 따라 이벤트 발생 순서와 메시지 전달 순서가 다를수 있다.
  1. 이벤트 재처리에 대한 여부
    • 동일한 이벤트를 다시 처리해야 할때 이벤트를 어떻게 할지 결정해야 한다.
    • 가장 쉬운 방법은 마지막으로 처리한 이벤트 순번을 기억해 두었다가 이미 처리한 순번의 이벤트가 도착하면 해당 이벤트를 처리하지않고 무시하는것이다.
    • 이외에 이벤트 처리를 멱등으로 처리하는 방법도 있다.

+ Recent posts