시스템 간 강결합 문제
- 쇼핑몰에서 구매를 취소하면 환불을 처리해야 한다. 이떄 환불 기능을 하는 주체는 주문 도메인 엔티티가 될 수 있다.
- 도메인 서비스를 파라미터로 전달받고 취소 도메인 기능에서 도메인 서비스를 실행하게 된다.
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
- 이벤트 처리에 필요한 이벤트 핸들러 생성
- 이벤트 발생 전에 이벤트 핸들러를 Events.handle() 메서드를 이용해서 등록
- 이벤트를 발생하는 도메인 기능을 실행
- 도메인은 Events.raise()를 이용해서 이벤트 발생
- Events.raise()는 등록된 핸들러의 canHandle()를 이용해서 이벤트를 처리할수 있는지 확인
- 핸들러가 처리할 수 있다면 handle()메서드를 이용해서 이벤트를 처리
- Events.raise() 실행을 끝내고 리턴
- 도메인 기능 실행을 끝내고 리턴
- 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가지 방법
- 로컬 핸들러를 비동기로 실행하기
- 메시지 큐를 사용하기
- 이벤트 저장소와 이벤트 포워더 사용하기
- 이벤트 저장소와 이벤트 제공 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처럼 많이 사용되는 메시징 시스템은 글로벌 트랜잭션 지원과 함께 클러스터와 고가용성을 지원하기 때문에 안정적으로 메시지를 전달할 수 있는 장점이 있다.
- 또한 다양한 개발언어와 통신 프로토콜을 지원하고 있다.
- 카프카 또한 많이 사용된다.
- 책에서는 카프카는 글로벌 트랜잭션을 지원하지 않는다고 했지만 2020년 11월부로 지원한다.
이벤트 저장소를 이용한 비동기 처리
출처: 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
이벤트 적용시 추가 고려 사항
- 이벤트 소스를 EventEntry에 추가할지 여부다
- 위에서 구현한 EventEntry는 이벤트 발생 주체가 없다
- 따라서 특정 주체가 발생한 이벤트만 조회하는 기능을 구현할 수 없다.
- (알아서 직접구현하라고 함)
- 포워더에서 전송 실패를 얼마나 허용할 것이냐 에 대한 여부
- 특정이벤트에서 계속 전송을 실패하게되면 이 이벤트 때문에 나머지 이벤트를 전송할수 없게된다.
- 이럴때 실패한 이벤트의 재전송 횟수에 제한을 두어야 한다.
- 이벤트 손실에 대한 여부
- 이벤트 저장소를 사용하는 방식은 이벤트 발생과 이벤트 저장을 한 트랜잭션으로 처리하기 떄문에 트랜잭션에 성공하면 이벤트가 저장소에 보관된다는 것을 보장할 수 있다.
- 반면 로컬 핸들러를 이용해서 이벤트를 비동기로 처리할 경우 이벤트 처리에 실패하면 이벤트를 유실하게 된다.
- 이벤트 순서에 대한 여부
- 이벤트를 발생 순서대로 외부 시스템에 전달해야 할 경우 이벤트 저장소를 사용하는것이 좋다.
- 이벤트 저장소는 일단 저장소에 이벤트를 발생순서대로 저장하고 그 순서대로 이벤트 목록을 제공하기 떄문이다.
- 반면 메시징 시스템은 사용 기술에 따라 이벤트 발생 순서와 메시지 전달 순서가 다를수 있다.
- 이벤트 재처리에 대한 여부
- 동일한 이벤트를 다시 처리해야 할때 이벤트를 어떻게 할지 결정해야 한다.
- 가장 쉬운 방법은 마지막으로 처리한 이벤트 순번을 기억해 두었다가 이미 처리한 순번의 이벤트가 도착하면 해당 이벤트를 처리하지않고 무시하는것이다.
- 이외에 이벤트 처리를 멱등으로 처리하는 방법도 있다.
'book > DDD start' 카테고리의 다른 글
11장 CQRS (0) | 2021.07.31 |
---|---|
8장 애그리것 트랜잭션 관리 2 (0) | 2021.07.07 |
8장 애그리것 트랜잭션 관리 (0) | 2021.07.07 |
9장 도메인 모델과 바운디드 컨텍스트 2부 (0) | 2021.06.30 |
9장 도메인 모델과 바운디드 컨텍스트 1부 (0) | 2021.06.30 |
Uploaded by Notion2Tistory v1.1.0