DDD 도메인 주도 설계 10장

DDD - 도메인 주도 설계

DDD

앞으로 적어갈 글은 최범균님의 저서 DDD START!를 읽고 요약 - 정리한 글입니다.

제 10장 : 이벤트

제 10장 이벤트

시스템 간 강결합의 문제

  • 사용자가 환불을 하는 경우를 생각해보자.

  • 보통 결제 시스템은 외부에 존재하는데, 그러면 결국 우리가 만든 서비스가 외부 시스템에 의존하는 형태가 된다.

  • 그러면 두 가지 문제가 발생한다.

    1. 외부 서비스에 오류가 생기는 경우 트랜잭션 처리를 어떻게 해야할까?
    1. 환불 기능을 실행하는 과정에서 예외가 발생하면 트랜잭션을 롤백해야할까? 아니면 일단 커밋 해야할까?
  • 외부 환불 서비스가 실패했으므로 트랜잭션을 롤백하는게 맞아 보인다.

  • 그러나 주문은 취소 상태로 변경하고 환불만 나중에 다시 시도하는 방식으로 처리할 수도 있다.

  • 또 다른 문제는 사실 성능인데, 외부 시스템의 응답 시간이 길어지면 그만큼 대기 시간이 발생할 수 밖에 없다.

  • 두 가지 문제 외에 도메인 객체에 서비스를 전달하면 추가로 설계상 문제가 나타날 수도 있다.

  • 아래와 같이 주문 로직과 결제 로직이 섞이는 문제가 발생한다.
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) { // 결제 로직
      ... // 결제 로직
    } // 결제 로직
  }
}
  • Order는 주문을 표현하는 도메인 객체인데 결제 도메인의 환불 관련 로직이 뒤섞여버리게 되었다.

  • 도메인 객체에 서비스를 전달할 때 또 다른 문제는 기능을 추가할 때 발생한다.

  • 만약 주문 취소 후 환불 뿐만 아니라 취소 했다는 내용을 통지해야한다면 로직은 더욱 더 섞이고 트랜잭션 처리는 더 복잡해진다.

  • 이러한 문제가 발생하게된 원인은 주문 BOUNDED CONTEXT와 결제 BOUNDED CONTEXT 간의 강결합 때문이다.

  • 이런 강결합 문제를 해결하기 위한 방법은 바로 이벤트를 사용하는 것이다.

  • 특히 비동기 이벤트를 사용하면 두 시스템 간의 결합을 크게 낮출 수 있다.

이벤트 개요

  • 이벤트가 발생한다는 것은 상태가 변경되었다는 것을 의미한다.

  • 이벤트는 발생하는 것에 그치지 않고 이벤트가 발생하면 그 이벤트에 반응하여 원하는 동작을 수행하는 기능을 구현한다.

  • 자바스크립트의 onClick과 같은 이벤트를 생각해보라.

이벤트 관련 구성요소

스크린샷 2020-11-19 오후 9 33 59

  • 도메인 모델에서 이벤트 주체는 엔티티, 밸류, 도메인 서비스와 같은 도메인 객체이다. 이들은 도메인 로직을 실행해 상태가 바뀌면 이벤트를 발생시킨다.

  • 이벤트 핸들러는 이벤트 생성 주체가 발생한 이벤트에 반응한다. 이벤트 핸들러는 생성 주체가 발생한 이벤트를 전달받아 이벤트에 담긴 데이터를 이용해 원하는 기능을 실행한다.

  • 이벤트 생성 주체와 이벤트 핸들러를 연결해주는 것이 이벤트 디스패처이다. 이벤트 생성 주체는 이벤트를 생성해 디스패처에 전달하고 디스패처는 해당 이벤트를 처리할 수 있는 핸들러에 이벤트를 전파시킨다.

이벤트의 구성

이벤트는 발생한 이벤트에 대한 정보를 담는다.

  • 이벤트 종류 : 클래스 이름으로 이벤트 종류를 표현

  • 이벤트 발생 시간

  • 추가 데이터 : 주문번호, 신규 배송지 정보 등 이벤트와 관련된 정보

배송지를 변경할 때 발생하는 이벤트를 생각해보자.

public class ShippingInfoChangedEvent {
  private String orderNumber;
  private long timestamp;
  private ShippingInfo newShippingInfo;

  // 생성자, getter
}
  • 클래스 이름에 Changed라는 과거 시제가 들어간 것은 이벤트는 현재 기준으로 과거에 벌어진 것을 표현하기 위함이다.

  • 이 이벤트를 발생시키는 주체는 Order 애그리거트이다.

  • 이 코드에서 Events.raise()는 디스패처를 통해 이벤트를 전파하는 기능을 제공한다.

public class Order {
  public void changeShippingInfo(ShippingInfo newShippingInfo) {
    verifyNotYetShipped();
    setShippingInfo(newShippingInfo);
    Events.raise(new ShippingInfoChangedEvent(number, newShippingInfo));
  }
  ...
}
  • ShippingInfoChangedEvent를 처리하는 핸들러는 디스패처로부터 이벤트를 전달받아 필요한 작업을 수행한다.

  • 변경된 배송지 정보를 물류 서비스에 재전송하는 핸들러는 다음과 같다.

public class ShippingInfoChangedHandler implement EventHandler<ShppingInfoChangedEvent> {

  @Override
  public void handle(ShppingInfoChangedEvent evt) {
    shippingInfoSynchronizer.sync(
      evt.getOrderNumber(),
      evt,getNewShippingInfo();
    )
  }
}
...
  • 이벤트는 이벤트 핸들러가 작업을 수행하는데 필요한 최소한의 데이터를 담아야한다.

  • 주의해야할 점은 이벤트에 이벤트 발생과 관련이 없는 정보는 담을 필요가 없다.

이벤트 용도

  • 이벤트는 크게 두 가지 용도로 쓰인다.

스크린샷 2020-11-19 오후 9 46 55

    1. 트리거
    • 도메인의 상태가 바뀔 때 마다 후처리를 해야하는 경우
    • 주문을 취소하면 환불을 처리해야하는데, 환불 처리를 위한 트리거로 주문 취소 이벤트를 사용할 수 있다.
    1. 서로 다른 시스템 간의 동기화
      • 배송지가 변경된 경우 외부 배송 서비스에 바뀐 배송지 정보를 전송해주어야한다.
      • 이 때 주문 도메인은 배송지 변경 이벤트를 발생시키고 이벤트 핸들러는 외부 배송 서비스와 배송지 정보를 동기화한다.

이벤트 장점

스크린샷 2020-11-19 오후 9 48 41

  • 이벤트의 가장 큰 장점은 서로 다른 도메인 로직이 섞이는 것을 방지할 수 있다.

  • 기능 확장에 용이하다.

동기식 이벤트 처리 문제

  • 이벤트를 사용해 강결합 문제를 해결할 수 있지만 외부 서비스에 받는 영향을 해소하지 못했다.

  • 외부 시스템과의 연동으로 인해 발생하는 성능 저하와 트랜잭션 범위 문제를 해결하기 위한 방법 중 하나가 이벤트를 비동기로 처리하는 것이다.

비동기 이벤트 처리

  • 회원 가입 신청을 하면 검증을 위해 이메일을 보내는 서비스가 많다.

  • 그래서 우리는 살다보면 많은 요구사항들이 A하면 최대 언제까지 B하라라는 요구사항인 경우가 많음을 알 수 있다.

  • 때문에 A 이벤트가 발생하면 별도 스레드로 B를 수행하는 핸들러를 실행하는 방식으로 요구사항을 구현할 수 있다.

  • 이벤트를 비동기로 구현하는 방법은 매우 다양하다.

  • 이 책에서는 네 가지의 비동기 이벤트 처리 구현을 다룬다.

  1. 로컬 핸들러를 비동기로 실행

  2. 메시지 큐 이용

  3. 이벤트 저장소와 이벤트 포워더 사용

  4. 이벤트 저장소와 이벤트 제공 API 사용

로컬 핸들러의 비동기 실행

  • 이벤트 핸들러를 비동기로 실행하는 방법은 이벤트 핸들러를 별도 스레드로 실행하는 것이다.

  • 별도 스레드로 이벤트 핸들러를 사용한다는 것은 다른 트랜잭션 범위에 영향을 받지 않는다는 것을 의미한다.

    • 스프링의 트랜잭션 관리자는 스레드를 이용해서 트랜잭션을 전파한다.
    • 물론 스레드가 아닌 다른 방식을 사용해 트랜잭션을 전파할 수 있지만, 가장 일반적으로 사용하는 트랜잭션 관리자는 스레드를 이용한다.

메시징 시스템을 이용한 비동기 구현

  • 비동기로 이벤트를 처리하는 또 다른 방법은 RabbitMQ나 Kafka와 같은 메시징 큐를 이용하는 것이다.

스크린샷 2020-11-23 오전 7 05 20

  • 필요하다면 이벤트를 발생하는 도메인 기능과 미시지 큐에 이벤트를 저장하는 절차를 한 트랜잭션으로 묶을 수도 있다.

  • 도메인 기능을 실행한 결과를 DB에 반영하고 발생한 이벤트를 메시지 큐에 저장하는 것을 같은 트랜잭션 범위에서 처리하기 위해서는 글로벌 트랜잭션이 필요하다.

    • 이 글로벌 트랜잭션은 안전하게 이벤트를메시지 큐에 전달할 수 있다는 장점이 있지만 그만큼 성능이 떨어진다는 단점이 있다. 스크린샷 2020-11-23 오전 7 34 18
    • 책에는 카프카가 글로벌 트랜잭션을 지원하지 않는다고 한다..고 되어있는데 이는 현재 시점 2020년 11월 23일부로 Spring의 @Transactional 애노테이션을 지원하면서 바뀌게 되었다.
    • 그래서 @Transactional('chainedTransactionManager') 애노테이션을 다음과 같이 등록해서 사용해볼 수 있다.
  @Bean(name = "chainedTransactionManager")
public ChainedTransactionManager chainedTransactionManager(JpaTransactionManager jpaTransactionManager,
                                                           KafkaTransactionManager kafkaTransactionManager) {
    return new ChainedTransactionManager(kafkaTransactionManager, jpaTransactionManager);
}
 주석 코드 : This local strategy is an alternative to executing Kafka operations within, and synchronized with, external transactions. This strategy is not able to provide transactions, for example in order to share transactions between messaging and database access.

 번역 : 이 로컬 전략은 외부 트랜잭션 내에서 그리고 동기화 된 Kafka 작업을 실행하는 대안입니다. 이 전략은 메시징과 데이터베이스 액세스간에 트랜잭션을 공유하기 위해 트랜잭션을 제공 할 수 없습니다.
  • 일반적으로 메시지 큐는 Producer와 Consumer가 별도의 프로세스에서 동작한다.
    • 하나의 JVM에서 Producer와 Consumer가 동작하는 것은 시스템만 복잡해지고 비효율적이다.

이벤트 저장소를 이용한 비동기 처리

스크린샷 2020-11-23 오전 7 41 44

  • 비동기로 이벤트를 처리하기 위한 또 다른 방법은 이벤트를 일단 DB에 저장해두고 별도 프로그램을 이용해 핸들러에 전달하는 것이다.

  • 이벤트가 발생하면 핸들러는 스토리지에 이벤트를 저장한다.

  • 포워더는 주기적으로 이벤트 저장소에서 이벤트를 가져와 이벤트 핸들러를 실행시킨다.

  • 포워더는 별도 스레드를 이용하므로 이벤트 발행과는 처리가 비동기로 처리된다.

  • 이벤트를 물리적 저장소에 보관하므로 핸들러 이벤트 처리에 실패할 경우, 포워더는 다시 이벤트 저장소에서 이벤트를 읽어와 핸들러를 실행하면 된다.

스크린샷 2020-11-23 오전 7 43 09

  • API 방식과 포워더 방식의 차이는 이벤트를 전달하는 방식에 그 차이가 있다.

  • 포워더 방식에서는 이벤트를 어디까지 처리했는지를 추적하는 역할이 포워드라면, API는 이벤트 목록을 요구하는 외부 핸들러가 자신이 어디까지 이벤트를 처리했는지 기억해야한다.

  • IAM에서는 위와 비슷한 방식으로 동기화를 제공하고 있다.

이벤트 저장소 구현

  • 포워더 방식과 API 방식 모두 이벤트 저장소를 사용한다.

  • 이벤트 저장소의 코드 구조는 위와 같다.

  • EventEntry : 이벤트 저장소에 보관할 데이터이다. EventEntry는 이벤트를 식별하기 위한 id, 이벤트의 타입인 type, 직렬화한 데이터 형식인 contentType, 이벤트 데이터 자체인 payload, 이벤트 시간인 timestamp를 갖는다.

이벤트 적용 시 추가 고려사항

  1. 이벤트 발행 주체를 이벤트에 포함 시킬지 여부 ( e.q. 주문 도메인이 발행한 이벤트만 조회하기)

  2. 포워더에서 전송 실패를 얼마나 허용할 것인가?

  3. 이벤트 손실을 얼마나 허용할 것인지, 이벤트 저장소를 사용한다면 이벤트 발생과 이벤트 저장이 한 트랜잭션에 묶이므로 트랜잭션에 성공하면 이벤트가 저장소에 보관되는 것을 보장할 수 있다.

  4. 이벤트 순서가 중요한 경우에는 이벤트 저장소를 사용할 수 있다. 메시징 시스템은 순서를 보장하기가 꽤나 어렵다 (Kafka는 특히)

  5. 이벤트 재처리에 대한 고민을 해볼 수 있다. 동일한 이벤트를 다시 처리해야 한다면 가장 쉬운 방법은 마지막으로 처리한 이벤트를 기억하고 있으면 된다.

Reference

DDD START! - 최범균님 -

그림 및 코드 참조 : https://incheol-jung.gitbook.io/docs/study/ddd-start/1



© 2022. by minkuk

Powered by minkuk