이벤트
쇼핑몰에서 구매를 취소하면 환불을 처리해야 한다. 이때 환불 기능을 실행하는 주체는 Order 도메인 엔티티가 될 수 있는데, 도메인 객체에서 환불 기능을 실행하려면 환불 기능을 제공하는 도메인 서비스를 파라미터로 받고 취소 도메인 기능에서 도메인 서비스를 실행하게 된다.
public class Order {
public void cancel(RefundService refundService) {
verifyNotYetShipped();
this.state = OrderState.CANCELD;
this.refundStatus = State.REFUND_STARTED;
try {
refundService.refund(getPaymentId());
this.refundStatus = State.REFUND_COMPLETED;
} catch(Exception ex) {
...
}
}
}
보통 결제 시스템은 외부에 존재하므로 RefundService는 외부에 있는 결제 시스템이 제공하는 환불 서비스를 호출한다. 그러면 외부 서비스가 정상이 아닐 경우에는 트랜잭션 처리를 어떻게해야 할 지가 고민일 수 있다. 환불 기능을 실행하는 과정에서 예외가 발생했을 때 트랜잭션을 롤백해야 할까? 외부의 환불 서비스를 실행하는 과정에서 예외가 발생하면 환불에 실패했으므로 주문 취소를 하는 것이 맞아보인다. 하지만 생각해보면 주문은 취소 상태로 변경하고 환불만 나중에 다시 시도해도 크게 문제는 없어보인다. (실제로도 운영사이트에서는 취소랑 환불을 분리하는 거 같다.)
또 다른 고민은 성능에 관한 것인데, 환불을 처리하는 외부 시스템이 길어지면 그만큼 대기 시간도 길어진다. (동기로 실행했을 때 기준) 즉, 외부 서비스 성능에 직접적인 영향을 받게 된다.
또 다른 고민은 도메인 객체에 서비스를 전달하면 설계상 문제가 발생할 수 있는데, 위 코드에서는 Order는 주문을 표현하는 도메인 객체인데, 결제 도메인의 환불 관련 로직이 뒤섞이게 된다. 이는 환불 기능이 변경하면 Order도 영향을 받게되는 SRP를 위반하게 된다는 말이다. (결제 도메인 때문에 주문 도메인이 변경됨)
또 다른 고민은 기능을 추가할 때도 발생할 수 있는데, 주문을 취소한 뒤에 취소했다는 내용을 통지해야 한다면 어떻게 될까? 그러면 위 코드에서 파라미터로 또 해당 서비스를 넘겨줘야 한다. 이는 로직이 섞이는 문제와 트랜잭션 처리를 더 복잡하게 만들어준다.
위처럼 이러한 문제가 발생하는 이유는 주문 바운디드 컨텍스트와 결제 바운디드 컨텍스트간의 강결합 때문이다. 주문이 결제와 강하게 결합되어 있어서 주문 바운디드 컨텍스트가 결제 바운디드 컨텍스트에 영향을 받게 되는 것이다.
이런 강결합을 없앨 수 있는 방법이 바로 이벤트이다.
이벤트는 '과거에 벌어진 어떤 것'을 의미하는데, JS에서의 이벤트와 비슷하다. 도메인 모델에서도 이와 유사하게 도메인의 상태 변경을 이벤트로 표현할 수 있다.
'주문을 취소할 때 이메일을 보낸다' 라는 요구사항이 있다고 했을 때, '주문이 취소할 때'는 주문이 취소 상태로 바뀌는 것을 의미하므로 '주문 취소됨 이벤트'를 활용해서 구현할 수 있다.
도메인 모델에 이벤트를 도입하려면 네 개의 구성요소가 필요하다. 이벤트, 이벤트 생성 주체, 이벤트 디스패처(퍼블리셔), 이벤트 핸들러(구독자)를 구현해야 한다.
이벤트 생성 주체는 엔티티, 밸류, 도메인 서비스와 같은 도메인 객체를 의미하고, 이벤트 핸들러는 이벤트 생성 주체가 발생한 이벤트에 반응한다. 따라서 이벤트를 전달받아 이벤트에 담긴 데이터를 이용해서 원하는 기능을 실행한다. 예를들어 '주문 취소됨'을 받은 이벤트는 이메일을 보내는 사실을 통지할 수 있다.
이벤트 생성 주체와 이벤트 핸들러를 연결해 주는 것이 이벤트 디스패처이다. 이벤트 생성 주체는 이벤트를 생성한 뒤에 디스패처에 이벤트를 전달하고, 디스패처는 해당 이벤트를 처리할 수 있는 핸들러에 이벤트를 전파하는 역할을 한다. 이 디스패처의 구현 방식에 따라서 동기, 비동기로 실행할 수 있다.
이벤트는 발생한 이벤트에 대한 정보를 담는다. (이벤트 종류(클래스 이름), 이벤트 발생 시간, 추가 데이터(주문번호 등과 같이 이벤트와 관련된 정보)) 배송지를 변경할 때 발생하는 이벤트를 생각해 보면 다음과 같이 작성할 수 있다.
public class ShippingInfoChangedEvent {
private String orderName;
private long timeStamp;
private ShippingInfo newShippingInfo;
}
클래시 이름은 과거 시제를 사용하는게 좋다.(현재 기준으로 과거에 벌어진 것을 표현하기 때문에) 또 이벤트는 핸들러가 작업을 수행하는데 필요한 데이터를 담아야 한다. 데이터가 부족하면 핸들러는 필요한 데이터를 읽기 위해서 관련 API를 호출하거나 DB에서 데이터를 가져올 수 있다.
이벤트는 크게 두 가지 용도로 사용하는데, 첫 번째 용도는 트리거다. 도메인의 상태가 바뀔 때 다른 후처리가 필요하면 후처리를 실행하기 위한 트리거로 이벤트를 사용할 수 있다. 예를들어, 맨처음 예시처럼 주문을 취소했을 때 환불을 처리해야 하는데 이때 환불 처리를 위한 트리거로 주문 취소 이벤트를 사용할 수 있다.
이벤트의 두 번째 용도는 서로 다른 시스템 간의 데이터 동기화이다. 배송지를 변경하면 외부 배송 서비스에 바뀐 배송지 정보를 전송해야 하는데, 주문 도메인은 배송지 변경 이벤트를 발생시켜 이벤트 핸들러에서 외부 배송 서비스와 배송지 정보를 동기화할 수 있다. (결국 트리거아닌가..?)
실제 이벤트와 관련된 코드를 구현함으로 조금 더 이벤트를 이해해보자. 이벤트와 관련된 코드는 다음과 같다.
- 이벤트 클래스 이벤트를 표현
- 디스패처 : 스프링이 제공하는 ApplicationEventPublisher를 이용
- Events : 이벤트를 발행. 이벤트 발행을 위해 ApplicationEventPublisher를 사용
- 이벤트 핸들러 : 이벤트를 수신해서 처리. 스프링이 제공하는 기능을 사용
ApplicationEventPublisher는 스프링에서 제공하는 인터페이스로, 이벤트 프로그래밍에 필요한 명세서를 제공한다. ApplicationContext 에 이미 상속되어 있어 해당 구현체에도 접근가능하다. 먼저 이벤트 클래스를 만들어보자. 이벤트 자체를 위한 상위 타입은 존재하지 않는다. 원하는 클래스를 이벤트로 사용하면 된다.
@Getter
public class OrderCanceledEvent {
private String orderNumber;
public OrderCanceledEvent(String number) {
this.orderNumber = number;
}
}
이제, 이벤트 발생과 이벤트 핸들러로 넘겨주기 위한 디스패처를 생성한다. 이는 스프링이 제공하는 ApplicationEventPublisher를 사용한다.
public class Events {
private static ApplicationEventPublisher publisher;
static void setPublisher(ApplicationEventPublisher publisher) {
Events.publisher = publihser;
}
public static void raise(Object event) {
if(publisher != null) {
publisher.publishEvent(event);
}
}
}
@Configuration
public class EventsConfiguration {
@Autowired
private ApplicationContext context;
@Bean
public InitinalizingBean eventsInitionalzer() {
return () -> Events.setPublisher(context);
}
}
Events 클래스의 raise() 메서드는 ApplicationEventPublisher가 제공하는 publishEvent() 메서드를 이용해서 이벤트를 발생시킨다. EventsConfiguraton을 통해 ApplicationEventpulisher를 셋팅한다. InitinalizingBean 타입 객체는 스프링 빈 객체를 초기화할 때 사용하는 인터페이스로, eventsInitionalizer 빈이 초기화 될 때 ApplicationEventpulisher에 setPublisher를 해주는 작업을 진행한다.
이제, 이벤트를 처리할 핸들러를 작성하면 된다.
@Service
public class OrderCanceledEventHandler {
private RefundService refundService;
public OrderCanceledEventHandler(RefundService refundService) {
this.refundService = refundService;
}
@EventListener(OrderCanceledEvent.class)
public void handler(OrderCancledEvent event) {
refundService.refund(event.getOrderNumber());
}
}
@EventListener 애너테이션을 통해 OrderCancledEvent 처리를 위한 핸들러를 구현할 수 있다. 이제 Order 도메인에서 취소 메서드를 작성할 때 이벤트를 날려주기만 하면 된다.
public class Order {
public void cancel() {
verifyNotYetShipped();
this.state = OrderState.CANCELD;
Events.raise(new OrderCanceledEvent(number.getNumber()));
}
}
전체 흐름을 정리하면, 도메인 기능이 실행되고 도메인 기능은 Event.raise를 통해 이벤트를 발생시킨다. 이 이벤트는 스프링이 제공하는 ApplicationEventPublisher를 이용해서 @EventListener(이벤트타입.class) 애너테이션이 붙은 메서드를 찾아 실행시킨다.
위 예제를 통해 이벤트를 통한 강결합 문제는 해결했지만, 아직 외부 서비스에 영향을 받는 문제가 남아있다. 즉, refundService.refund()가 외부 환불 서비스와 연동한다고 했을 때, 외부 기능이 느려지면 cancel() 메서드도 함께 느려진다. 따라서 이를 해결하기 위한 여러가지 방법이 존재한다.
- 로컬 핸들러를 비동기로 실행
- 메시지 큐, 카프카를 사용
- 이벤트 저장소와 포워더 또는 이벤트 제공 API 사용
로컬 핸들러를 비동기로 실행한다고 했을 때 간단히 실행하는 방법은 스프링이 제공하는 @Async 애너테이션을 사용하면 된다. @Async 애너테이션을 사용하기 위해 @EnableAsync 애너테이션을 사용해 비동기 기능을 활성화 시켜야한다. @Async는 SimpleAsyncTaskExecutor를 사용한다.
다른 방법들은 생략하겠다.
이벤트 적용 시 추가 고려 사항이 있는데, 개인적으로 생각할 부분은 이벤트 처리와 DB 트랜잭션을 고려하는 부분이다. 이벤트를 처리할 때는 DB 트랜잭션을 함께 고려해야 하는데, 예를들어 주문 취소와 환불 기능을 다음과 같이 이벤트를 이용해서 구현했다고 하자.
- 주문 취소 기능은 주문 취소 이벤트를 발생
- 주문 취소 이벤트 핸들러는 환불 서비스에 환불 처리를 요청
- 환불 서비스는 외부 API를 호출해서 결제를 취소
이벤트 핸들러를 비동기로 처리했을 때, 외부 API가 실패했을 경우는 실제 DB에는 주문이 취소되었지만 실제로는 환불 서비스는 안될 수도 있다. 만약 이를 하나의 트랜잭션으로 묶고 싶을때는 스프링에서 제공하는 @TransactionalEventListener 애너테이션을 사용하면 된다. phase 속성 값으로 TransactionPhase.AFTER_COMMIT을 지정하면 트랜잭션 커밋을 성공한 뒤에 핸들러 메서드를 실행하게 된다. (동기에서만 사용하는건가..?)
CQRS
주문 내역 조회 기능을 구현하려면 여러 애그리거트에서 데이터를 가져와야 한다. Order에서는 주문 정보를, Product에서는 상품 이름을, Member에서는 회원 이름과 ID를 가져와야 한다. 조회 화면 특성상 조회 속도가 빠를수록 좋은데 여러 애그리거트의 데이터가 필요하면 구현 방법을 고민해야 한다.
객체 지향으로 도메인 모델을 구현할 때 주로 사용하는 ORM 기법은 도메인 상태 변경 기능을 구현하는데는 적합하지만 여러 애그리거트에서 데이터를 가져와 출력하는 기능을 구현하기에는 고려할 게 많다. 따라서 이런 구현 복잡도를 낮추기 위한 방법으로는 상태 변경을 위한 모델과 조회를 위한 모델을 분리하는 것이다.
도메인 모델 관점에서 상태 변경 기능은 주로 한 애그리거트의 상태를 변경한다. 예를 들어 주문 취소 기능과 배송지 변경 기능은 한 개의 Order 애그리거트를 변경한다. 반면에 조회 기능에 필요한 데이터를 표시하려면 두 개 이상의 애그리거트가 필요할 때가 많다. 이를 해결하기 위한 기법으로 CQRS가 있는데, 이는 상태를 변경하는 명령을 위한 모델과 상태를 제공하는 조회를 위한 모델을 분리하는 패턴을 말한다.
CQRS는 복잡한 도메인에 적합하다. 도메인이 복잡할수록 명령 기능과 조회 기능이 다루는 데이터 범위에 차이가 나기때문에 도메인 모델이 복잡해지는 것을 막을 수 있다. 따라서 CQRS를 사용하면 명령 모델은 객체 지향에 기반해서 도메인 모델을 구현하기에 좋은 JPA를, 조회 모델은 DB 테이블에서 SQL로 데이터를 조회할 때 좋은 마이바티스를 사용해서 구현하면 된다.
일반적인 웹 서비스는 상태를 변경하는 요청보다 상태를 조회하는 요청이 많다. 온라인 쇼핑몰을 예로 들면 주문 요청보다 카탈로그를 조회하고 상품의 상세 정보를 조회하는 요청이 비교할 수 없을 정도로 많다. 따라서 조회 성능을 높이기 위해 쿼리를 최적화해서 쿼리 실행 속도 자체를 높히거나, 메모리에 조회 데이터를 캐싱해서 응답 속도를 높이기도 한다.
CQRS 패턴을 적용할 때 얻을 수 있는 장점은 명령 모델을 구현할 때 도메인 자체에 집중할 수 있다는 점이다. 조회 성능을 위한 코드가 명령 모델에 없으므로 도메인 로직을 구현하는 데 집중할 수 있다. 또 다른 장점으로는 조회 성능을 향상시키는데 유리하다는 점이다. 조회 단위로 캐시 기술을 적용할 수 있고, 조회에 특화된 쿼리를 마음대로 사용할 수 있다. 캐시뿐만 아니라 조회 전용 저장소를 사용하면 조회 처리량을 대폭 늘릴 수 있다.
반면에 단점으로는, 구현해야 할 코드가 많아진다. 단일 모델을 사용할 때 발생하는 복잡함 때문에 발생하는 구현 비용과 조회 전용 모델을 생성해야하는 비용이 있다. 만약 도메인이 단순하다면 조회 전용을 따로 만들 때 이점이 있는지 확인해야 한다.
참조
도메인 주도 개발 시작하기 - 최범균
'DDD' 카테고리의 다른 글
도메인 모델과 바운디드 컨텍스트 (0) | 2022.12.27 |
---|---|
도메인 서비스, 애그리거트 트랜잭션 관리 (0) | 2022.12.20 |
응용 서비스와 표현 영역 (0) | 2022.12.13 |
리포지터리와 모델 구현 (0) | 2022.12.01 |
애그리거트 (0) | 2022.11.13 |