-
[도메인 주도 개발 시작하기] 10. 이벤트STUDY/DDD 2023. 1. 10. 00:13
1. 시스템 간 강결합 문제
쇼핑몰에서 구매를 취소하면 환불을 처리해야 한다. 이때 환불 기능을 실행하는 주체는 주문 도메인 엔티티가 될 수 있고, 응용 서비스에서 환불 기능을 실행할 수도 있다.
1. 도메인 엔티티에서 실행 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 ex) { ... } } } 2. 응용 서비스에서 실행 public class CancelOrderService { private RefundService refundService; @Transactional public void cancel(OrderNo orderNo) { Order order = findOrder(orderNo); order.cancel(); order.refundService(); try { refundService.refund(order.getPaymentId()); order.refundCompleted(); } catch (Exception ex) { ... } } }
보통 결제 시스템은 외부에 존재하므로 RefundService는 외부에 있는 결제 시스템이 제공하는 환불 서비스를 호출하게 되는데, 이때 두 가지 문제가 발생할 수 있다.
- 외부 서비스가 정상이 아닐 경우(환불 기능을 실행하는 과정에서 exception이 발생하는 경우) 트랜잭션 처리를 어떻게 해야 할지 애매하다.
- 환불을 처리하는 외부 시스템의 응답 시간이 길어지는 만큼 주문 취소 기능의 대시 시간도 증가한다. 즉, 외부 서비스 성능에 직접적인 영향을 받게 된다.
- 도메인 객체에 서로 다른 도메인 로직(주문 도메인 객체에 결제 도메인의 환불 관련 로직)이 섞이는 문제가 발생한다.
- 기능을 추가할 경우 새로운 서비스를 파라미터로 받게 되고, 이렇게 되면 로직이 섞이는 문제가 커지고 트랜잭션 처리가 복잡해진다.
위 문제가 발생하는 이유는 주문 바운디드 컨텍스트와 결제 바운디드 컨텍스트간의 강결합(high coupling) 때문이다. 주문이 결제와 강하게 결합되어 있어서 주문 바운디드 컨텍스트가 결제 바운디드 컨텍스트에 영향을 받게 되는 것이다.
이런 강한 결합을 없앨 수 있는 방법은 '이벤트'를 사용하는 것이다. 특히 비동기 이벤트를 사용하면 두 시스템 간의 결합을 크게 낮출 수 있다.
2. 이벤트 개요
이벤트라는 용어는 '과거에 벌어진 어떤 것'을 의미한다. 예를 들어 사용자가 암호를 변경한 것을 '암호를 변경했음 이벤트'가 벌어졌다고 할 수 있다. 이벤트가 발생한다는 것은 상태가 변경됐다는 것을 의미한다. 이벤트는 발생하는 것에서 끝나지 않고, 그 이벤트에 반응하여 원하는 동작을 수행하는 기능을 구현한다.
도메인 모델에서도 도메인의 상태 변경을 이벤트로 표현할 수 있다. 예를 들어 '주문을 취소할 때 이메일을 보낸다'라는 요구사항에서 '주문을 취소할 때'는 주문이 취소 상태로 바뀌는 것을 의미하므로 '주문 취소됨 이벤트'를 활용해서 구현할 수 있다.
도메인 모델이 이벤트를 도입하려면 네 개의 구성요소를 구현해야 한다.
- 이벤트
- 이벤트 생성 주체
- 이벤트 디스패처(퍼블리셔)
- 이벤트 핸들러(구독자)
이벤트 생성 주체
엔티티, 밸류, 도메인 서비스와 같은 도메인 객체로, 도메인 로직을 실행해서 상태가 바뀌면 관련 이벤트를 발생시킨다.
이벤트 핸들러
이벤트 생성 주체가 발생한 이벤트에 반응한다. 생성 주체가 발생한 이벤트를 전달받아 이벤트에 담긴 데이터를 이용해서 원하는 기능을 실행한다.
이벤트 디스패처
이벤트 생성 주체와 이벤트 핸들러를 연결해 주는 것이다. 이벤트 생성 주체는 이벤트를 생성해서 디스패처에 이벤트를 전달하고, 이벤트를 전달받은 디스패처는 해당 이벤트를 처리할 수 있는 핸들러에 이벤트를 전파한다. 이벤트 디스패처의 구현 방식에 따라 이벤트 생성과 처리를 동기나 비동기로 실행하게 된다.
이벤트
발생한 이벤트에 대한 정보를 담는다.
- 이벤트 종류 : 클래스 이름으로 이벤트 종류를 표현
- 이벤트 발생 시간
- 추가 데이터 : 주문번호, 신규 배송지 정보 등 이벤트와 관련된 정보
ex) 배송지를 변경할 때 발생하는 이벤트
# 이벤트를 위한 클래스 public class ShippingINfoChangedEvent { private String orderNumber; private long timestamp; private ShippingInfo newShippingInfo; // 생성자, getter }
# 이벤트를 발생하는 주체 public class Order { public void changeShippingInfo(ShippingInfo shippingInfo) { verifyNotYetShipped(); setShippingInfo(newShippingInfo); // 디스패처를 통해 이벤트를 전파하는 기능 제공 Events.raise(new SippingInfoChangedEvent(number, newShippingInfo)): } ... }
# 디스패처로부터 이벤트를 전달받아 필요한 작업을 수행하는 핸들러 public class ShinngInfoChangedHandler { @EventListener(ShippingInfoChangedEvent.class) public void handler(ShippingInfoChangedEvent evt) { shippingInfoSynchronizer.sync( evt.getOrderNumber(), evt.getNewShippingInfo() ); } }
이벤트는 이벤트 핸들러가 작업을 수행하는 데 필요한 데이터를 담아야 한다. 이 데이터가 부족하면 핸들러는 필요한 데이터를 읽기 위해 관련 API를 호출하거나 DB에서 데이터를 직접 읽어와야 한다. 이벤트는 데이터를 담아야 하지만 그렇다고 이벤트 자체와 관련 없는 데이터를 포함할 필요는 없다.
이벤트는 크게 두 가지 용도로 쓰인다.
- 트리거(Trigger)
- 서로 다른 시스템 간의 데이터 동기화
첫째, 도메인의 상태가 바뀔 때 다른 후처리가 필요하면 후처리를 실행하기 위한 트리거로 이벤트를 사용할 수 있다. 주문에서는 주문 취소 이벤트를 트리거로 사용할 수 있는데, 주문을 취소하면 환불을 처리해야 하는데 이때 환불 처리를 위한 트리거로 주문 취소 이벤트를 사용할 수 있다.
두번째, 서로 다른 시스템 간의 데이터 동기화 용도로 사용할 수 있다. 배송지를 변경하면 외부 배송 서비스에 바뀐 배송지 정보를 전송해야 하는데, 주문 도메인은 배송지 변경 이벤트를 발생시키고 이벤트 핸들러는 외부 배송 서비스와 배송지 정보를 동기화할 수 있다.
이벤트 장점
- 서로 다른 도메인 로직이 섞이는 것을 방지할 수 있다.
- 이벤트 핸들러 사용으로 기능 확장이 용이하다.
서로 다른 도메인 로직이 섞이는 것을 방지할 수 있는데, 기존에 구매 취소 로직내에 환불 로직이 같이 존재했는데, 이벤트를 적용함으로써 구매 취소 로직에 환불 로직이 없어지고, 환불 실행을 위한 파라미터도 없어지게 된다. 즉, 주문 도메인에서 결제(환불) 도메인으로의 의존을 제거할 수 있게 된다.
이벤트 핸들러를 사용하면 기능 확장에 용이해지는데, 구매 취소 시 환불과 함께 이메일로 취소 내용을 보내고 싶은 경우, 이메일 발송을 처리하는 핸들러를 구현만 하면 된다.
3. 이벤트, 핸들러, 디스패처 구현
이벤트 관련 코드
- 이벤트 클래스 : 이벤트를 표현한다.
- 디스패처 : 스프링이 제공하는 ApplicationEventPublisher를 이용한다.
- Events : 이벤트를 발생한다. 이벤트 발행을 위해 ApplicationEventPublisher를 사용한다.
- 이벤트 핸들러 : 이벤트를 수신해서 처리한다. 스프링이 제공하는 기능을 사용한다.
이벤트 클래스
- 이벤트 자체를 위한 상위 타입은 존재하지 않고, 원하는 클래스를 이벤트로 사용.
- 이벤트는 과거에 벌어진 상태 변화나 사건을 의미하므로 이벤트 클래스의 이름을 결정할 때에는 과거 시제를 사용해야 한다는 점 유의.
- 이벤트를 처리하는 데 필요한 최소한의 데이터를 포함해야 함.
- 모든 이벤트가 공통으로 갖는 프로퍼티가 존재할 경우 상위 클래스를 만들어 상속받아 구현
# 공통 프로퍼티를 갖는 상위 클래스 public abstract class Event { private long timestamp; public Event() { this.timestamp = System.currentTimeMillis(); } public long getTimestamp() { return timestamp; } } # 이벤트 클래스 public class OrderCanceledEvent extends Event { private string orderNumber; public OrderCanceledEvent(String number) { super(); this.orderNumber = number; } }
Events 클래스와 ApplicationEventPublisher
# ApplicationEventPublisher를 사용해서 이벤트를 발생시키는 Events 클래스 public class Events { private static ApplicationEventPublisher publisher; static void setPublisher(ApplicationEventPublisher publisher) { Events.publisher = publisher; } // 이벤트를 발생시키는 메서드 public static void raise(Object event) { if (publisher != null) { publisher.publishEvent(event); } } }
이벤트 발생
public class Order { public void cancel() { verifyNotYetShipped(); this.state = OrderState.CANCELED; // 이벤트 발생 코드 Events.raise(new OrderCanceledEvent(number.getNumber())): } ... }
이벤트 핸들러
스프링이 제공하는 @EventListener 애너테이션을 사용해서 구현한다.
public class OrderCancelEventHandler { private RefundService refundService; public OrderCanceledEventHandler(RefundService refundService) { this.refundService = refundService; } @EventListener(OrderCanceledEvent.Class) public void handler(OrderCanceledEvent event) { refundService.refund(event.getOrderNumber()); } }
이벤트 처리 흐름
- 도메인 기능을 실행한다.
- 도메인 기능은 Events.raise()를 이용해서 이벤트를 발생시킨다.
- Events.raise()는 스프링이 제공하는 ApplicationEventPublisher를 이용해서 이벤트를 출판한다.
- ApplicationEventPublisher는 @EventListener(이벤트타입.class) 애너테이션이 붙은 메서드를 찾아 실행한다.
4. 동기 이벤트 처리 문제
외부 서비스의 영향을 받을 경우, 외부 서비스의 성능 저하가 시스템의 성능 저하로 연결될 수 있고, 뿐만 아니라 트랜잭션도 문제가 된다. 예를 들어 외부 환불 서비스를 이용할 경우, 환불 서비스에서 익셉션이 발생했을 때 주문 취소 메서드의 트랜잭션을 롤백해야 하지는에 대한 문제가 발생할 수 있다. 이때 반드시 트랜잭션을 롤백하지 않고도, 구매 취소 자체는 처리하고 환불만 재처리하거나 수동으로 처리하는 방법도 존재한다.
외부 시스템과의 연동을 동기로 처리할 때 발생하는 성능과 트랜잭션 범위 문제를 해소하는 방법은 이벤트를 비동기로 처리하거나 이벤트와 트랜잭션을 연계하는 것이다.
5. 비동기 이벤트 처리
'A하면 이어서 B하라'는 요구사항 중에는 'A하면 최대 언제까지 B 하라'로 바꿀 수 있는 요구사항이 존재한다. 예를 들어, 회원 가입 신청 시점에서 인증 이메일 발송을 할 경우, 바로 이메일이 발송되지 않아도 되고 또한 발송을 실패하더라도 사용자가 이메일 재전송을 요청하여 수동으로 이메일을 받을 수 있다.
이러한 요구사항은 이벤트를 비동기로 처리하는 방식으로 구현할 수 있다. 즉, A 이벤트가 발생하면 별도 스레드로 B를 수행하는 핸들러를 실행하는 방식으로 요구사항을 구현할 수 있다.
비동기 이벤트 처리 구현 방법
- 로컬 핸들러를 비동기로 실행하기
- 메시지 큐를 사용하기
- 이벤트 저장소와 이벤트 포워더를 사용하기
- 이벤트 저장소롸 이벤트 제공 API 사용하기
로컬 핸들러 비동기 실행
이벤트 핸들러를 비동기로 실행하는 방법은 이벤트 핸들러를 별도 스레드로 실행하는 것이다. 스프링이 제공하는 @Async 애너테이션을 사용하면 비동기로 이벤트 핸들러를 실행할 수 있다.
- @EnableAsync 애너테이션을 사용해서 비동기 기능을 활성화한다.
- 이벤트 핸들러 메서드에 @Async 애너테이션을 붙인다.
# 스프링 설정 클래스에 @EnableAsync 애너테이션 추가 @SpringBootApplication @EnableAsync public class ShopApplication { ... } # 비동기로 실행할 이벤트 핸들러 메서드에 @Async 애너테이션 추가 @Service public class OrderCanceledEventHandler { ... @Async @EventListener(OrderCanceledEvent.class) public void handler(OrderCanceledEvent event) { ... } }
메이징 시스템을 이용한 비동기 구현
카프카나 래빗MQ와 같은 메시징 시스템을 사용해서 비동기로 이벤트를 처리할 수 있다. 이벤트가 발생하면 이벤트 디스패처는 이벤트를 메시지 큐에 보낸다. 메시지 큐는 이벤트를 메시지 리스너에 전달하고, 메시지 리스너는 알맞은 이벤트 핸들러를 이용해서 이벤트를 처리한다. 이때 이벤트를 메시지 큐에 저장하는 과정과 메시지 큐에서 이벤트를 읽어와 처리하는 과정은 별도 스레드나 프로세스로 처리된다.
이벤트 저장소를 이용한 비동기 처리
이벤트를 일단 DB에 저장한 뒤에 별도 프로그램을 이용해서 이벤트 핸들러에 전달하는 것으로 이벤트를 비동기 처리할 수 있다.
이벤트가 발생하면 핸들러는 스토리지에 이벤트를 저장한다. 포워더는 주기적으로 이벤트 저장소에서 이벤트를 가져와 이벤트 핸들러를 실행한다. 포워더는 별도 스레드를 이용하기 때문에 이벤트 발행과 처리가 비동기로 처리된다.
이 방식은 도메인의 상태와 이벤트 저장소로 동일한 DB를 사용한다. 즉, 도메인의 상태 변화와 이벤트 저장이 로컬 트랜잭션으로 처리된다. 이벤트를 물리적 저장소에 보관하기 때문에 핸들러가 이벤트 처리에 실패할 경우 포워더는 다시 이벤트 저장소에서 이벤트를 읽어와 핸들러를 실행하면 된다.
이벤트를 외부에 제공하는 API를 통한 비동기 처리
API 방식과 포워더 방식의 차이점은 이벤트를 전달하는 방식에 있다. 포워더 방식이 포워더를 이용해서 이벤트를 외부에 전달한다면, API 방식은 외부 핸들러가 API 서버를 통해 이벤트 목록을 가져간다. 포워더 방식은 이벤트를 어디까지 처리했는지 추적하는 역할이 포워더에 있다면 API 방식에서는 이베트 목록을 요구하는 외부 핸들러가 자신이 어디까지 이벤트를 처리했는지 기억해야 한다.
6. 이벤트 적용 시 추가 고려 사항
- 이벤트 소스를 EventEntry에 추가할지 여부
- 포워더에서 전송 실패를 얼마나 허용할 것인가
- 이벤트 손실
- 이벤트 순서
- 이벤트 재처리
- DB 트랜잭션
출처 : 최범균, 『도메인 주도 개발 시작하기 DDD 핵심 개념 정리부터 구현까지』, 한빛미디어(2022), p300-p341
'STUDY > DDD' 카테고리의 다른 글
[도메인 주도 개발 시작하기] 11. CQRS (0) 2023.01.10 [도메인 주도 개발 시작하기] 9. 도메인 모델과 바운디드 컨텍스트 (1) 2022.12.27 [도메인 주도 개발 시작하기] 8. 애그리거트 트랜잭션 관리 (0) 2022.12.19 [도메인 주도 개발 시작하기] 7. 도메인 서비스 (0) 2022.12.19 [도메인 주도 개발 시작하기] 6. 응용 서비스와 표현 영역 (0) 2022.12.12