이벤트
이벤트는 여러 애그리거트에 걸처 처리해야하는 작업이 하나의 트랜잭션으로 묶일 필요가 없는 경우 사용하기 좋은 데이터 처리 방법이다. 예를 들어 주문 취소 및 환불 프로세스는 순차적으로 실행되어야 하지만 실제로 트랜잭션으로 묶여 처리될 필요는 없다. 주문 취소는 주문 도메인의 영역이고, 환불은 결제 도메인의 영역으로 분리되어 있다고 가정한다.
시스템 간 강결합 문제
만약 주문 취소와 환불이 하나의 트랜잭션으로 묶이는 경우를 생각해보자. 사용자로부터 주문 취소 요청이 들어왔을 때, 표현 계층은 주문 애그리거트의 상태를 주문 취소로 변경하고, 해당 주문의 결제 정보를 찾아 환불 프로세스를 진행한다. 포인트제도 처럼 결제가 완벽하게 애플리케이션의 내부에서 진행된다면 문제가 없을 것이다.
하지만 보통 결제는 은행, 핀테크 서비스 등의 외부의 서비스와 통신을 통해 진행되므로 외부와 통신한 뒤 환불이 진행될 것이다. 여기서 외부의 결제 서비스가 가용중이 아닌 상황일 수도 있고, 응답이 지연될 수도 있다. 이러한 경우 사용자의 요청은 애플리케이션의 내부 컴퓨팅 리소스를 계속 점유하고 있을 것이고, 응답이 지연되어 사용자는 애플리케이션 사용에 있어 불편함을 느끼게 될 것이다.
또 다른 문제점이 있다. 만약 주문 취소 기간이 임박한 상태에서 사용자의 주문 취소 요청이 들어왔다고 가정한다. 사용자는 급하게 주문 취소를 요청하고 애플리케이션은 주문 취소 및 환불 작업을 실행한다. 이 때 외부 결제 서비스가 응답이 지연되어 취소되었고, 주문 및 환불은 하나의 트랜잭션으로 묶여 있기에 주문 애그리거트의 상태는 다시 주문완료 상태로 롤백된다. 여기서 사용자가 주문 취소 작업이 정상적으로 처리되지 않았음을 응답받은 시점에 주문 취소 기간이 지나버려 재요청이 불가능한 상황이라면? 사용자 입장에서는 굉장히 불쾌한 경험이 될 것이다.
간단하게 코드로 구현하면 아래와 같다.
public class OrderService {
private final OrderOutputPort orderOutputPort;
private final RefundOutputPort refundOutputPort;
// JPA 영속성 컨텍스트에서 더티체킹 후 persist
@Transactional
public void cancel(Long orderId) {
Order order = OrderOutputPort.findById(OrderId).orElseThrow(() ->
new OrderNotFountException();
);
order.cancel(); // order 애그리거트 내부에서 캔슬이 가능한지 체크한 뒤 상태 변경
refundOutputPort.refund(order.getPaymentId()); // 외부 결제 서비스 호출
order.refund(); // order 애그리거트 상태 변경
}
}
위의 로직을 살펴보면 주문을 담당하는 OrderService에서 주문과 결제 정보를 함께 처리하여 코드의 복잡도가 증가한다. 취소 및 환불 로직은 주문과 결제 도메인이 강하게 결합되어 있기에 분리하는 편이 좋다. 이럴 때 이벤트를 사용한다.
이벤트 사용
이벤트는 과거에 벌어진 특정한 사건, 상황을 의미한다. 예를 들어 사용자가 주문을 취소했다면 ‘주문 취소됨 이벤트’가 발생했다고 말할 수 있다.
주문 취소와 결제 환불이 하나의 트랜잭션으로 묶이면 발생할 수 있는 문제에 대해 알아봤다. 사실 주문을 취소 상태로 변경하고 환불에 대한 처리는 비동기 방식으로 나중에 처리해도 아무런 상관이 없다. 이커머스 플랫폼에서 우리가 주문을 취소하면 주문이 취소되었다는 응답을 환불은 일주일 정도 걸릴 수 있다는 메시지와 함께 바로 받을 수 있다. 이를 생각하고 이벤트를 이해해보자.
주문 도메인과 결제 도메인의 결합도를 낮추려면 어떻게 하는 것이 좋을까? 이벤트를 사용하면 이렇게 처리할 수 있다. 주문 취소 및 환불 요청이 들어온 경우 주문을 취소상태로 변경하고, 주문 취소됨
이라는 이벤트를 발행하는 것이다. 주문 취소됨
이벤트를 바라보고 있던 환불 서비스는 이벤트에 포함된 결제 식별자를 통해 환불을 진행하면 된다.
시퀀스 다이어그램으로 알아보자. 먼저 사용자로부터 주문 식별자를 받아 주문을 조회하고 주문의 상태를 변경한다. 주문의 상태가 정상적으로 변경되었다면 주문 식별자를 포함한 주문 취소됨
이벤트를 발행한다.
public class OrderService {
private final OrderOutputPort orderOutputPort;
private final OrderEventPublisher orderEventPublisher;
// 애그리거트 상태변경과 이벤트 발행은 하나의 트랜잭션으로 묶어야 함
@Transactional
public void cancel(Long orderId) {
// 주문을 조회하고 상태 변경
Order order = OrderOutputPort.findById(OrderId).orElseThrow(() ->
new OrderNotFountException();
);
order.cancel();
// 주문 취소 이벤트 생성 및 발행
OrderEvent<OrderCancel> orderCancelEvent
= new OrderCancelEvent(order.paymentID());
orderEventPublisher.publish(orderCancelEvent);
}
}
이후 주문 취소됨 이벤트를 구독하고 있던 결제 서비스는 메시지 브로커를 통해 주문 취소됨 이벤트를 받고 외부 결제 시스템에 환불을 요청한다. 환불 요청이 정상적으로 승인 되었다면 환불 신청 완료됨 이벤트를 발행한다.
public class PaymentService {
private final PaymentOutputPort paymentOutputPort;
private final PaymentEventPublisher paymentEventPublisher;
@Transactional
public void cancel(Long paymentId) {
Payment payment = paymentOutputPort.refund(paymentId);
PaymentEvent<refundRequest> refundRequestEvent
= new RefundRequestEvent(payment.id());
paymentEventPublisher.publish(refundRequestEvent);
}
}
이제 두 도메인에 대한 처리가 완벽하게 분리되고, 취소에 대한 환불 처리가 비동기적으로 실행된다. 여기서 유의해야 할 점은 주문의 상태 변경과 주문 취소 이벤트 발행을 하나의 트랜잭션으로 묶어야 한다는 것과, 이벤트를 처리하는 로직에서는 중복 메시지를 솎아 내거나, 멱등한 핸들러를 작성하는 식으로 중복 이벤트를 처리해야 한다는 것이다.
'CS' 카테고리의 다른 글
Rate Limiter 와 구현 알고리즘 (1) | 2024.04.25 |
---|---|
CQRS (0) | 2024.04.07 |
DDD - 애그리거트 트랜잭션 관리와 동시성 처리 (0) | 2024.04.05 |
DDD - 도메인과 애그리거트 (1) | 2024.03.27 |
Blocking vs Non-Blocking I/O (0) | 2024.03.26 |