ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 10장 - 이벤트
    📕 book/도메인 주도 개발 시작하기 2023. 1. 16. 09:44

    📌 시스템 간 강결합 문제


    외부에서 제공하는 서비스를 파라미터로 전달받고 도메인 기능에서 도메인 서비스를 실행할 경우 문제가 발생할 수 있다.

    1. 외부 서비스가 정상이 아닐 경우 트랜잭션 처리를 어떻게 해야 할지 애매하다는 것이다.
      • 외부 기능을 실행하는 과정에서 익셉션이 발생할 경우 롤백 or 커밋 중에 무엇을 해야 할까 ❓

     

    ex) 환불 서비스를 실행하는 경우

    • 외부의 환불 서비스를 실행하는 과정에서 익셉션이 발생하면 환불에 실패했으므로 주문 취소 트랜잭션을 롤백하는 것이 맞을 것 같지만 반드시 트랜잭션을 롤백 해야 하는 것은 아니다.
    • 주문은 취소 상태르 변경하고 환불만 나중에 다시 시도하는 방식으로 처리할 수도 있다.

     

    2. 성능 문제

    • 외부 시스템의 응답 시간이 길어지면 그 만큼 대기 시간도 길어진다.
    • 즉, 외부 서비스 성능에 영향을 받게 된다.

     

    도메인 객체에 서비스를 전달하면 또 다른 문제가 나타날 수 있다.

    • 설계상 문제 : 서로 다른 도메인 로직이 섞일 수 있다.
    • 기능을 추가할 때 로직이 더 섞이고 트랜잭션 처리가 복잡해진다.

     

    이러한 문제가 발생하는 이유

    바운디드 컨텍스트 간의 강결합 때문

    • ex) 주문과 결제가 강하게 결합되어 있어 각 바운디드 컨텍스트에 영향을 받게 된다.

     

    강결합을 없앨 수 있는 방법

    이벤트를 사용

    → 두 시스템 간의 결합을 크게 낮출 수 있다.

     

    ❗ 이벤트


    💡 이벤트가 발생한다는 것은 상태가 변경됐다는 것을 의미한다.

    도메인 모델에서의 도메인 상태 변경을 이벤트로 표현할 수 있다.

    • 도메인의 상태 변경과 관련된 경우가 많고 이런 요구사항을 이벤트를 이용해 구현할 수 있다.
    • ex) ‘주문 취소’ ⇒ 주문 취소됨 이벤트 활용

     

    이벤트 구성요소

    도메인 모델에 이벤트를 도입하기 위해서는 4가지 구성요소를 구현해야 한다.

    1. 이벤트
    2. 이벤트 생성 주체
    3. 이벤트 디스패처(퍼블리셔)
    4. 이벤트 핸들러(구독자)

     

    📖 이벤트 생성 주체

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

    📖 이벤트 핸들러

    • 이벤트 생성 주체가 발생한 이벤트에 반응
    • 이벤트를 전달받아 이벤트에 담긴 데이터를 이용해 원하는 기능을 실행
    • ex) 주문 취소 이벤트 → 주문자에게 알림

    📖 이벤트 디스패처

    • 이벤트 생성 주체와 이벤트 핸들러를 연결
    • 이벤트 디스패처의 구현 방식에 따라 이벤트 생성과 처리를 동기 또는 비동기로 실행하게 된다.

     

    이벤트 구성

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

    • 이벤트 종류 - 클래스 이름으로 이벤트 종류를 표현
    • 이벤트 발생 시간
    • 추가 데이터 - 이벤트와 관련된 정보(ex: 주문번호, 신규 배송지 정보 등)

     

    // 배송지 변경시 발생하는 이벤트
    public class ShippingInfoChangedEvent {
      private String orderNumber;
      private long timestamp;
      private ShippingInfo newShippingInfo;
      ...
    }

    ❗이벤트 이름에는 과거 시제를 사용한다

    • 이벤트는 현재 기준으로 과거에 벌어진 것을 표현하기 위함

     

    이벤트 발생

    // 배송지 변경 이벤트를 발생하는 주체 - Order애그리거트
    public class Order {
      ...
      public void changeShippingInfo(ShippingInfo newShippingInfo) {
          verifyNotYetShipped();
          setShippingInfo(newShippingInfo);
          Events.raise(new ShippingInfoChangedEvent(number, newShippingInfo));
      }
      ...
    }
    
    • Events.raise() - 디스패처를 통해 이벤트를 전파하는 기능을 제공

     

    이벤트 핸들러

    public class ShippingInfoChangedHandler {
      
      @EventListener(ShippingInfoChangedEvent.class)
      public void handle(ShippingInfoChangedEvent evt) {
    
        // 이벤트가 필요한 데이터를 담고 있지 않으면 
        // 리포지터리, API, DB 접근 등의 방식을 사용해 데이터를 가져온다.
        Order order = orderRepository.findById(evt.getOrderNo());
        shippingInfoSynchronizer.sync(
          order.getNumber().getValue(),
          order.getShippingInfo()
        );
      }
    }

    이벤트는 이벤트 핸들러가 작업을 수행하는 데 필요한 데이터를 담고 있다.

    • 데이터가 부족하다면 필요한 데이터를 위해 관련 API 호출 또는 DB에서 값을 직접 가져와야 한다.

     

    데이터는 필요한 것들만 담는다!

     

    이벤트의 사용 용도

    1. 트리거 (Trigger)
      • 도메인의 상태가 바뀔 때 다른 후처리가 필요하다면 후처리를 실행하기 위한 트리거로 이벤트를 사용할 수 있다.
      • ex) ‘주문 취소’ ⇒ 주문을 취소시 환불 해야 한다. 환불을 위한 트리거로 주문 취소 이벤트 사용
    2. 서로 다른 시스템간의 데이터 동기화
      • 이벤트를 발생하고 외부 서비스와 정보를 동기화 할 수 있다.

     

    장점

    ❗ 서로 다른 도메인 로직이 섞이는 것을 방지할 수 있다.

    • 이벤트를 통해 다른 도메인으로의 의존을 제거

     

    ❗기능 확장에 용이

    • 기능을 확장해도 다른 로직은 수정할 필요가 없다.

     

    ⌨️ 이벤트, 핸들러, 디스패처 구현


    이벤트를 위한 상위 타입은 존재하지 않는다.

    → 원하는 클래스를 이벤트로 사용하면 된다.

     

    ⚠️ 클래스의 이름을 과거 시제로 사용해야 한다!!

     

    Events 클래스와 ApplicationEventPublisher

    ApplicationEventPublisher

    • 이벤트 발생과 출판을 위해 스프링이 제공하는 ApplicationEventPublisher를 사용
    • 스프링 컨테이너도 될 수 있다.

     

    Events 클래스

    public class Events {
    
      // ApplicationEventPublisher를 사용해 이벤트를 발생
      private static ApplicationEventPublisher publisher;
    
      // 사용할 ApplicationEventPublisher를 전달받는다.
      static void setPublisher(ApplicationEventPublisher publisher) {
          Events.publisher = publisher;
      }
    
      public static void raise(Object event) {
          if (publisher != null) {
              // publishEvent() 메서드를 이용해 이벤트 발생
              publisher.publishEvent(event);
          }
      }
    }
    

    Events의 setPublisher 메서드에 이벤트 퍼블리셔를 전달하기 위해 Config 클래스를 만든다.

     

    EventsConfiguartion

    @Configuration
    public class EventsConfiguration {
      @Autowired
      private ApplicationContext applicationContext;
    
      // Events 클래스를 초기화 - 초기화시 ApplicationContext를 전달
      @Bean
      public InitializingBean eventsInitializer() {
          return () -> Events.setPublisher(applicationContext);
      }
    }
    

     

    이벤트 발생과 이벤트 핸들러

    앞서 만든 Events 클래스의 raise() 메서드를 통해 이벤트를 발생시킨다.

     

    ex) 주문 취소 로직를 수행한 후 Events.raise()를 통해 관련 이벤트를 발생

    public class Order {
    
      public void cancel() {
    
        verifyNotYetShipped();
        this.state = OrderState.CANCELED;
        Events.raise(new OrderCanceledEvent(number.getNumber()));
      }
    }
    

     

    📖 @EventListener

    • 이벤트를 처리할 핸들러는 @EventListener를 사용해 구현

     

    이벤트 핸들러

    @Service
    public class OrderCanceledEventHandler {
      private RefundService refundService;
    
      public OrderCanceledEventHandler(RefundService refundService) {
          this.refundService = refundService;
      }
    
      @EventListener(OrderCanceledEvent.class)
      public void handle(OrderCanceledEvent event) {
          refundService.refund(event.getOrderNumber());
      }
    }
    

    ApplicationEventPublisher의 publishEvent()를 실행할 때 OrderCanceledEventHandler 타입 객체 전달을 하게 되면

    OrderCanceledEvent.class 값을 가지는 @EventListener 애너테이션을 붙인 메서드를 찾아 실행한다.

    • 위 코드에서는 handle() 메서드를 실행한다.

     

    흐름 정리

    1. 도메인 기능 실행
    2. 도메인 기능은 Events.raise() 를 통해 이벤트를 발생
    3. Events.raise()는 스프링이 제공하는 ApplicationEventPublisher를 이용해 이벤트 출판
    4. @EventListener 애너테이션이 붙은 메서드를 찾아 실행

     

    응용 서비스와 동일한 트랜잭션 범위에서 이벤트 핸들러를 실행하고 있다.

    즉, 도메인의 상태 변경과 이벤트 핸들러가 같은 트랜잭션 범위에서 실행된다.

     

    📌 동기 이벤트 처리 문제


    이벤트를 통해 강결합문제는 해소했지만 외부 서비스에 영향을 받는 문제가 남았다.

     

    응용서비스를 사용하는 예시

    // 응용 서비스 
    // 외부 연동 과정에서 exception이 발생했을 때 트랜잭션 처리를 어떤식으로 하나?
    @Transactional 
    public void cancel(OrderNo orderNo) {
      Order order = findOrder(orderNo);
      order.cancel(); // OrderCanceledEvent 발생
    }
    
    // 이벤트 처리
    @Service
    public class OrderCanceledEventHandler {
    
      ...
      @EventListener(OrderCanceledEvent.class)
      public void handle(OrderCancelEvent event) {
       
        // 응답 처리 시간이 길어지거나 exception이 발견한다면?
        refundService.refund(event.getOrderNumber());
      }
    }

    외부 서비스에서 익셉션이 발생하면 어느 구역에서 트랜잭션을 롤백해야 하나?

     

    ❗외부 시스템과의 연동을 동기로 처리할 때 발생하는 성능과 트랜잭션 범위 문제를 해소하는 방법은 이벤트를 비동기로 처리하거나 이벤트와 트랜잭션을 연계하는 것이다.

     

    📌 비동기 이벤트 처리


    우리가 구현해야 하는 요구사항 중에는 일정 시간 안에만 후속조치를 하면 되는 경우가 많다.

    • 요구사항은 이벤트를 비동기로 처리하는 방식으로 구현할 수 있다.

     

    이벤트를 비동기로 구현하는 방법

    • 로컬 핸들러를 비동기로 실행
    • 메시지 큐 사용
    • 이벤트 저장소와 이벤트 포워더 사용하기
    • 이벤트 저장소와 이벤트 제공 API 사용

     

    로컬 핸들러 비동기 실행

    이벤트 핸들러를 비동기로 실행하는 방법 = 이벤트 핸들러를 별도 스레드로 실행

     

    📖 @Async

    • @Async 애너테이션을 사용하면 손쉽게 비동기로 이벤트 핸들러를 실행할 수 있다.


    2가지 조건

    • @EnableAsync 애너테이션을 사용해서 비동기 기능을 활성화
    • 이벤트 핸들러 메서드에 @Async 애너테이션을 붙인다.


    📖 @EnableAsync

    • 스프링의 비동기 실행 기능을 활성화한다.

     

    @EnableAsync

    //@EnableAsync 사용
    @SpringBootApplication
    @EnableAsync
    @EnableJpaRepositories(repositoryBaseClass = RangeableRepositoryImpl.class)
    public class ShopApplication {
    
        public static void main(String[] args) {
            SpringApplication.run(ShopApplication.class, args);
        }
    
    }

     

    @Async

    @Service
    public class OrderCanceledEventHandler {
        private RefundService refundService;
    
        ... 
        @Async
        @EventListener(OrderCanceledEvent.class)
        public void handle(OrderCanceledEvent event) {
            refundService.refund(event.getOrderNumber());
        }
    }

    OrderCanceledEvent가 발생하면 handle() 메서드를 별도 스레드를 이용해 비동기로 실행한다.

     

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

    카프카나 레빗MQ와 같은 메시징 시스템을 사용해 비동기로 이벤트를 처리할 수 있다.

    1. 이벤트 발생 시 이벤트 디스패처는 이벤트를 메시지 큐로 보낸다.
    2. 메시지 큐는 이벤트를 메시지 리스너에 전달하고 메시지 리스너는 알맞은 이벤트 핸들러를 이용해 이벤트를 처리한다.

     

    이벤트를 메시지 큐에 저장하고 읽어와 처리하는 과정은 별도 스레드나 프로세스로 처리

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

    ⇒ 글로벌 트랜잭션

     

    📖 글로벌 트랜잭션

    • ex) 도메인 기능을 실행한 결과를 DB에 저장, 발생한 이벤트를 메시지 큐에 저장하는 것을 같은 트랜잭션 범위에서 실행하기위해 필요하다.


    장점

    • 안전하게 이벤트를 메시지 큐에 전달할 수 있다.


    단점

    • 전체적인 성능이 떨어진다.
    • 글로벌 트랜잭션을 지원하지 않는 메시징 시스템도 있다.

    일반적으로 메시지 큐를 사용하면 이벤트를 발생시키는 주체와 이벤트 핸들러가 별도 프로세스에서 동작한다.

     

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

    이벤트를 일단 DB에 저장한 뒤 별도 프로그램을 이용해 이벤트 핸들러에 전달하는 것.

    포워드를 이용한 방법

    1. 이벤트가 발생하면 핸들러는 스토리지에 이벤트를 저장
    2. 포워더는 주기적으로 이벤트 저장소에서 이벤트를 가져와 이벤트 핸들러를 실행

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

     

    외부에 제공하는 API를 사용하는 방법

    포워더 방식이 포워더를 이용해 이벤트를 외부에 전달한다면, API 방식은 외부 핸들러가 API 서버를 통해 이벤트 목록을 가져간다.

     

    이벤트 저장소 구현

     

    EventEntry

    • 이벤트 저장소에 보관할 데이터

     

    EventStore

    • 이벤트를 저장하고 조회하는 인터페이스를 제공한다.
    • 이벤트 객체를 직렬화해 payload에 저장한다.
    • 이벤트는 과거에 벌어진 사건 → 변경되지 않는다 → 수정하는 기능을 제공하지 않는다.

     

    JdbcEventStore

    • JDBC를 이용한 EventStore 구현 클래스
    • 전달받은 event 객체를 JSO로 변환해 payload로 전달

     

    EventApi

    • REST API를 이용해 이벤트 목록을 제공하는 컨트롤러

     

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


    1. 이벤트 소스를 EventEntry에 추가할지 여부

    • 특정 주체가 발생한 이벤트만 처리하기 위해서는 발생 주체 정보를 추가해야 한다.

    2. 포워더에서 전송 실패를 얼마나 허용할 지

    • 실패한 이벤트의 재전송 횟수 제한을 두어야 한다.

    3. 이벤트 손실

    • 이벤트 저장소를 사용하는 방식 - 이벤트 발생, 저장을 한 트랜잭션으로 처리해 문제가 없지만
    • 로컬 핸들러를 이용해 이벤트를 비동기로 처리하는 경우 이벤트 처리에 실패하면 이벤트를 유실한다.

    4. 이벤트 순서

    • 이벤트 발생 순서대로 외부 시스템에 전달해야 하는 경우 이벤트 저장소를 사용
    • 메시징 시스템은 사용 기술에 따라 이벤트 발생 순서와 메시지 전달 순서가 다를 수 있다.

    5. 이벤트 재처리

    • 동일한 이벤트를 다시 처리해야 할 때 이벤트를 어떻게 할지 결정

     

    이벤트 처리와 DB 트랜잭션 고려

    이벤트 처리를 동기, 비동기 어떤 것으로 하든 이벤트 처리 실패와 트랜잭션 실패를 함께 고려해야 한다.

    • 트랜잭션 실패와 이벤트 처리 실패를 모두 고려하면 복잡하다
    • 경우의 수를 줄이면 도움이 된다.

     

    경우의 수를 줄이는 방법 : 트랜잭션이 성공할 때만 이벤트 핸들러를 실행

     

    스프링은 @TransactionalEventListener 애너테이션을 지원한다.

    • 스프링 트랜잭션 상태에 따라 이벤트 핸들러를 실행할 수 있게 한다.
    @Async
    @TransactionalEventListener(
        classes = OrderCanceledEvent.class,
        phase = TransactionPhase.AFTER_COMMIT
    )
    public void handle(OrderCanceledEvent event) {
        refundService.refund(event.getOrderNumber());
    }

    phase 속성 값 TransactionPhase.AFTER_COMMIT : 트랜잭션 커밋에 성공한 뒤에 핸들러 메서드 실행

    • 이벤트 핸들러를 실행했는데 트랜잭션이 롤백되는 상황은 발생하지 않는다.

     

    트랜잭션이 성공할 때만 이벤트 핸들러를 실행하게 되면 트랝개션 실패에 대한 경우의 수가 줄어든다.

    따라서, 이벤트 처리 실패만 고민하면 된다.

    → 이벤트 특성에 따라 재처리 방식을 결정하면 된다.

     

    참고자료

    http://www.yes24.com/Product/Goods/108431347

     

    댓글

Designed by Tistory.