[DDD START] 10장 이벤트
18 Aug 2019시스템 간 강결합의 문제
쇼핑몰에서 구매를 취소하려면 환불을 처리해야 한다. 이 때 환불 기능을 실행하는 주체는 주문 엔티티가 될 수 있다.
다음 두가지 방법으로 환불 기능을 구현할 수 있다.
- 주문 도메인에서 환불 처리
- 응용 서비스에서 환불 처리
주문 도메인에서 환불 처리
public class Order {
// 외부 서비스를 실행하기 위해 도메인 서비스를 파라미터로 전달 받음
public void cancel(RefundService refundService) {
// 주문과 관련된 로직
verifyNotYetShipped();
this.state = OrderState.CANCELED;
this.refundStatus = State.REFUND_STARTED;
// 결제와 관련된 로직
try {
refundService.refund(getPaymentId());
this.refundStatus = State.REFUND_COMPLETED;
} catch (Exception e) {
...
}
}
}
응용 서비스에서 환불 처리
public class CancelOrderService {
private RefundService refundService;
@Transactional
public void cancel(OrderNo orderNo) {
Order order = findOrder(orderNo);
order.cancel();
order.refundStarted();
try {
refundService.refund(order.getPaymentId());
order.refundCompleted();
} catch (Exception e) {
...
}
}
}
보통 결제 시스템은 외부에 존재하므로 RefundService는 외부의 환불 시스템을 호출하는데 여기서 세 가지 문제가 발생한다.
- 외부의 환불 서비스에서 예외가 발생하면 주문 트랜잭션을 롤백해야 하는가?
- 주문만 취소 상태로 변경하고 환불은 나중에 처리할수도 있다.
- 외부 시스템의 성능에 직접적인 영향을 받는다.
- 주문 도메인에서 환불을 처리하면 주문 도메인이 결제 도메인의 영향을 받게 된다.
이벤트
이벤트를 사용하면 서로 다른 도메인이 섞이는 것을 방지할 수 있다.
이벤트의 구성요소
- 이벤트 주체: 엔티티, 밸류, 도메인 서비스와 같은 도메인 객체이다. 도메인 객체는 도메인 로직을 실행해서 상태가 바뀌면 관련 이벤트를 발행한다.
- Event Handler: 이벤트 생성 주체가 발행한 이벤트를 처리한다.
- Event Dispatcher: 이벤트 발행, 이벤트 핸들러 등록, 이벤트를 핸들러에 등록하는 등의 기능을 제공한다.
구현
이벤트
- 이벤트는 핸들러에서 이벤트를 처리하는 데 필요한 데이터를 포함한다.
- 이벤트 클래스의 이름은 과거형을 사용한다.
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());
}
}
Event Dispatcher인 Events 구현
- 도메인을 사용하는 응용 서비스는 이벤트를 받아 처리할 핸들러를 Events.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();
}
}
}
이벤트 적용
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();
}
}
public class Order {
public void cancel() {
verifyNotYetShipped();
this.state = OrderState.CANCELED;
// Events.raise를 이용해서 이벤트를 발생시키면 Events.raise() 메서드는 이벤트를 처리할 핸들러를 찾아 handle() 메서드를 실행한다.
Events.raise(new OrderCanceledEvent(number.getNumber()));
}
}
이벤트 처리 흐름
- 이벤트 처리에 필요한 이벤트 핸들러를 생성한다.
- 이벤트 발행 전에 이벤트 핸들러를 Events.handle() 메서드를 이용해서 등록한다.
- 이벤트를 발행하는 도메인 기능을 실행한다.
- 도메인은 Events.raise()를 이용해서 이벤트를 발행한다.
- Events.raise()는 등록된 핸들러의 canHandle()을 이용해서 이벤트를 처리할 수 있는지 확인한다.
- 핸들러가 이벤트를 처리할 수 있다면 handle() 메서드를 이용해서 이벤트를 처리한다.
- Events.raise() 실행을 끝내고 리턴한다.
- 도메인 기능 실행을 끝내고 리턴한다.
- Events.reset()을 이용해서 ThreadLocal을 초기화한다.
동기 이벤트 처리 문제
이벤트를 사용해서 강결합 문제를 해소했지만 외부 서비스에 영향을 받는 문제가 남아있다.
- 외부 연동 과정에서 예외가 발생하면 트랜잭션 처리는?
- refundService.refund()가 오래 걸리면?
A하면 이어서 B하라
는 요구사항 중에서 A하면 최대 언제까지 B하라
로 바꿀 수 있는 요구사항은
이벤트를 비동기로 처리하는 방식으로 구현할 수 있다.
비동기 이벤트 구현 방법
- 로컬 핸들러를 비동기로 실행하기
- 메시지 큐 사용하기
- 이벤트 저장소와 이벤트 포워더 사용하기
- 이벤트 저장소와 이벤트 제공 API 사용하기