Posts [도메인주도개발시작하기] Chapter10-이벤트
Post
Cancel

[도메인주도개발시작하기] Chapter10-이벤트

10.1 시스템 간 강결합 문제

  • 쇼핑몰에서 구매를 취소하면 환불을 처리해야 한다.
  • 이때 환불 기능을 실행하는 주체는 주문 도메인 엔티티가 될 수 있따.
  • 도메인 객체에서 환불 기능을 실행하려면 다음 코드처럼 환불 기능을 제공하는 도메인 서비스를 파라미터로 전달바독 취소 도메인 기능에서 도메인 서비스를 실행하게 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Order {
  ...
  // 외부 서비스를 실행하기 위해 도메인 서비스를 파라미터로 전달받음
  public void cancel(RefundService refundService) {
    verifyNotYetShipped();
    this.state = OrderState.CANCELED;

    this.refundStatus = State.REFUND_STARTED;
    try {
      refundService.refund(getPaymentId());
      this.fefundStatus = State>REFUND_COMPLETED;
    } catch (Exception ex) {
      ??
    }

  }

  ...
}
  • 혹은 응용 서비스에서 환불 기능을 실행할 수도 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class CancelOrderService {
  private RefundService refundService;

  @Transactional
  public void cancel(OrderNo orderNo) {
    Order order = findOrder(orderNo);
    order.cancel();

    order.refundStarted();
    try {
      refundService.refund(order.getPaymentId());
      order.refundCompleted();
    } catch (Exception ex) {
      ???
    }
  }

  ...
}
  • 보통 결제 시스템은 외부에 존재하므로 RefundService는 외부 결제 시스템이 제공하는 환불 서비스를 호출한다.
  • 이때 세 가지 문제가 발생할 수 있다.

1) 트랜잭션 처리 범위 문제

  • 외부 서비스가 정상이 아닐 경우 트랜잭션 처리를 어떻게 해야할지 애매하다는 것이다.
    • 환불 기능을 실행하는 과정에서 익셉션이 발생하면 롤백해야 할까? 일단 커밋?
    • 외부의 환불 서비스를 실행하는 과정에서 익셉션이 발생하면 환불에 실패했으므로 주문 취소 트랜잭션을 롤백하는 것이 맞아 보인다.
    • 하지만 반드시 롤백 해야 하는 것은 아니다. 주문은 취소 상태로 변경하고 환불만 나중에 다시 시도하는 방식으로 처리할 수도 있다.

2) 성능 문제

  • 환불을 처리하는 외부 시스템의 응답 시간이 길어지면 그만큼 대기 시간도 길어진다.
    • 예를 들어, 환불 처리 기능이 30초 걸리면 주문 취소 기능은 30초만큼 대기시간이 증가한다.
    • 즉, 외부 서비스 성능에 직접적인 영향을 받게 된다.

3) 도메인 객체에 서비스를 전달함으로써 추가적인 설계 문제

  • 우선 주문 로직과 결제 로직이 섞이는 문제가 있을 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Order {
  ...
  // 외부 서비스를 실행하기 위해 도메인 서비스를 파라미터로 전달받음
  public void cancel(RefundService refundService) {
    verifyNotYetShipped();
    this.state = OrderState.CANCELED;
    // 여기까지 주문 로직

    this.refundStatus = State.REFUND_STARTED;
    try {
      refundService.refund(getPaymentId());
      this.fefundStatus = State>REFUND_COMPLETED;
    } catch (Exception ex) {
      ??
    }
    // 여기까지 결제 로직

  }

  ...
}
  • 그리고 기존 기능에 새로운 기능을 추가할 때 발생한다.
    • 만약 주문을 취소한 뒤 환불뿐만 아니라 취소했다는 내용을 통지해야 한다면?
    • 환불 도메인 서비스와 동일하게 파라미터로 통지 서비스를 받도록 해야할 것이고 앞서 언급한 로직이 섞인는 문제가 더 커지고 트랜잭션 처리가 더 복잡해지게 될 것이다.
    • 게다가 영향 주는 외부 서비스가 두개로 증가한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
public class Order {
  // 기능을 추가할 떄마다 파라미터가 함께 추가되면
  // 다른 로직이 더 많이 섞이고, 트랜잭션 처리가 더 복잡해진다.
  public void cancel(RefundService refundService, NotiService notiSvc) {
    verifyNotYetShipped();
    this.state = OrderState.CANCELED;
    
    ...
    // 주문 + 결제 + 통지 로직이 섞임
    // refundService 는 성공하고, notiSvc는 실패하면?
    // refundService와 notiSvc 중 무엇을 먼저 처리하나?
  }
}
  • 위에서 언급된 문제들은 주문 바운디드 컨텍스트와 결제 바운디드 컨텍스트간의 강결합(high coupling) 때문이다.
  • 주문이 결제와 강하게 결합되어 있어서 주문 바운디드 컨텍스트가 결제 바운디드 컨텍스트에 영향을 받게 되는 것이다.
  • 이런 강결합을 없앨 수 있는 방법이 이벤트를 사용하는 것이다.
  • 특히 비동기 이벤트를 사용하면 두 시스템 간의 결합도를 크게 낮출 수 있는데, 한 번 익숙해지면 모든 연동을 이벤트와 비동기로 처리하고 싶을 정도로 강력하고 매력적인 것이 이벤트다.

10.2 이벤트 개요

  • ‘이벤트’ 는 ‘과거에 벌어진 어떤 것’을 의미한다.
    • 예를 들어, 사용자가 암호를 변경한 것은 ‘암호를 변경했음 이벤트’가 벌어졌다고 할 수 있다. 비슷하게 주문을 취소했다면 ‘주문을 취소했음 이벤트’가 발생했다고 할 수 있다.
  • 웹 브라우저에서 자바스크립트 코드를 작성해본 경험이 있는 개발자라면 이미 이벤트에 익숙할 것이다.
    • UI개발에서 모든 UI컴포넌트는 관련 이벤트를 발생시킨다.
    • 예를 들어, 버튼을 클릭시 ‘버튼 클릭됨 이벤트’, 스크롤시 ‘스크롤됨 이벤트’가 발생하게 된다.
  • 이벤트가 발생했다는 것은 상태가 변경됐다는 것을 의미한다.
  • ‘암호 변경됨 이벤트’가 발생한 이유는 회원이 암호를 변경했기 때문이고, ‘주문 취소됨 이벤트’가 발생한 이유는 주문을 취소했기 떄문이다.

  • 이벤트가 발생하면 그 이벤트 반응하여 우너하는 동작을 수행하는 기능을 구현한다.
    • 다음 자바스크립트는 jQuery를 이용해서 작성한 코드이다.
    • 이 코드에서 click() 에 전달한 함수는 ‘myBtn’ 버튼에서 ‘클릭됨 이벤트’가 발생하면 그 이벤트에 반응하여 경고창을 출력한다.
1
2
3
$("#myBtn").click(function (evt) {
  alert("경고");
});
  • 도메인 모델에서도 UI 컴포넌트와 유사하게 도메인의 상태 변경을 이벤트로 표현할 수 있다.
  • 보통 ‘~할 때’, ‘~가 발생하면’, ‘만약 ~하면’과 같은 요구사항은 도메인의 상태 변경과 관련된 경우가 많고 이런 요구사항을 이벤트를 이용해서 구현할 수 있다.
  • 예를 들어 ‘주문을 취소할 때 이메일을 보낸다’ 라는 요구사항에서 주문을 취소할 때’ 는 주문이 취소 상태로 바뀌는 것을 의미하므로 ‘주문 취소됨 이벤트’를 활용해서 구현할 수 있다.

10.2.1 이벤트 관련 구성 요소

  • 도메인 모델에 이벤트를 도입하려면 아래 이미지와 같이 네 개의 구성요소인 이벤트, 이벤트 생성 주체, 이벤트 디스패처(퍼블리셔), 이벤트 핸들러(구독자)를 구현해야 한다.

image

  • 이벤트 생성 주체
    • 도메인 모델에서 이벤트 생성 주체는 엔티티, 밸류, 도메인 서비스와 같은 도메인 객체이다.
    • 이들 도메인 객체는 도메인 로직을 실행해서 상태가 바뀌면 관련 이벤트를 발생시킨다.
  • 이벤트 핸들러
    • 이벤트 생성 주체가 발생한 이벤트에 반응한다.
    • 생성 주체가 발생한 이벤트를 전달받아 이벤트에 담긴 데이터를 이용해서 원하는 기능을 실행한다.
    • 예를 들어, ‘주문 취소됨 이벤트’를 받는 이벤트 핸들러는 해당 주문의 주문자에게 SMS로 주문 취소 사실을 통지할 수 있다.
  • 이벤트 디스패처
    • 이벤트 생성 주체와 이벤트 핸들러를 연결해준다.
    • 이벤트 생성 주체는 이벤트를 생성해서 디스패처에 이벤트를 전달한다.
    • 이벤트를 전달받은 디스패처는 해당 이벤트를 처리할 수 있는 핸들러에 이벤트를 전파한다.
    • 이벤트 디스패처의 구현 방식에 따라 이벤트 생성과 처리를 동기나 비동기로 실행하게 된다.

10.2.2 이벤트의 구성

  • 이벤트는 발생한 이벤트에 대한 정보를 담는다.
  • 이 정보는 다음을 포함한다.
    • 1)이벤트 종류: 클래스 이름으로 이벤트 종류를 표현
    • 2)이벤트 발생시간
    • 3)추가 데이터: 주문번호, 신규 배송지 정보 등 이벤트와 관련된 정보
  • 배송지를 변경시 발생하는 이벤트는 아래와 같이 작성할 수 있다.
1
2
3
4
5
6
7
public class ShippingInfoChangedEvent {
  private String orderNumber;
  private long timestamp;
  private ShippingInfo newShippingInfo;

  // 생성자, getter
}
  • 클래스 이름을 보면 ‘Changed’라는 과거 시제를 사용했다. 이벤트는 현재 기준으로 과거(바로 직전이라도)에 벌어진 것을 표현하기 때문에 이벤트 이름에는 과거 시제를 사용한다.

  • 이 이벤트를 발생하는 주체는 Order 애그리거트다. Order 애그리거트의 배송지 변경 기능을 구현한 메서드는 다음 코드처럼 배송지 정보를 변경한 뒤에 이 이 벤트를 발생시킬 것이다.
  • 이 코드에서 Events.raise() 는 디스패처를 통해 이벤트르 전파하는 기능을 제공하는데 이 기능의 구현과 관련된 내용은 뒤에서 살펴보도록 하자.
1
2
3
4
5
6
7
8
public class Order {
  public void changeShippingInfo(ShippingInfo newShippingInfo) {
    verifyNotYetShipped();
    setShippingInfo(newShippingInfo);
    Events.raise(new ShippingInfoChangedEvent(number, newShippingInfo));
  }
  ...
}
  • ShippingInfoChangedEvent 를 처리하는 핸들러는 디스패처로부터 이벤트를 전달받아 필요한 작업을 수행한다.
    • 예를 들어, 변경된 배송지 정보를 물류 서비스에 전송하는 핸들러는 다음과 같이 구현할 수 있다.
1
2
3
4
5
6
7
8
9
public class ShippingInfoChangedHandler {
  @EventListener(ShippingInfoChangedEvent.class)
  public void handle(ShippingInfoChangedEvent evt) {
    shippingInfoSynchronizer.sync(
      evt.getOrderNumber(),
      evt.getNewShippingInfo()
    );
  }
}
  • 이벤트는 이벤트 핸들러가 작업을 수행하는데 필요한 데이터를 담아야 한다.
  • 이 데이터가 부족하면 핸들러는 필요한 데이터를 읽기 위해 관련 API를 호출하거나 DB에서 데이터를 직접 읽어와야 한다.
  • 예를 들어, ShppingInfoChangedEvent 가 바뀐 배송지 정보를 포함하고 있지 않다고 가정해보자.
    • 이 핸들러가 같은 VM에서 동작하고 있따면 다음과 같이 주문 데이터를 로딩해서 배송지 정보를 추출해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ShippingInfoChangedHandler {
  
  @EventListener(ShippingInfoChangedEvent.class)
  public void handle(ShippingInfoChangedEvent evt) {
    // 이벤트가 필요한 데이터를 담고 있지 않으면,
    // 이벤트 핸들러는 리포지터리, 조회 API, 직접 DB 접근 등의
    // 방식을 통해 필요한 데이터를 조회해야 한다.
    Order order =  orderRepository.findById(evt.getOrderNo());
    shippingInfoSynchronizer.sync(
      order.getOrderNumber(),
      order.getNewShippingInfo()
    );
  }

  ...
}
  • 이벤트는 데이터를 담아야 하지만 그렇다고 이벤트 자체와 관련 없는 데이터를 포함할 필요는 없다.
  • 배송지 정보를 변경해서 발생시킨 ShippingInfoChangedEvent 가 이벤트 발생과 직접 관련된 바뀐 배송지 정보를 포함하는 것은 맞지만, 배송지 정보 변경과 전혀 관련 없는 주문 상품번호와 개수를 담을 필요는 없다.

10.2.3 이벤트 용도

1) 트리거

  • 도메인의 상태가 바뀔때 다른 후처리가 필요하면 후처리를 실행하기 위한 트리거로 이벤트를 사용할 수 있다.
  • 주문에선 주문 취소 이벤트를 트리거로 사용할 수 있다.
  • 주문을 취소하면 환불을 처리해야 하는데 이때 환불 처리를 위한 트리거로 주문 취소 이벤트를 사용할 수 있다.

image

  • 예매 결과를 SMS로 통지할때도 이벤트를 트리거로 사용할 수도 있다. 예매 도메인은 ‘예매 완료’ 이벤트를 발생시키고 이 이벤트 핸들러에선 SMS를 발송하는 방식으로 구현할 수 있다.

2) 서로 다른 시스템 간의 데이터 동기화

  • 배송지를 변경하면 외부 배송 서비스에 바뀐 배송지 정보를 전송해야 한다.
  • 주문 도메인은 배송지 변경 이벤트를 발생시키고 이벤트 핸들러는 외부 배송 서비스와 배송지 정보를 동기화할 수 있다.

10.2.4 이벤트 장점

1) 서로 다른 도메인 로직이 섞이는 것을 방지

image 이미지 출처: https://velog.io/@csh0034/%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%A3%BC%EB%8F%84-%EA%B0%9C%EB%B0%9C-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0-10.-%EC%9D%B4%EB%B2%A4%ED%8A%B8

  • 위 이미지를 보면 구매 취소 로직에 이벤트를 적용함으로써 환불 로직이 없어지고 환불 서비스를 실행하기 위한 파라미터도 없어진 것을 볼 수 있다.
  • 환불 실행 로직은 주문 취소 이벤트를 받는 이벤트 핸들러로 이동하게 된다.
  • 이벤트를 사용하여 주문 도메인에서 결제(환불) 도메인으로의 의존을 제거했다.

2) 기능 확장이 용이

  • 구매 취소시 환불과 함께 이메일로 취소 내용을 보내고 싶다면 이메일 발송을 처리하는 핸들러를 구현하면 된다.
  • 기능을 확장해도 구매 취소 로직은 수정할 필요가 없다.

image

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

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

Note: 이벤트 디스패처를 직접 구현할 수도 있지만 이 책에서는 스프링이 제공하는 이벤트 관련 기능을 사용해서 이벤트 발생과 처리를 구현한다.

10.3.1 이벤트 클래스

  • 이벤트 자체를 위한 상위 타입은 존재하지 않는다. 원하는 클래스를 이벤트로 사용하면 된다.
  • OrderCanceledEvent 와 같이 클래스 이름 뒤에 접미사로 Event 를 사용해서 이벤트로 사용하는 클래스라는 것을 명시적으로 표현할 수도 있고 OrderCanceled 처럼 간결함을 위해 과거 시제만 사용 할수도 있다.
  • 이벤트 클래스는 이벤트를 처리하는데 필요한 최소한의 데이터를 포함해야 한다.
    • 예를 들어 주문 취소됨 이벤트는 적어도 주문번호를 포함해야 관련 핸들러에서 후속처리를 할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
public class OrderCanceledEvent {
  // 이벤트는 핸들러에서 이벤트를 처리하는데 필요한 데이터를 포함한다.
  private String orderNumber;

  public OrderCanceledEvent(String number) {
    this.orderNumber = number;
  }

  public String getOrderNumber() {
    return orderNumber;
  }
} 
  • 모든 이벤트가 공통으로 갖는 프로퍼티가 존재한다면 관련 상위 클래스를 만들 수도 있다.
    • 예를 들어 모든 이벤트가 발생 시간을 갖도록 하려면 다음과 같은 상위클래스를 만들고 각 이벤트 클래스가 이를 상속받도록 하면 된다.
1
2
3
4
5
6
7
8
9
10
// 공통 추상 클래스
package com.myshop.common.event;

public abstract class Event {
    private long timestamp;

    public Event() {
        this.timestamp = System.currentTimeMillis();
    }
}
1
2
3
4
5
6
7
8
9
10
// 발생 시간이 필요한 각 이벤트 클래스는 Event 클래스를 상속받아 구현한다.
public class OrderCanceledEvent extends Event {

    private String orderNumber;

    public OrderCanceledEvent(String number) {
        super();
        this.orderNumber = number;
    }
}

10.3.2 Events 클래스와 ApplicationEventPublisher

  • 이벤트 발생과 출판을 위해 스프링이 제공하는 ApplicationEventPublisher 를 사용한다.
  • 스프링 컨테이너는 ApplicationEventPublisher도 된다.
    • 스프링 컨테이너는 ApplicationEventPublisher 를 상속받는 구조로 되어 있기 때문이다.
  • Events 클래스는 ApplicationEventPublisher 를 사용해서 이벤트를 발생시키도록 구현할 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
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 클래스가 사용할 ApplicationEventPublisher 객체는 setPublisher() 메서드를 통해서 전달받는다.
  • Events#setPublisher() 메서드에 이벤트 퍼블리셔를 전달하기 위해 스프링 설정 클래스를 아래와 같이 작성한다.
1
2
3
4
5
6
7
8
9
10
@Configuration
public class EventsConfiguration {
    @Autowired
    private ApplicationContext applicationContext;

    @Bean
    public InitializingBean eventsInitializer() {
        return () -> Events.setPublisher(applicationContext);
    }
}
  • eventsInitializer() 메서드는 InitializingBean 타입 객체를 빈으로 설정한다.
    • 이 타입은 스프링 빈 객체를 초기화할 때 사용하는 인터페이스로, 이 기능을 사용해서 Events 클래스를 초기화했다.
    • 위에서 언급한것처럼 ApplicationContext 는 ApplicationEventPublisher 를 상속하고 있으므로 Events 클래스를 초기화할 때 ApplicationContext 를 전달했다.

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

  • 이벤트를 발생시킬 코드는 Events.raise() 메서드를 사용한다.
    • 예를 들어 Order#cancel() 메서드는 다음과 같이 구매 취소 로직을 수행한뒤 Events.raise() 를 이용해서 관련 이벤트를 발생시킨다.
1
2
3
4
5
6
7
8
public class Order {
    public void cancel() {
        verifyNotYetShipped();
        this.state = OrderState.CANCELED;
        Events.raise(new OrderCanceledEvent(number.getNumber()));
    }
    ...
}
  • 이벤트를 처리할 핸들러는 스프링이 제공하는 @EventListener 어노테이션을 사용해서 구현한다.
  • 다음은 OrderCanceldedEvent 를 처리하기 위한 핸들러를 구현한 코드의 예다
1
2
3
4
5
6
7
8
9
10
11
12
13
@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() 메서드를 실행할 때 OrderCanceledEvent 타입객체를 전달하면, OrderCanceledEvent.class 값을 갖는 @EventListener 어노테이션이 붙은 메서드를 찾아 실행한다.

10.3.4 흐름 정리

image

  • 아래와 같이 방식으로 처리된다.
    • 1)도메인 기능을 실행한다.
    • 2)도메인 기능은 Events.raise() 메서들르 이용해서 이벤트를 발생시킨다.
    • 3)Events.raise() 는 스프링이 제공하는 ApplicationEventPublisher 를 이용해서 이벤트를 출판한다.
    • 4)ApplicationEventPublisher 는 @EventListener(이벤트타입.class) 어노테이션이 붙은 메서드를 찾아 실행한다.
  • 코드 흐름을 보면 응용 서비스와 동일한 트랜잭션 범위에서 이벤트 핸들러를 실행하고 있다.
    • 즉, 도메인 상태 변경과 이벤트 핸들러는 같은 트랜잭션 범위에서 실행된다.

10.4 동기 이벤트 처리 문제

  • 이벤트를 사용해서 강결합 문제는 해결했지만, 외부 서비스에 영향을 받는 문제가 남아있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 1. 응용 서비스 코드
@Transactional // 외부 연동 과정에서 익셉션이 발생하면 트랜잭션 처리는?
public void cancel(OrderNo orderNo) {
  Order order = findOrder(orderNo);
  order.cancel(); // OrderCanceledEvent 발생
}

// 2. 이벤트를 처리하는 코드
@Service
public class OrderCanceledEventHandler {
  ...

  @EventListener(OrderCanceledEvent.class)
  public void handle(OrderCanceledevent event) {
    // refundService.refund()가 느려지거나 익셉션이 발생하면?
    refundService.refund(event.getOrderNumber());
  }
}
  • 위 코드에서 refundService.refund() 가 외부 환불 서비스와 연동한다고 가정해 보자.
    • 만약 외부 환불 기능이 갑자기 느려지면 cancel() 메서드도 함께 느려진다.
    • 이것은 외부 서비스의 성능 저하가 바로 내 시스템의 성능 저하로 연결된다는 것을 의미한다.
  • 성능 저하뿐만 아니라 트랜잭션도 문제가 된다.
    • refundService.refund() 에서 익셉션이 발생하면 cancel() 메서드의 트랜잭션을 롤백해야 할까? 트랜잭션을 롤백하면 구매 취소 기능을 롤백하는 것이므로 구매 취소가 실패하는 것과 같다.
    • 생각해볼만한 것은 외부 환불 서비스 실행에 실패했다고 반드시 트랜잭션을 롤백해야 하는지에 대한 문제다.
    • 일단 구매 취소 자체는 처리하고 환불만 재처리하거나 수동으로 처리할 수도 있다.
  • 외부 시스템과의 연동을 동기로 처리할때 발생하는 성능과 트랜잭션 범위 문제를 해소하는 방법은 이벤트를 비동기로 처리하거나 이벤트 와 트랜잭션을 연계하는 것이다.
    • 두 방법중 먼저 비동기 이벤트 처리에 대해 알아보자.

10.5 비동기 이벤트 처리

  • 회원 가입 신청을 하면 검증을 위해 이메일을 보내는 서비스가 많다.
    • 회원 가입 신청을 하자마자 바로 내 메일함에 검증 이메일이 도착할 필요는 없다.
    • 이메일이 몇 초뒤에 도착해도 문제 되지 않는다.
    • 10초 ~ 20초 후에 이메일이 도착해도 되고, 심지어 이메일을 받지 못하면 다시 받을 수 있는 기능을 이용하면 된다.
  • 비슷하게 주문을 취소하자마자 바로 결제를 취소하지 않아도 된다.
    • 수십초 내에 결제 취소가 이루어지면 된다. 몇칠 뒤에 결제가 확실하게 취소되면 문제없을 때도 있다.
  • 이렇게 ‘A 하면 이어서 B하라’ 는 내용을 담고 있는 요구사항은 실제로 ‘A 하면 최대 언제까지 B 하라’ 인 경우가 많다.
    • 즉, 일정 시간 안에서만 후속 조치를 처리하면 되는 경우가 적지 않다.
    • 게다가 ‘A 하면 이어서 B 하라’ 는 요구사항에서 B를 하는데 실패하면 일정 간격으로 재시도를 하거나 수동 처리를 해도 상관없는 경우가 있다.
    • 앞의 이메일 인증 예가 이에 해당한다.
    • 회원 가입 신청 시점에서 이메일 발송을 실패하더라도 사용자는 이메일 재전송 요청을 이용하여 수동으로 인증 이메일을 다시 받아볼 수 있다.
  • ‘A 하면 일정 시간 안에 B하라’ 는 요구사항에서 ‘A 하면은’ 이벤트로 볼 수도 있다.
    • ‘회원 가입 신청을 하면 인증 이메일을 보내라’ 는 요구사항에서 ‘회원 가입 신청을 하면’ 은 ‘회원 가입 신청함 이벤트로’ 볼 수 있다.
    • 따라서 ‘인증 이메일을 보내라’ 기능은 ‘회원 가입 신청함 이벤트’를 처리하는 핸들러에서 보낼 수 있다.
  • 앞서 말했듯 ‘A 하면 이어서 B 하라’는 요구사항 중에서 ‘A 하면 최대 언제까지 B 하라’로 바꿀 수 있는 요구사항은 이벤트를 비동기로 처리하는 방식으로 구현할 수 있다.
    • 다시 말해서 ‘A 이벤트가 발생하면 별도 스레드로 B를 수행하는 핸들러를 실행하는 방식으로 요구사항을 구현할 수 있다.
  • 이벤트를 비동기로 구현하는 방법은 다양한데, 여기선 다음 네 가지 방식으로 비동기 이벤트 처리를 구현하는 방법에 대해 알아보자.
    • 1)로컬 핸드러를 비동기로 실행하기
    • 2)메시지 큐를 사용하기
    • 3)이벤트 저장소와 이벤트 포워더 사용하기
    • 4)이벤트 저장소와 이벤트 제공 API 사용하기
  • 네 가지 방식은 각자 구현하는 방식도 다르고 그에 따른 장단점이 있다. 각 방식에 대해 차례대로 살펴보자.

10.5.1 로컬 핸들러 비동기 실행

  • 이벤트 핸들러를 별도 스레드로 실행시키는 방법이다.
  • 스프링이 제공하는 @Async 어노테이션을 사용하면 손쉽게 구현 가능하다.
1
2
3
4
5
6
7
8
@SpringBootApplication
@EnableAsync  // 기능 활성화
public class ShopApplication {

    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}
1
2
3
4
5
6
7
8
9
10
// 핸들러
@Service
public class OrderCanceledEventHandler {

    @Async // @Async 에너테이션 사용
    @EventListener(OrderCanceledEvent.class)
    public void handle(OrderCanceledEvent event) {
        refundService.refund(event.getOrderNumber());
    }
}

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

  • 카프카나 래빗MQ 와 같은 메시징 시스템을 사용하는 것이다.
  • 처리 프로세스는 다음과 같다.
    • 1)이벤트가 발생하면 이벤트 디스패처는 아래 이미지와 같이 이벤트를 메시지 큐에 저장한다.
    • 2)메시지 큐는 이벤트를 메시지 리스너에 전달한다.
    • 3)메시지 리스너는 알맞은 이벤트 핸들러를 통해 이벤트를 처리한다.
  • 이때 이벤트를 메시지 큐에 저장하는 과정과 메시지 큐에서 이벤트를 읽어와 처리하는 과정은 별도 스레드나 프로세스로 처리된다.

image

  • 필요하다면 이벤트를 발생시키는 도메인 기능과 메시지 큐에 이벤트를 저장하는 절차를 한 트랜잭션으로 묶어야 한다.
    • 도메인 기능을 실행한 결과를 DB에 반영하고 이 과정에서 발생한 이벤트를 메시지 큐에 저장하는 것을 같은 트랜잭션 범위에서 실행하려면 글로벌 트랜잭션이 필요하다.
  • 글로벌 트랜잭션을 사용하면 안전하게 이벤트를 메시지 큐에 전달할 수 있는 장점이 있지만 반대로 전체 성능이 떨어지는 단점도 있다.
  • 글로벌 트랜잭션을 지원하지 않는 메시징 시스템도 있다.
  • 메시지 큐를 사용하면 보통 이벤트를 발생하는 주체와 이벤트 핸들러가 별도 프로세스에서 동작한다.
    • 이것은 이벤트 발생 JVM과 이벤트 처리 JVM이 다르다는 것을 의미한다.
    • 물론 한 JVM에서 이벤트 발생 주체와 이벤트 핸들러가 메시지 큐를 이용해서 이벤트를 주고받을 수 있지만, 이는 시스템을 복잡하게 만들 뿐이다.
  • 래빗MQ 처럼 많이 사용되는 메시징 시스템은 글로벌 트랜잭션 지원과 함께 클러스터와 고가용성을 지원하기 때문에 안정적으로 메시지를 전달할 수 있는 장점이 있다.
    • 또한 다양한 개발 언어와 통신 프로토콜을 지원하고 있다.
  • 메시지를 전달하기 위해 많이 사용되는 것 중 또 하나가 카프카인데 글로벌 트랜잭션을 지원하지 않지만 다른 메시징 시스템에 비해 높은 성능을 보여준다.
    • 여기를 참고하면 카프카에서 제공하는 ChainedKafkaTransactionManager 를 통해 DB 트랜잭션과 카프카 트랜잭션을 묶어서 관리할 수 있다.

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

  • 이벤트를 일단 DB에 저장 후 별도 프로그램을 이용해서 이벤트 핸들러에 전달하는 방법이다.

1) 이벤트 포워더를 이용한 방식

image

  • 이벤트 발생시 핸들러는 스토리지에 이벤트를 저장한다.
  • 포워더는 주기적으로 이벤트 저장소에서 이벤트를 가져와 이벤트 핸들러를 실행한다.
  • 포워더는 별도 스레드를 이용하므로 이벤트 발행과 처리가 비동기로 처리된다.

  • 이 방식은 도메인의 상태와 이벤트 저장소로 동일한 DB를 사용하기에 이 둘이 로컬 트랜잭션으로 처리된다.
  • 이벤트를 물리적 젖아소에 보관하기 떄문에 핸들러가 이벤트 처리에 실패할 경우 포워더는 다시 이벤트 저장소에서 이벤트를 읽어와 핸들러를 실행하면 된다.

2) 이벤트를 외부에 제공하는 API 를 사용하는 방식

image

  • 포워더 방식과의 차이점은 이벤트를 전달하는 방식에 있다.
    • 포워더 방식은 포워더를 이용해서 이벤트를 외부에 전달한다면,
    • API 방식은 외부 핸들러가 API 서버를 통해 이벤트 목록을 가져간다.
    • 포워더 방식은 이벤트를 어디까지 처리했는지 추적하는 역할이 포워더에 있다면 API 방식에선 이벤트 목록을 요구하는 외부 핸들러가 자신이 어디까지 이벤트를 처리했는지 기억해야 한다.

이벤트 저장소 구현

  • 포워더 방식과 API 방식 모두 이벤트 저장소를 사용하므로 이벤트를 저장할 저장소가 필요하다.

KakaoTalk_Photo_2023-06-17-17-27-57 001

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

실제 구현 코드

  • EventEntry 클래스를 실제 구현한 코드는 아래와 같다.
1
2
3
4
5
6
7
8
9
public class EventEntry {
  private Long id;
  private String type;
  private String contentType; // application.json
  private String payload; // 실제 이벤트 페이로드
  private long timestampe;

  // constructor, getter
}
  • EventStore 는 이벤트 객체를 직렬화해서 payload 에 저장한다.
  • 이때 JSON으로 직렬화했다면 contentType 값으로 ‘application/json’ 을 갖는다.
1
2
3
4
public interface EventStore {
  void save(Object event);
  List<EventEntry> get(long offset, long limit);
}
  • 이벤트는 과거에 벌어진 사건이므로 데이터가 변경되지 않기에 새로운 이벤트를 추가하는 save 메서드와 조회하는 get 메서드만 제공한다.(기존 이벤트 수정 메서드는 존재하지 않는다.)

  • EventStore 인터페이스를 구현한 JdbcEventStore 클래스는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@Component
public class JdbcEventStore implements EventStore {
    private ObjectMapper objectMapper;
    private JdbcTemplate jdbcTemplate;


    @Autowired
    public void setJdbcTemplate(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Autowired
    public void setObjectMapper(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public void save(Object event) {
        EventEntry entry = new EventEntry(event.getClass().getName(),
                "application/json", toJson(event));
        jdbcTemplate.update(
                "insert into evententry (type, content_type, payload, timestamp) values (?, ?, ?, ?)",
                ps -> {
                    ps.setString(1, entry.getType());
                    ps.setString(2, entry.getContentType());
                    ps.setString(3, entry.getPayload());
                    ps.setTimestamp(4, new Timestamp(entry.getTimestamp()));
                });
    }

    private String toJson(Object event) {
        try {
            return objectMapper.writeValueAsString(event);
        } catch (JsonProcessingException e) {
            throw new PayloadConvertException(e);
        }
    }

    @Override
    public List<EventEntry> get(long offset, long limit) {
        return jdbcTemplate.query("select * from evententry order by id asc limit ?, ?",
                ps -> {
                    ps.setLong(1, offset);
                    ps.setLong(2, limit);
                },
                (rs, rowNum) -> {
                    return new EventEntry(
                            rs.getLong("id"), rs.getString("type"),
                            rs.getString("content_type"), rs.getString("payload"),
                            rs.getTimestamp("timestamp").getTime());
                });
    }
}
  • 간단한 구현이므로 스프링이 제공하는 JdbcTemplate 을 사용했다.
  • save() 메서드는 EventEntry 객체를 생성하여 저장한다.
    • mysql 을 사용하여 주요키를 auto_increment 컬럼으로 지정했기에 insert 쿼리 실행시 주요키를 설정하지 않았다.
  • get() 메서드는 MySQL의 limit 를 이용해서 id 순으로 정렬했을때 offset 파라미터로 지정한 이벤트부터 limit 개수만큼 데이터를 조회한다.

  • EventEntry 를 저장할 event 테이블의 ddl은 아래와 같다.
1
2
3
4
5
6
7
create table evententry {
  id int not null AUTO_INCREMENT PRIMARY KEY,
  `type` varchart(255),
  `content_type` varchart(255),
  payload MEDIUMTEXT,
  `timestamp` datetime,
} character set utf8mb4

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

  • 이벤트 저장소를 위한 기반이 되는 클래스는 모든 구현했고 남은 것은 발생한 이벤트를 이벤트 저장소에 추가하는 이벤트 핸들러를 구현하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
@Component
public class EventStoreHandler {
  private EventStore eventStore;

  public EventStoreHandler(EventStore eventStore) {
    this.eventStore = eventStore;
  }

  @EventListener(Event.class)
  public void handle(Event event) {
    eventStore.save(event);
  }
}
  • Event 타입을 상속받은 이벤트 타입만 이벤트 저장소에 저장하는 핸들러이다.

REST API 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestController
public class EventApi {
  private EventStore eventStore;

  @Autowired
  public void setEventStore(EventStore eventStore) {
      this.eventStore = eventStore;
  }

  @RequestMapping(value = "/api/events", method = RequestMethod.GET)
  public List<EventEntry> list(
          @RequestParam("offset") Long offset,
          @RequestParam("limit") Long limit) {
      return eventStore.get(offset, limit);
  }
}
  • EventApi 가 처리하는 URL에 연결하면 아래와 같이 JSON 형식의 EventEntry 목록을 구할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[
  {
    "id": 1,
    "type": "com.myshop.eventstore.infra.SampleEvent",
    "contentType": "application/json",
    "payload": "{\"name\": \"name1\", \"value\": 11}",
    "timestamp": 1641684436000
  },
  {
    "id": 2,
    "type": "com.myshop.eventstore.infra.SampleEvent",
    "contentType": "application/json",
    "payload": "{\"name\": \"name2\", \"value\": 12}",
    "timestamp": 1641684436000
  }
]
  • 이벤트를 수정하는 기능이 없으므로 REST API도 단순 조회 기능만 존재한다.
  • API를 사용하는 클라이언트는 일정 간격으로 다음 과정을 실행한다.
    • 1)가장 마지막에 처리한 데이터의 offset 인 lastOffset을 구한다. 저장한 lastOffset이 없으면 0을 사용한다.
    • 2)마지막에 처리한 lastOffset 을 offset 으로 사용해서 API를 실행한다.
    • 3)API 결과로 받은 데이터를 처리한다.
    • 4)offset + 데이터 개수를 lastOffset 으로 저장한다.
  • 마지막으로 처리한 lastOffset 을 저장하는 이유는 같은 이벤트를 중복해서 처리하지 않기 위해서이다.
  • API를 사용하는 과정을 그림으로 정리하면 다음과 같다.

KakaoTalk_Photo_2023-06-17-17-27-57 002

  • 클라이언트가 1분 주기로 최대 5개의 이벤트를 조회하는 상황을 정리한 것이다.
  • 최초로 이벤트를 1분 시점에 조회한 이벤트가 없으므로 off은 이다.
  • 1분 시점에 5개의 이벤트를 조회해서 처리했으므로 2분 시점에 요청하는 offset 은 5가된다.
  • 2분 시점에 offset 5 이후로 저장된 이벤트가 3개다보니 3분 시점의 offset은8이된다.
  • 3분 시점에 0개이벤트만 제공되므로 4분 시점에도 동일하게 offset 8로 요청한다.

  • 클라이언트 API 를 이용해서 언제든지 원하는 이벤트를 가져올 수 있기 떄문에 이벤트 처리에 실패하면 다시 실패한 이벤트부터 읽어와 이벤트를 재처리할 수 있다.
  • API 서버에 장애가 발생한 경우에도 주기적으로 재시도를 해서 API 서버가 살아나면 이벤트를 처리할 수 있다.

포워더 구현

  • 포워더는 일정 주기로 EventStore 에서 이벤트를 읽어와 이벤트 핸들러에 전달하면 된다.
  • API 방식 클라이언트와 마찬가지로 마지막으로 전달한 이벤트의 off을 기억해두었다가 다음 조회 시점에 마지막으로 처리한 offset 부터 이벤트를 가져오면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Component
public class EventForwarder {
    private static final int DEFAULT_LIMIT_SIZE = 100;

    private EventStore eventStore;
    private OffsetStore offsetStore;
    private EventSender eventSender;

    private int limitSize = DEFAULT_LIMIT_SIZE;

    @Scheduled(initialDelay = 1000L, fixedDelay = 1000L)
    public void getAndSend() {
        long nextOffset = getNextOffset();
        List<EventEntry> events = eventStore.get(nextOffset, limitSize);
        if (!events.isEmpty()) {
            int processedCount = sendEvent(events);
            if (processedCount > 0) {
                saveNextOffset(nextOffset + processedCount);
            }
        }
    }

    private long getNextOffset() {
        return offsetStore.get();
    }

    private int sendEvent(List<EventEntry> events) {
        int processedCount = 0;
        try {
            for (EventEntry entry : events) {
                eventSender.send(entry);
                processedCount++;
            }
        } catch(Exception ex) {
            // 로깅 처리
        }
        return processedCount;
    }

    private void saveNextOffset(long nextOffset) {
        offsetStore.update(nextOffset);
    }
}
  • getAndSend() 메서드를 주기적으로 실행하기 위해 스프링의 @Scheduled 어노테이션을 사용했다.
    • 스프링을 사용하지 않으면 별도 스케줄링 프레임워크를 이용해서 getAndSend() 메서드를 원하는 주기로 실행하면 된다.
  • getNextOffset() 메서드와 saveNextOffset() 메서드에선 사용한 OffsetStore 인터페이스는 다음 두 메서드를 정의하고 있다.
1
2
3
4
public interface OffsetStore {
    long get();
    void update(long nextOffset);
}
  • OffsetStore 를 구현한 클래스는 offset 값을 DB 텡니블에 저장하거나 로컬 파일에 보관해서 마지막 offset 을 물리적 저장소에 보관해야 한다.

  • 실제 이벤트 발송 로직에 따르면 getAndSend() 메서드를 실행하면 마지막으로 전송에 성공한 이벤트의 다음 이벤트 부터 읽어와 전송을 시도하게 된다.(익셉션이 발생하면 전송을 멈추고 전송에 성공한 이벤트 개수를 리턴하므로)

  • EventSender 인터페이스는 다음과 같이 단순하다.

1
2
3
public interface EventSender {
    void send(EventEntry event);
}
  • 이 인터페이스를 구현한 클래스는 send() 메서드에서 외부 메시징 시스템에 이벤트를 전송하거나 원하는 핸들러에 이벤트를 전달하면 된다.
  • 이벤트 처리 중에 익셉션이 발생하면 그대로 전파해서 다음 주기에 getAndSend() 메서드를 실행할 때 재처리할 수 있도록 한다.

자동 증가 칼럼 주의사항: primary key로 자동 증가 칼럼을 사용할 때는 주의할 점이 있다. insert 쿼리 실행하는 시점에 값이 증가하지만 실제 데이터는 트랜잭션 커밋 시점에 DB에 반영된다. 즉 insert 쿼리 실행해서 자동 증가 칼럼이 증가했더라도 트랜잭션 커밋 전에 조회하면 증가한 값을 가진 레코드는 조회되지 않는다. 또한 커밋 시점에 따라 DB에 반영되는 시점이 달라질 수도 있다. 예를 들어 마지막 자동 증가 칼럼 값이 10인 상태에서 A트랜잭션이 insert 쿼리를 실행 후 B 트랜잭션이 insert 쿼리를 실행하면 A는 11, B는 12를 사용하게 된다. 그런데 B 트랜잭션 커밋 후 A 가 커밋되면 12가 DB에 먼저 반영되고 그다음 11이 반영된다. 만약 두 트랜잭션 커밋 사이에 데이터 조회시 11은 조회되지 않고 12만 조회되는 상황이 발생한다. 이런 문제가 발생하지 않도록 하려면 ID를 기준으로 데이터를 지연 조회하는 방식을 사용해야 한다. 관련 내용은 여기를 참고하면 된다.

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

  • 이벤트 적용 시 다음 고려사항을 생각해본다.

1) 이벤트 소스를 EventEntry에 추가할지?

  • EventEntry는 이벤트 발생 주체에 대한 정보를 갖지 않는다.
  • 특정 주체가 발생시킨 이벤트만 조회하는 기능을 구현할 수 없다.
  • 이 기능을 구현하려면 이벤트에 발생 주체 정보를 추가해야 한다.

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

  • 포워더는 이벤트 전송에 실패하면 실패한 이벤트부터 다시 읽어와 전송을 시도한다.
    • 만약? 특정 이벤트가 계속 실패한다면?
  • 실패한 이벤트의 재전송 횟수 제한을 두어야 한다.
  • 실패한 이벤트는 실패용 DB나 메시지 큐에 저장한다.

3) 이벤트 손실이 된다면 어떻게 할지?

  • 이벤트 저장소를 사용하면 이벤트 발생과 이벤트 저장을 한 트랜잭션으로 처리하기 때문에 트랜잭션에 성공하면 이벤트가 저장소에 보관된다는 것을 보장할 수 있다.
  • 반면 로컬 핸들러를 이용해서 이벤트를 비동기로 처리할 경우 이벤트 처리에 실패하면 이벤트를 유실하게 된다.

4) 이벤트 순서는?

  • 이벤트 발생 순서대로 외부 시스템에 전달해야 할 경우는 이벤트 저장소를 사용하는 것이 좋다.
    • 이벤트 저장소는 젖아소에 이벤트를 발생 순서대로 저장하고 그 순서대로 이벤트 목록을 제공하기 떄문이다
  • 반면 메시징 시스템은 사용 기술에 따라 이벤트 발생 순서와 메시지 순서가 다를 수 있다.

5) 이벤트 재처리는?

  • 동일한 이벤트를 다시 처리할 때 이벤트를 어떻게 할지 결정해야 한다.
  • 가장 쉬운 방법은 마지막으로 처리한 이벤트의 순번을 기억해두었다가 이미 처리한 순번의 이벤트가 도착하면 해당 이벤트를 처리하지 않고 무시하는 것이다.
    • 예를 들어, 회원 가입 신청 이벤트가 처음 도착하면 이메일을 발송하는데, 동일한 순번의 이벤트가 다시 들어오면 이메일을 발송하지 않는 방식으로 구현하는 것이다.
  • 이 외에 이벤트를 멱등으로 처리하는 방법도 있다.

멱등성이란?: 연산을 여러번 적용해도 결과가 달라지지 않는 성질을 멱등성이라 한다. 수학에서 절대값 함수인 abs()가 멱등성을 갖는 대표적인 예이다. 어떤 x에 대해 abs() 연산을 여러 번 적용해도 결과는 동일하다. 즉, abs(x), abs(abs(x)), abs(abs(abs(x))) 는 모두 결과가 같다. 비슷하게 이벤트 처리도 동일 이벤트를 한 번 적용하나 여러 번 적용하나 시스템이 같은 상태가 되도록 핸들러를 구현할 수 있다. 예를 들어 배송지 정보 변경 이벤트를 받아서 주소를 변경하는 핸들러는 그 이벤트를 한 번 처리하나 여러 번 처리하나 결과적으로 동일 주소를 값으로 갖는다. 같은 이벤트를 여러 번 적용해도 결과가 같으므로 이 이벤트 핸들러는 멱등성을 갖는다. 이벤트 핸들러가 멱등성을 가지면 시스템 장애로 인해 같은 이벤트가 중복해서 발생해도 결과적으로 동일 상태가 된다. 이는 이벤트 중복 발생이나 중복 처리에 대한 부담을 줄여준다.

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

  • 이벤트를 처리할 땐 DB 트랜잭션을 함꼐 고려해야 한다.
  • 예를 들어 주문 취소와 환불 기능을 다음과 같이 이벤트를 이용해서 구현했다고 하자.
    • 주문 취소 기능은 주문 취소 이벤트를 발생시킨다.
    • 주문 취소 이벤트 핸들러는 환불 서비스에 환불 처리를 요청한다.
    • 환불 서비스는 외부 API를 호출해서 결제를 취소한다.
  • 이벤트 발생과 처리를 모두 동기로 처리하면 실행 흐름은 다음과 같을 것이다.

KakaoTalk_Photo_2023-06-17-18-06-44 002

  • 고민할 포인트는 12번 과정까지 다 성공하고 13번 과정에서 DB를 업데이트하는데 실패하는 상황이다.
  • 다 성공하고 13번 과정에서 실패하면 결제는 취소됐는데 DB에는 주문이 취소되지 않은 상태로 남게 된다.

  • 이벤트를 비동기로 처리할 때도 DB 트랜잭션을 고려해야 한다.

KakaoTalk_Photo_2023-06-17-18-06-43 001

  • 주문 쉬초 이벤트를 비동기로 처리할 때의 실행흐름이다.
  • 이벤트 핸들러를 호출하는 5번 과정은 비동기로 실행한다.
  • DB 업데이트와 트랜잭션을 다 커밋한 뒤에 환불 로직인 11~13번 과정을 실행했다고 하자.
  • 만약 12번 과정에서 외부 API 호출에 실패하면 DB 에는 주문이 취소된 상태로 데이터가 바뀌었는데 결제는 취소되지 않은 상태로 남게 된다.

어떻게 하면 가장 효율적으로 해결할 수 있을까?

  • 이벤트 처리를 동기로 하든 비동기로 하든 이벤트 처리 실패와 트랜잭션 실패를 함께 고려해야 한다.
  • 트랜잭션 실패와 이벤트 처리 실패를 모두 고려하면 복잡해지므로 경우의 수를 줄이면 도움이 된다.
    • 경우의 수를 줄이는 방법은 트랜잭션이 성공할 때만 이벤트 핸들러를 실행하는 것이다.
  • 스프링은 @TransactionalEventListener 어노테이션을 지원하여 스프링 트랜잭션 상태에 따라 이벤트 핸들러를 실행할 수 있게 한다.
1
2
3
4
5
6
7
@TransactionalEventListener(
  classes = OrderCanceledEvent.class,
  phase = TransactionPhase.AFTER_COMMIT
)
public void handle(OrderCanceledEvent event) {
  refundService.refund(event.getOrderNumber());
}
  • 위 코드에서 phase 속성 값으로 TransactionPhase.AFTER_COMMIT 을 지정했는데 이는 스프링이 트랜잭션 커밋에 성공한 뒤에만 핸들러를 실행하도록 한다.
    • 중간에 에러가 발생해서 트랜잭션이 롤백 되면 핸들러 메서드를 실행하지 않는다.
    • 이 기능을 사용하면 이벤트 핸들러를 실행했는데 트랜잭션이 롤백되는 상황은 발생하지 않는다.
  • 이벤트 저장소로 DB를 사용해도 동일한 효과를 볼 수 있다.
    • 이벤트 발생 코드와 저장 처리를 한 트랜잭션으로 처리하면 된다.
    • 이렇게 하면 트랜잭션 성공시에만 이벤트가 DB에 저장되므로, 트랜잭션은 실패했는데 이벤트 핸들러가 실행되는 상황은 발생하지 않게 된다.
  • 트랜잭션 성공할 때만 이벤트 핸들러를 실행하게 되면 트랜잭션 실패에 대한 경우의 수가 줄어 이제 이벤트 처리 실패만 고민하면 된다. 이벤트 특성에 따라 재처리 방식을 결정하면 된다.

Reference

This post is licensed under CC BY 4.0 by the author.

[도메인주도개발시작하기] Chapter9-도메인 모델과 바운디드 컨텍스트

[도메인주도개발시작하기] Chapter11-CQRS