도메인 주도 개발 시작하기 - 10. 이벤트

 Date: 2022-11-14

10.1 시스템 간 강결합 문제
10.2 이벤트 개요
10.3 이벤트, 핸들러, 디스패처 구현
10.4 동기 이벤트 처리 문제
10.5 비동기 이벤트 처리
10.6 이벤트 적용 시 추가 고려 사항

10.1 시스템 간 강결합 문제

  1. 트랜잭션 처리 문제
    • 환불 기능을 실행하는 과정에서 익셉션이 발생하는 경우
      ① 주문 취소 트랜잭션을 롤백
      ② 주문을 취소 상태로 변경한 뒤 환불만 나중에 시도
  2. 성능 문제
    • 외부 시스템의 응답 시간이 길어지면 대기 시간도 길어진다. → 즉, 외부 서비스 성능에 직접적인 영향을 받게 된다.
  3. 설계상 문제
    • 다른 서비스의 로직이 섞이는 문제가 발생한다.
    • 기능을 추가할 때마다 로직이 섞이는 문제가 더 커지고, 트랜잭션 처리가 더 복잡해진다.
     public class Order {
         // 기능을 추가할 때마다 파라미터가 함께 추가되어야 하고, 로직이 더 많이 섞이게 되어 트랜잭션 처리가 더 복잡해진다.
         public void cancel(RefundService refundService) {
             // 주문 로직
             verifyNotYetShipped();
             this.state = OrderState.CANCELED;
        
             // 결제 로직
             this.refundStatus = State.STARTED;
             try {
                 // 응답 시간이 길어지면 서비스 성능에 영향을 받는다.
                 refundService.refund(getPaymentId());
                 this.refundStatus = State.REFUND_COMPLETED;
     	        } catch (Exception ex) {
                 // 두 로직의 트랜잭션 처리를 해야 한다.
                 ...
             }
         }
     }
    

➡️ 바운디드 컨텍스트 간의 강결합을 없앨 수 있는 방법은 이벤트를 사용하는 것이다.

10.2 이벤트 개요

  • 이벤트라는 용어는 ‘과거에 벌어진 어떤 것’을 의미한다.
    • 예를 들어, 사용자가 암호를 변경한 것은 ‘암호를 변경 이벤트’가 발생한 것이다.
  • 이벤트가 발생했다는 것은 상태가 변경됐다는 것을 의미한다.
  • 이벤트는 발생하는 것에서 끝나지 않고, 그 이벤트에 반응하여 원하는 동작을 수행하는 기능을 구현한다.

      $("#myBtn").click(function(event)) {
          alert("이벤트 발생!");
      }
    
  • 도메인 모델에서도 UI 컴포넌트와 유사하게 도메인의 상태 변경을 이벤트로 표현할 수 있다.
    • ~할 때, ~가 발생하면, 만약 ~하면 과 같은 요구사항은 도메인의 상태 변경과 관련된 경우가 많고 이런 요구사항을 이벤트를 이용해서 구현할 수 있다.

10.2.1) 이벤트 관련 구성요소

  • 도메인 모델에 이벤트를 도입하려면 네 개의 구성요소를 구현해야 한다.
    • 이벤트
    • 이벤트 생성 주체
    • 이벤트 디스패처(퍼블리셔)
    • 이벤트 핸들러(구독자)
  • 도메인 모델에서 이벤트 생성 주체는 엔티티, 밸류, 도메인 서비스와 같은 도메인 객체이다.

    → 도메인 객체는 도메인 로직을 실행해서 상태가 바귀면 관련 이벤트를 발생시킨다.

  • 이벤트 핸들러는 이벤트를 전달받아 이벤트에 담긴 데이터를 이용해서 원하는 기능을 실행한다.
  • 이벤트 생성 주체와 이벤트 핸들러를 연결해 주는 것이 이벤트 디스패처이다.
    • 이벤트 생성 주체는 이벤트를 생성해서 디스패처에 이벤트를 전달한다.
    • 이벤트를 전달받은 디스패처는 해당 이벤트를 처리할 수 있는 핸들러에 이벤트를 전파한다.
    • 이벤트 디스패처의 구현 방식에 따라 이벤트 생성과 처리를 동기나 비동기로 실행하게 된다.

10.2.2) 이벤트의 구성

  • 이벤트는 발생한 이벤트에 대한 정보를 담는다.
    • 이벤트 종류: 클래스 이름으로 이벤트 종류를 표현
    • 이벤트 발생 시간
    • 추가 데이터: 주문번호, 신규 배송지 정보 등 이벤트와 관련된 정보
  • 이벤트는 현재 기준으로 과거에 벌어진 것을 표현하기 때문에 이벤트 이름에는 과거 시제를 사용한다.

      public class ShippingInfoChangedEvent {
          private String orderNumber;
          private long timestamp;
          private ShippingInfo newShippingInfo;
          ...
      }
    
    • 이벤트를 발생하는 주체는 Order 애그리거트다.
    • Events.raise()는 디스패처를 통해 이벤ㄴ트를 전파하는 기능을 제공한다.
      public Order { // ShippingInfoChangedEvent 이벤트의 주체 
          public void changeShippinginfo(ShippingInfo newShippingInfo) {
              verifyNotYetShipped();
              setShippingInfo(newShippingInfo);
              **Events.raise**(new ShippingInfoChangedEvent(number, newShippingInfo));
          }
          ...
      }
    
    • 이벤트를 처리하는 핸들러는 디스패처로부터 이벤트를 전달받아 필요한 작업을 수행한다.
      public class ShippingInfoChangedHandler {
        
          @EventListener(ShippingInfoChangedEvent.class)
          public void handle(ShippingInfoChangedEvent event) {
              // 필요한 데이터를 읽기 위해 API를 호출하거나 DB에서 데이터를 읽어온다.
              Order order = orderRepository.findById(event.getOrderNo());
        
              shippingInfoSynchronizer.sync(
                  order.getNumber().getValue(),
                  order.getShippingInfo());
          }
      }
    
    • 이벤트는 데이터를 담아야 하지만 그렇다고 이벤트 자체와 관련 없는 데이터를 포함할 필요는 없다.

10.2.3) 이벤트 용도

  • 이벤트는 크게 두 가지 용도로 쓰인다.
    후처리 실행을 위한 트리거
        예매 결과를 SMS로 통지하는 이벤트를 트리거로 사용할 수 있다.

    시스템 간의 동기화
        배송지를 변경하면 외부 배송 서비스에 바뀐 배송지 정보를 전송해야 한다.

10.2.4) 이벤트 장점

  • 이벤트를 사용하면 서로 다른 도메인 로직이 섞이는 것을 방지할 수 있다.

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

  • 이벤트 클래스: 이벤트를 표현한다.
  • 디스패처: 스프링이 제공하는 ApplicationEventPublisher를 이용한다.
  • Events: 이벤트를 발행한다. 이벤트 발행을 위해 ApplicationEventPublisher를 사용한다.
  • 이벤트 핸들러: 이벤트를 수신해서 처리한다. 스프링이 제공하는 기능을 사용한다.

10.3.1) 이벤트 클래스

  • 이벤트 자체를 위한 상위 타입은 존재하지 않으므로, 원하는 클래스를 이벤트로 사용하면 된다.
  • 이벤트 클래스의 이름은 과거 시제를 사용해야 한다.
  • 이벤트 클래스는 이벤트를 처리하기 위해 필요한 최소한의 데이터를 포함해야 한다.
  • 모든 이벤트가 공통으로 갖는 프로퍼티가 존재한다면 관련된 상위 클래스를 만들 수도 있다.

      @Getter
      public abstract class Event {
          private long timestamp;
        
          public Event() {
              this.timestamp = System.currentTimeMillis();
          }
      }
    

10.3.2) Events 클래스와 ApplicationEventPublisher

  • 이벤트 발생과 출판을 위해 스프링이 제공하는 ApplicationEventPublisher를 사용한다.

      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);
              }
          }
      }
    
  • Events#setPublisher() 메서드에 이벤트 퍼블리셔를 전달하기 위해 스프링 설정 클래스를 작성한다.

      @Configuration
      public class EventsConfiguration {
          @Autowired
          private ApplicationContext applicationContext;
            
          @Bean
          public InitializingBean eventsInitializer() {
              return () -> Events.setPublisher(applicationContext);
          }
      }
    
    • InitializingBean 타입으 스프링 빈 객체를 초기화 할 때 사용하는 인터페이스로, Events 클래스를 초기화 한다.
    • ApplicationContextApplicationEventPublisher를 상속하고 있으므로 Events를 초기화할 때 ApplicationContext를 전달한다.

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

  • 이벤트를 발생시키는 코드는 Events.raise() 메서드를 사용한다.
  • 이벤트를 처리할 핸들러는 스프링이 제공하는 @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());
          }
      }
    

10.3.4) 흐름 정리

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

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

10.4 동기 이벤트 처리 문제

  • 이벤트를 사용해서 강결합 문제를 해결할 수 있지만, 외부 서비스에 영향을 받는 문제가 남아있다.
    • 아래의 소스에서 refundService.refund() 가 외부 환불 서비스와 연동한다고 가정해보자.
      // 1.응용 서비스 코드
      @Transactional  // 외부 연동 과정에서 예외가 발생하는 경우 트랜잭션 처리?
      public void cancel(OrderNo orderNo) {
          Order order = findOrder(orderNo);
      	    order.cancel();  // order.cancel()에서 OrderCanceledEvent 발생
      }
        
      // 2.이벤트를 처리하는 코드
      @Service
      public class OrderCanceledEventHandler {
          ...
          @EventListener(OrderCanceledEvent.class)
          public void handle(OrderCanceledEvent event) {
              // refundService.refund()가 느려지거나 예외가 발생하면?
              refundService.refund(event.getOrderNumber());
          }
      }
    
    • 만약 외부 환불 기능이 느려지면 cancel() 메서드도 같이 영향을 받는다.
    • 트랜잭션 처리에 대해서도 롤백을 할지 부분적으로 처리할지 생각해봐야 한다.

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

10.5 비동기 이벤트 처리

  • 우리가 구현해야 할 것 중에서 ‘A 하면 이어서 B 하라’는 내용을 담고 있는 요구사항은 실제로 ‘A 하면 최대 언제까지 B 하라’인 경우가 많다.
    • 회원 가입 신청 후 이메일 검증을 위해 이메일이 몇 초에서 몇 분 뒤에 도착해도 된다.
    • 주문을 취소하자마자 바로 결제가 취소되지 않더라도, 수십 초 혹은 며칠 내에 결제가 취소되면 된다.

    → 일정 시간 안에만 후속 조치를 처리하면 되는 경우가 많다.

  • ‘A 하면 일정 시간 안에 B 하라’는 요구사항에서 ‘A 하면’ 부분을 이벤트로 볼 수도 있다.
    • 회원 가입 신청을 하면’회원 가입 신청 이벤트로볼 수 있고, ‘인증 이메일을 보내라’ 기능은 이벤트를 처리하는 핸들러에서 보낼 수 있다.
  • 이벤트를 비동기로 구현하는 방법
    • 로컬 핸들러를 비동기로 실행하기
    • 메시지 큐를 사용하기
    • 이벤트 저장소와 이벤트 포워더 사용하기
    • 이벤트 저장소와 이벤트 제공 API 사용하기

10.5.1) 로컬 핸들러 비동기 실행

  • 이벤트 핸들러를 비동기로 실행하는 방법은 이벤트 핸들러를 별도 스레드로 실행하는 것이다.
  • @Async 애너테이션 사용
    • @EnableAsync 애너테이션을 사용해서 비동기 기능을 활성화 한다.

        @SpringBootApplication
        @EnableAsync
        public class ShopApplication {
            public static void main (String[] args) {
                SpringApplication.run(ShopApplication.class, args);
            }
        }
      
    • 이벤트 핸들러 메서드에 @Async 애너테이션을 붙인다.

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

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

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

  • 비동기로 이벤트를 처리해야 할 때 카프카나 래빗MQ와 같은 메시징 시스템을 사용하는 것이다.
    • 이벤트가 발생하면 이벤트 디스패처는 이벤트를 메시지 큐에 보낸다.
    • 메시지 큐는 이벤트를 메시지 리스너에 전달하고, 메시지 리스너는 알맞은 이벤트 핸들러를 이용해서 이벤트를 처리한다.
  • 메시지 큐에 저장하는 과정과 메시지 큐에서 이벤트를 읽어와 처리하는 과정은 별도 스레드나 프로세스로 처리된다.
  • 필요하다면 이벤트를 발생시키는 도메인 기능과 메시지 큐에 이벤트를 저장하는 절차를 한 트랜잭션으로 묶어야 할 수도 있다.
    • 글로벌 트랜잭션을 사용하면 안전하게 이벤트를 메시지 큐에 전달할 수 있는 장점이 있지만, 글로벌 트랜잭션으로 인해 전체 성능이 떨어지는 단점도 있다.
    • 또한, 글로벌 트랜잭션을 지원하지 않는 메시징 시스템도 있다.
  • 메시지 큐를 사용하면 보통 이벤트를 발생시키는 주체와 이벤트 핸들러가 별도 프로세스에서 동작한다.

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

두 가지 처리 방식

  1. 포워더 방식
    • 이벤트를 DB에 저장한 뒤에 별도 프로그램을 이용해서 이벤트 핸들러에 전달한다.
    • 이벤트 발생 → 핸들러 → 스토리지에 이벤트 저장 → 포워더 → 이벤트 핸들러 실행
    • 포워더는 별도 스레드를 이용하기 때문에 이벤트 발행과 처리가 비동기로 처리된다.
    • 이벤트를 물리적 저장소에 보관하기 때문에 핸들러가 이벤트 처리에 실패할 경우 포워더는 다시 이벤트 저장소에서 이벤트를 읽어봐 핸들러를 실행하면 된다.

    → 이 방식은 도메인 상태와 이벤트 저장소로 동일한 DB를 사용하기 때문에, 도메인의 상태 변화와 이벤트 저장이 로컬 트랜잭션으로 처리된다.

  2. API 방식
    • 이벤트를 외부에 제공하는 API를 사용한다.
    • API 방식과 포워더 방식의 차이점은 이벤트를 전달하는 방식이다.
    • API방식은 외부 핸들러가 API 서버를 통해 이벤트 목록을 가져간다. (포워더 방식은 포워더를 이용해서 이벤트를 전달한다.)
    • API 방식에서는 이벤트 목록을 요구하는 외부 핸들러가 이벤트를 어디까지 처리했는지 기억해야 한다. (포워더 방식은 이벤트를 어디까지 처리했는지 추적한다.)

이벤트 저장소 구현

  • 포워더 방식과 API 방식 모두 이벤트 저장소를 사용하므로 이벤트를 저장할 저장소가 필요하다.
  • EventEntry: 이벤트 저장소에 보관할 데이터
    • id: 식별자
    • type: 이벤트 타입
    • contnetType: 직렬화한 데이터 형식
    • payload: 이벤트 데이터
    • timestamp: 이벤트 시간
  • EventStore: 이벤트를 저장하고 조회하는 인터페이스를 제공
    • 이벤트 객체를 직렬화해서 payload에 저장한다.
    • 이벤트는 과거에 벌어진 사건이므로 데이터가 변경되지 않는다. 이런 이유로 EventStore 인터페이스는 새로운 이벤트를 추가하는 기능과 조회하는 기능만 제공하고, 수정하는 기능은 제공하지 않는다.
  • JdcbEventStore: JDBC를 이용한 EventStore 구현 클래스
  • EventApi: REST API를 이용해서 이벤트 목록을 제공하는 컨트롤러

이벤트 저장을 위한 핸들러 구현

  • 발생한 이벤트를 이벤트 저장소에 추가하는 이벤트 핸들러를 구현한다.
  • Event 타입을 상속받은 이벤트 타입만 이벤트 저장소에 보관하기 위해 @EventListener 애너테이션 값으로 Event.class를 갖는다.

REST API 구현

  • 이벤트를 수정하는 기능이 없으므로 REST API는 단순 조회 기능만 존재한다.
  • API를 사용하는 클라이언트는 일정 간격으로 다음 과정을 실행한다.
    1. 가장 마지막에 처리한 데이터의 offset인 lastOffset을 구한다. 저장한 lastOffset이 없으면 0을 사용한다.
    2. 마지막에 처리한 lastOffset을 offset으로 사용해서 API를 실행한다.
    3. API 결과로 받은 데이터를 처리한다.
    4. offset + 데이터 개수를 lastOffset으로 저장한다.

    → 마지막에 처리한 lastOffset을 저장하는 이유는 같은 이벤트를 중복해서 처리하지 않기 위해서이다.

  • 클라이언트 API를 이용해서 이벤트를 가져올 수 있기 때문에 이벤트 처리에 실패하면 다시 실패한 이벤트부터 읽어와 이벤트를 재처리할 수 있다.

포워더 구현

  • 포워더는 API 방식의 클라이언트 구현과 유사하다.
  • 포워더는 일정 주기로 EventStore에서 이벤트를 읽어와 이벤트 핸들러에 전달하면 된다.
  • API 방식 클라이언트와 마찬가지로 마지막으로 전달한 이벤트의 offset을 기억해 두었다가 다음 조회 시점에 마지막으로 처리한 offset 부터 이벤트를 가져오면 된다.

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

  1. 이벤트 소스를 EventEntry에 추가할지 여부
    • ‘Order가 발생시킨 이벤트만 조회하기’ 처럼 특정 주체가 발생시킨 이벤트만 조회하는 기능이 필요하다면, 이벤트에 발생 주체 정보를 추가해야 한다.
  2. 포워더에서 전송 실패를 얼마나 허용할지
    • 포워더는 이벤트 전송에 실패한 이벤트부터 다시 읽어와 전송을 시도한다.
    • 하지만, 특정 이벤트에서 계속 전송에 실패하면 나머지 이벤트를 전송할 수 없게 된다.
    • 따라서 포워더를 구현할 때는 실패한 이벤트의 재전송 횟수 제한을 두어야 한다.
    • 처리에 실패한 이벤트를 생략하지 않고 별도 실패용 DB나 메시지 큐에 저장하기도 한다. 처리에 실패한 이벤트를 물러적인 저장소에 남겨두면 이후 실패 이유 분석이나 후처리에 도움이 된다.
  3. 이벤트 손실
    • 이벤트 저장소를 사용하는 방식은 이벤트 발생과 이벤트 저장을 한 트랜잭션으로 처리하기 때문에 트랜잭션에 성공하면 이벤트가 저장소에 보관된다는 것을 보장할 수 있다.
    • 반면에 로컬 핸들러를 이용해서 이벤트를 비동기로 처리할 경우 이벤트 처리에 실패하면 이벤트를 유실하게 된다.
  4. 이벤트 순서
    • 이벤트 발생 순서대로 외부 시스템에 전달해야 할 경우, 이벤트 저장소를 사용하는 것이 좋다.
    • 반면에 메시징 시스템은 사용 기술에 따라 이벤트 발생 순서와 메시지 전달 순서가 다를 수도 있다.
  5. 이벤트 재처리
    • 동일한 이벤트를 다시 처리해야 할 때 어떻게 할지 결정해야 한다.
    • 가장 쉬운 방법은 마지막으로 처리한 이벤트의 순번을 기억해 두었다가 이미 처리한 순번의 이벤트는 처리하지 않고 무시하는 것이다.
    • 이벤트를 멱등으로 처리하는 방법도 있다.

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

  • 이벤트를 처리할 때는 DB 트랜잭션을 함께 고려해야 한다.
  • 이벤트 처리를 동기로 하든 비동기로 하든 이벤트 처리 실패와 트랜잭션 실패를 함께 고려해야 한다.

    → 경우의 수를 줄이기 위해 트랜잭션이 성공할 때만 이벤트 핸들러를 실행하는 것이 방법이다.

  • @TransactionalEventListener 애너테이션은 스프링 트랜잭션 상태에 따라 이벤트 핸들러를 실행할 수 있게 한다.

      @TransactionalEventListener(
          class = OrderCancledEvent.class,
          phase = TransactionPhase.AFTER_COMMIT
      )
      public void handle(OrderCanceledEvent event) {
          refundService.refund(event.getOrderNumber());
      }
    

    → AFTER_COMMIT으로 커밋에 성공한 뒤 핸들러 메서드를 실행하면, 이벤트 핸들러를 실행했는데 트랜잭션이 롤백 되는 상황은 발행하지 않는다.

  • 트랜잭션이 성공할 대만 이벤트 핸들러를 실행하게 되면 트랜잭션 실패에 대한 경우의 수가 줄어 이제 이벤트 처리 실패만 고민하면 된다.