고딩왕 코범석

10장 - 이벤트 본문

Book Lounge/도메인 주도 개발 시작하기

10장 - 이벤트

고딩왕 코범석 2022. 8. 3. 22:52
반응형

Index

  1. 시스템 간 강결합 문제
  2. 이벤트 개요
    1. 이벤트 관련 구성요소
    2. 이벤트의 구성
    3. 이벤트 용도
    4. 이벤트 장점
  3. 이벤트, 핸들러, 디스패처 구현
    1. 이벤트 클래스
    2. Events 클래스와 ApplicationEventPublisher
    3. 이벤트 발생과 이벤트 핸들러
    4. 흐름 정리
  4. 동기 이벤트 처리 문제
  5. 비동기 이벤트 처리
    1. 로컬 핸들러 비동기 실행
    2. 메시징 시스템을 이용한 비동기 구현
    3. 이벤트 저장소를 이용한 비동기 처리
  6. 이벤트 적용 시 추가 고려 사항
    1. 이벤트 처리와 DB 트랜잭션 고려

시스템 간 강결합 문제

쇼핑몰에서 환불 로직을 구현한다 가정할 때, 다음과 같은 방법이 있다.

  1. 도메인 객체에서 환불 기능을 제공하는 도메인 서비스를 파라미터로 받아 도메인의 상태를 변경 후 도메인 서비스를 실행
  2. 응용 서비스 자체에서 환불 기능 실행

보통 환불 서비스는 외부에 있기 때문에 두 가지 문제가 발생할 수 있다.

  1. 외부 서비스가 정상이 아닐 경우 트랜잭션 처리를 어떻게 해야하는가?
    1. 외부 서비스에서 예외가 발생하면 환불에 실패해서 주문 취소 트랜잭션을 롤백하는 것이 맞아보이지만, 주문은 취소 상태로 변경시키고 환불만 나중에 시도하는 방식으로 처리할 수 있다.
  2. 성능 문제
    1. 환불을 처리하는 외부 시스템의 응답 시간이 길어지면 그만큼 대기 시간도 길어진다.

특히 도메인 서비스를 파라미터로 받을 경우는 주문 취소 로직과 결제 환불 로직이 한 코드에 위치하는 문제가 발생한다. 즉, 환불 기능이 변경되면 주문 도메인 엔티티도 영향을 받는다는 것이다.

현재까지 언급한 문제들이 발생하는 이유는 주문 바운디드 컨텍스트와 결제 바운디드 컨텍스트간의 강결합으로 발생하는 문제들이다. 주문이 결제와 강하게 결합되어 있어 주문 바운디드 컨텍스트가 결제 바운디드 컨텍스트에 영향을 받게 되는 것이다.

이러한 문제점을 이벤트를 사용해 두 시스템 간의 결합도를 크게 낮출 수 있다.

위로

이벤트 개요

이벤트는 과거에 벌어진 어떤 것을 의미한다. 예를 들어 사용자가 암호를 변경한 것을 사용자가 암호를 변경한 이벤트가 벌어졌다고 할 수 있다.

이벤트 관련 구성요소

도메인 모델에 이벤트를 도입하려면 다음과 같은 네 개의 구성요소를 구현해야 한다.

https://user-images.githubusercontent.com/37062337/181745787-2e8cfe45-aa7f-4033-896c-db6aafe51861.png

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

이벤트 핸들러는 이벤트 생성 주체가 발생한 이벤트에 반응한다. 즉, 이벤트를 받아 원하는 기능을 실행한다.

이벤트 생성 주체와 핸들러를 연결해주는 것이 이벤트 디스패처다. 정리하면 이벤트 생성 주체가 이벤트 디스패처에 전달하고 디스패처가 이 이벤트를 처리할 핸들러에게 이벤트를 전한다.

이벤트 디스패처의 구현 방식에 따라 이벤트 생성과 처리를 동기 혹은 비동기로 구현할 수 있다.

이벤트의 구성

이벤트에 대한 정보는 다음과 같이 표현한다.

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

이벤트 용도

이벤트는 트리거서로 다른 시스템 간의 데이터 동기화를 목적으로 쓰인다.

트리거는 도메인의 상태가 변경될 때 다른 후처리가 필요하다면 후처리를 실행하기 위한 트리거로 사용할 수 있다.

다른 시스템 간 데이터 동기화의 경우 도메인의 상태가 변경될 때 이벤트를 발생시켜 외부 시스템의 데이터를 동기화할 수 있다.

이벤트의 장점

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

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

이로 인해 앞에서 언급한 도메인 서비스를 파라미터로 받아 도메인 서비스를 직접 의존하는 단점을 없앴다. 추가적인 이벤트 핸들러만 구현하면 되는 점 때문에 확장성도 높아진다.

위로

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

이벤트와 관련된 코드는 다음과 같다.

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

이벤트 클래스

이벤트를 위한 상위 타입은 존재하지 않는다. 이벤트가 과거에 발생했으므로, 클래스를 만들때는 과거 시제를 사용하여 클래스를 생성한다. 또한, 이벤트를 처리하는데 있어 최소한의 데이터를 포함해야한다.

public class OrderCanceledEvent {
    private String orderNumber;

    // getter / setter..
}

모든 이벤트가 공통적으로 갖고 있을 데이터가 필요하다면 추상 클래스를 이용해 공통적인 프로퍼티를 기입하고 이를 상속받아 사용한다.

Events 클래스와 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#raise()ApplicationEventPublisher#publishEvent()를 발생시킨다.

Events#setPublisher()는 Events가 사용할 Publisher를 전달받을 때 사용한다. 이를 위해 다음과 같이 스프링 설정 클래스를 작성해야한다.

@Configuration
public class EventsConfiguration {
  @Autowired
  private ApplicationContext applicationContext;

  @Bean
  public InitializingBean eventsInitializer() {
      return () -> Events.setPublisher(applicationContext);
  }
}

Events 클래스의 Publisher를 초기화 시키는 과정이며 ApplicationContext는 ApplicationEventPublisher를 상속하고 있어 Events 클래스를 초기화할 때 ApplicationContext를 전달한다.

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

이벤트를 발생시키는 코드는 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());
  }
}

흐름 정리

https://user-images.githubusercontent.com/37062337/181754135-5084cd00-24a1-4c94-9ef6-f7c2fea6b7c4.png

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

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

위로

동기 이벤트 처리 문제

동기 방식으로 이벤트를 구현하면 의존하고 있는 서비스의 성능도 함께 의존하는 단점이 발생하며 트랜잭션 롤백 문제도 고민이다.

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

위로

비동기 이벤트 처리

요구사항 중에는 ‘A하면 이어서 B하라’라는 요구사항이 많다. 이 요구사항에서 ‘최대 언제까지 B하라’로 바꿀 수 있는 요구사항은 A 이벤트가 발생하면 별도의 쓰레드로 B를 수행하는 핸들러를 실행하는 방식으로 구현할 수 있다.

이벤트를 비동기로 구현할 수 있는 방법은 아래와 같은 방법이 있다.

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

로컬 핸들러 비동기 실행

이벤트 핸들러를 비동기로 실행하는 방법은 이벤트 핸들러를 별도의 쓰레드로 실행하는 것이다. @Async를 이용해 비동기로 실행할 수 있는데, 이를 위해 아래 두 가지를 하면 된다.

  1. @EnableAsync로 비동기 기능 활성화
  2. 이벤트 핸들러 메서드에 @Async 기입

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

Kafka, RabbitMQ를 이용해 이벤트가 발생하면 이벤트 디스패처는 이벤트를 메세지 큐에 보낸다.

메세지 큐는 이벤트를 메세지 리스너에 전달하고, 리스너는 알맞는 핸들러를 이용해 이벤트를 처리한다.

이 때, 이벤트를 메세지큐에 저장하는 과정, 메세지 큐에서 이벤트를 읽어와 처리하는 과정은 별도 쓰레드나 프로세스로 처리된다.

https://user-images.githubusercontent.com/37062337/181757337-b5aa347a-3514-4699-a8ea-bc91c3eb9689.png

필요하다면 이벤트를 발생시키는 도메인 기능과 메세지 큐에 이벤트를 저장하는 절차를 한 트랜잭션으로 묶을 수 있다. 이는 글로벌 트랜잭션이 필요로 하는데, 전체 성능이 떨어지는 단점이 발생한다.

메세지 큐를 사용하면 이벤트 발생 주체와 핸들러가 별도의 프로세스에서 동작한다. 이는 이벤트 발생 JVM과 처리 JVM이 다르다는 것을 의미한다.

메세지 큐 중 RabbitMQ는 글로벌 트랜잭션과 클러스터, 고가용성, 다양한 언어와 통신 프로토콜을 지원하는 장점이 있다.

Kafka는 글로벌 트랜잭션을 지원하지 않지만 다른 메세징 시스템에 비해 높은 성능을 보여준다.

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

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

https://user-images.githubusercontent.com/37062337/182582807-598b99ba-df3b-4838-9ce6-36033b8c8be4.png

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

이벤트 저장소를 이용한 두 번째 방법은 이벤트를 외부에 제공하는 API를 이용하는 것이다.

https://user-images.githubusercontent.com/37062337/182583725-d6c028a4-b464-431f-889d-f26e8cb3677d.png

API 방식과 포워더 방식의 차이점은 이벤트를 전달하는 방식이다.

포워더 방식은 포워더를 이용해 이벤트를 외부에 전달한다. 그리고 이벤트를 어디까지 처리했는지 추적하는 역할이 포워더에 있다.

API 방식은 외부 핸들러가 API 서버 를 통해 이벤트 목록을 가져간다. 그리고 이벤트 목록을 요구하는 외부 핸들러가 자신이 어디까지 이벤트를 처리했는지 기억해야 한다.

두 방식 모두 이벤트 저장소를 사용하므로 이벤트를 저장할 저장소가 필요하다. 이를 구현한 코드 구조는 아래와 같다.

https://user-images.githubusercontent.com/37062337/182596440-6e788727-9ef1-4e52-a19a-41de54f355c0.png

  • EventEntry : 이벤트 저장소에 보관할 데이터
  • EventStore : 이벤트를 저장하고 조회하는 인터페이스를 제공한다.
  • JdbcEventStore : JDBC를 이용한 구현 클래스
  • EventApi : REST API를 이용해 이벤트 목록을 제공하는 컨트롤러

위 그림처럼 구현했다면 API를 사용하는 클라이언트는 일정 간격으로 다음 과정을 진행한다.

  1. 가장 마지막에 처리한 데이터의 offset인 lastOffset을 구한다. 저장한 lastOffset이 없다면 0을 사용한다.
  2. 마지막에 처리한 lastOffset을 offset으로 사용해 API를 호출한다.
  3. API로 받은 데이터를 처리한다.
  4. offset + 데이터 갯수를 lastOffset으로 저장한다.

포워더는 offset의 경우 API 방식과 비슷하다. 차이점은 스프링의 @Scheduled를 사용해 일정 간격동안 이벤트를 가져와서 처리하는 점이다.

위로

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

고려할 첫 번째 사항은 앞에서 설명한 EventEntry 객체는 이벤트 발생 주체에 대해 정보를 갖고 있지 않다. 즉, 발생시킨 특정한 이벤트만 조회할 수 없는 문제가 있다.

두 번째는 포워더에서 전송 실패를 얼마나 허용할 것인지 정하지 않았다. 포워더는 이벤트 전송에 실패하면 실패한 이벤트부터 다시 읽어와 전송을 시도한다. 그런데 특정 이벤트의 전송을 계속 실패하게 되면 나머지 이벤트를 전송할 수 없다.

  • 이 때는 실패한 이벤트의 재전송 횟수를 제한하여 특정 횟수 이상 실패하면 다음 이벤트를 처리하도록 해야한다.
  • 처리에 실패한 이벤트를 별도 실패용 DB나 메세지 큐에 저장하기도 한다. 이를 물리 저장소에 남겨두면 실패 이유 분석이나 후처리에 도움이 된다.

세 번째는 이벤트 손실이다. 이벤트 저장소를 사용하는 방식은 이벤트 발생과 저장을 하나의 트랜잭션으로 처리하기 때문에 commit되면 이벤트가 저장되는 것을 보장할 수 있다. 반면 로컬 핸들러를 이용해 이벤트를 비동기로 처리하면 이벤트에 대한 유실 가능성이 존재한다.

네 번째는 이벤트의 순서다. 외부 시스템에 전달할 경우 이벤트 저장소를 사용하는 것이 좋다. 반면 메세징 시스템은 사용 기술에 따라 이벤트 발생순서와 메세지 전달 순서가 다를 수 있다.

마지막으로 이벤트 재처리에 대한 것이다. 동일 이벤트를 다시 처리할 때 이벤트를 어떻게 할지 결정해야한다.

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

예를 들어 주문 취소와 환불 기능을 다음과 같이 이벤트를 이용해 구현했다고 가정해보자.

  • 주문 취소 기능은 주문 취소 이벤트를 발생시킨다.
  • 주문 취소 이벤트 핸들러는 환불 서비스에 환불 처리를 요청한다.
  • 환불 서비스는 외부 API를 호출해 결제를 취소한다.

이 과정을 모두 동기로 처리하면 아래 그림과 같다.

https://user-images.githubusercontent.com/37062337/182606362-d17c71d1-7c44-406c-99ca-c86615b7d8be.png

이 과정의 13번에서 DB 업데이트 시 예외가 발생할 경우 외부 API 호출로 결제는 취소됐지만 DB는 주문이 취소되지 않은 상태로 남게 된다.

이벤트를 비동기로 처리할 때도 트랜잭션을 고려해야한다. 비동기 처리 과정은 아래 그림과 같다.

https://user-images.githubusercontent.com/37062337/182610161-3d5d7644-1e2e-42d7-9ccf-dd57a4a2e618.png

이벤트 핸들러를 호출하는 5번 과정부터 비동기로 실행되는데 DB 업데이트 반영(10번 과정)까지 정상적으로 완료 후 12번 과정에서 외부 API 호출에 실패하면 주문 데이터는 취소되지만 환불은 받지 못하는 상황이 발생할 수 있다.

이처럼 이벤트 처리 과정은 이벤트 처리 실패와 트랜잭션 실패를 고려해야한다. 두 가지 모두를 고려하면 복잡해지므로 경우의 수를 줄이는 것이 좋다. 그 방법 중 하나는 트랜잭션이 성공할 때만 이벤트 핸들러를 실행하는 것이다.

스프링이 제공하는 @TransactionalEventListener를 통해 트랜잭션 상태에 따라 이벤트 핸들러를 실행할 수 있게 한다.

@TransactionalEventListener(
    classes = OrderCanceledEvent.class,
    phase = TransactionPhase.AFTER_COMMIT
)
public void handle(OrderCanceledEvent event) {...}

phase 속성 값을 AFTER_COMMIT으로 설정하여 트랜잭션 커밋에 성공한 뒤 해당 핸들러 메서드를 실행시킨다. 이벤트 저장소로 DB를 사용할 때도 트랜잭션이 성공할 때만 이벤트가 DB에 저장된다.

이렇게 설정하면 경우의 수가 줄어들어 이벤트 처리 실패의 경우만 고려하면 된다.

위로

반응형