Published on

도메인 주도 개발 시작하기 Chap10

Authors
  • avatar
    Name
    ywj9811
    Twitter

이벤트

시스템 간 강결합 문제

예를 들어 쇼핑몰에서 구매를 취소하면 환불처리를 해야한다.

이때 여러 도메인이 함께 동작할 수 있는데, 주문 도메인에서 환불 기능을 제공하는 도메인 서비스를 받아서 처리하거나, 응용 서비스에서 처리할 수 있다.

하지만, 주로 결제 시스템은 외부에 위치하기 때문에 환불 도메인 서비스는 외부에 있는 결제 시스템이 제공하는 환불 서비스를 호출한다.

이때 두가지 문제가 발생할 수 있다.

  1. 외부 서비스가 정상이 아닌 경우 트랜잭션 처리를 어떻게 해야할까?
  2. 만약 환불을 처리하는 외부 시스템의 응답 시간이 길어지면, 서비스의 응답 시간도 같이 길어질 수 있다.

이러한 문제를 해결하는 방법으로는 이벤트 방식이 있다.

이벤트 개요

여기서 사용하는 이벤트는 ‘과거에 벌어진 어떤 것’ 을 의미한다.

이벤트 관련 구성요소

Untitled

여기서, 도메인 모델을 대입하여 생각하면 다음과 같다.

  • 이벤트 생성 주체 : 엔티티, 밸류, 도메인 서비스와 같은 도메인 객체
  • 이벤트 핸들러 : 이벤트 생성 주체가 발행한 이벤트에 반응하는 부분
  • 이벤트 디스패처 : 이벤트 생성 주체와 이벤트 핸들러를 연겨해 주는 것

이때 이벤트 디스패처의 구현 방식에 따라서 이벤트 생성과 처리를 동기로 하거나 비동기로 하거나 나뉜다.

이벤트 구성

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

이벤트 핸들러는 디스패처로 부터 이벤트를 전달받아 이벤트 처리에 필요한 작업을 수행한다.

이벤트 용도

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

  1. 트리거

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

    주문에서는 주문 취소 이벤트를 트리거로 사용할 수 있는 것이다.

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

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

이벤트 장점

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

Untitled

위의 경우는 예시로 들은 주문 도메인에서 환불 기능을 제공하는 도메인 서비스를 받아서 처리하는 경우를 이벤트를 사용하여 분리한 것이다.

이벤트를 사용하여 주문 도메인에서 환불을 위한 결제 도메인에 대한 의존을 제거했다.

그리고, 이벤트를 사용하면 확장에도 용이하다.

만약 이메일을 보내는 로직이 추가로 필요하다면, 도메인에서는 해당 이벤트를 추가로 발생시키기만 하면 된다.

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

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

이벤트 클래스

정해진 타입은 없다. 원하는 클래스를 이벤트로 사용하면 된다.

주로, 과거 시제를 사용하여 클래스명을 만든다.

예를 들어 OrderCanceledEvent 혹은 OrderCanceled 이렇게 만든다.

public OrderCanceledEvent {
		private String orderNumber;
	
		// getter, constructure ...

모든 이벤트가 공통으로 갖는 프로퍼티가 존재한다면 관련 상위 추상 클래스를 만들고 사용하기 또한 가능하다.

Events 클래스와 ApplicationEventPublisher

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

Events 클래스는 ApplicationEventPublisher를 사용해서 이벤트를 발생시키도록 구현할 것이다.

public class Events {
		private static ApplicationEventPublisher publisher;
		
		//setter
		
		public static void raise(Object event) {
				if (publisher != null) {
						publisher.publishEvent(event);
				}
		}
}

Events 클래스의 raise() 는 ApplicationEventPublisher이 제공하는 publishEvent() 를 이용해서 이벤트를 발생시킨다.

@Configuration
public class EventConfiguration {
		@Autowired
		private ApplicationContext applicationContext;
		
		@Bean
		public InitializingBean eventsInitializer() {
				return () -> Events.setPublisher(applicationContext);
		}
}

이렇게 Events 클래스를 초기화 하고 사용할 수 있다.

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

Events.raise() 를 통해 이벤트를 발생시키는데, 이제 이벤트를 처리해야 한다.

이벤트를 처리할 핸들러는 스프링이 제공하는 @EventListner 어노테이션을 사용해서 구현한다.

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

이런식으로 만들 수 있다.

이때 이벤트를 발생시킬 때 사용한 타입 객체를 가지는 @EventListener 가 붙은 메소드를 찾아서 실행시킨다.

흐름 정리

  1. 도메인 기능 실행
  2. 도메인 기능은 Events.raise() 를 사용하여 이벤트 발생
  3. Events.raise() 는 스프링이 제공하는 기능을 사용하여 이벤트를 출판
  4. @EventListener 를 찾아서 실행

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

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

동기 이벤트 처리 문제

지금까지 도메인간의 결합을 해결했다.

하지만 외부 시스템에 대한 영향은 해결하지 못했다.

만약 외부 환불 서비스가 실행에 실패했다고 하더라도, 반드시 트랜잭션을 롤백할 필요가 없다면?

구매 취소는 성공 시키고, 이후에 환불을 따로 재처리 할 수 있을 것이다.

이를 위해서는 외부 시스템과 연동을 동기로 처리하지 않고 비동기로 처리하거나 이벤트와 트랜잭션을 연계하는 방식이 있다.

비동기 이벤트 처리

4가지 방식으로 비동기 이벤트 처리를 구현하는 것을 알아보자.

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

로컬 핸들러 비동기 실행

이때는 단순하게 이벤트 핸들러를 별도의 스레드로 실행하는 것이다.

스프링이 제공하는 Async 어노테이션을 사용하면 손쉽게 비동기로 구현할 수 있다.

이를 위해 두가지 준비가 필요하다.

  1. @EventAsync 어노테이션을 사용해 비동기 기능 활성화
  2. 이벤트 핸들러 메소드에 @Async 어노테이션을 붙인다.

이렇게 하면 기존의 코드를 크게 수정하지 않고 비동기로 변경할 수 있다.

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

기존의 방식이 아닌, 메시지 큐를 이용하는 방식이다.

Kafka 혹은 RabbitMQ 와 같은 메시징 시스템을 사용하는 것이다.

이러한 방식을 사용하면 이벤트를 메시지 큐에 저장하는 과정과 메시지 큐에서 이벤트를 읽어와 처리하는 과정은 별도의 스레드나 프로세스로 처리한다.

하지만, 이때 모든 과정을 하나의 트랜잭션 범위에서 실행하기 위해서는 글로벌 트랜잭션이 필요하며 이는 성능이 안좋아질 수 있다.

참고로, RabbitMQ는 글로벌 트랜잭션을 지원하지만, Kafka는 글로벌 트랜잭션을 지원하지 않는다 한다.

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

이벤트를 비동기로 처리하는 또 다른 방법은, 이벤트를 DB에 저장하고, 주기적으로 읽어와 이벤트 핸들러에 전달하는 것이다.

이때 두가지 방식이 있으며, 이를 그림으로 확인하자.

Untitled
Untitled

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

다만, 이벤트를 사용하면 추가로 고려할 사항이 있다.

  • 이벤트 저장소에 이벤트의 발생 주체에 대한 정보를 추가할지 고려하자.
  • 이벤트 저장소의 포워더에서 전송 실패를 얼마나 허용하고, 어떻게 처리할지 고려하자.
  • 이벤트 저장소를 하나의 트랜잭션에서 수행하면 보관이 보장되지만, 로컬 핸들러를 사용하는 경우 이벤트가 유실되는 위험이 있다는 것을 고려하자.
  • 이벤트 순서를 보장하는 방법에 대해 고려하자.
  • 이벤트 재처리를 어떻게 수행할지 고려하자.

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

DB 트랜잭션 관점에서 고려할 점이 있다.

  1. 이벤트 발생과 처리를 모두 동기로 처리한다면

    Untitled

    만약 위에서 13번 과정이 실패하면 모두 롤백을 하는것이 맞을까 생각을 해볼 필요가 있을 것이다.

  2. 이벤트를 비동기로 처리한다면

    Untitled

    10번까지 성공했지만, 12번이 실패한다면 결제는 취소되지 않을 것이다.

    이에 대한 처리를 고민할 필요가 있을 것이다.

이벤트 처리에 대해 동기/비동기 상관 없이 트랜잭션 실패를 함께 고려해야 할 것이다.

하지만 이를 모두 고려하기 위해서는 너무 많은 경우의 수가 있다.

이러한 경우의 수를 줄이는 방법중에 하나는 트랜잭션이 성공할 때만 이벤트 핸들러를 처리하는 방식이 있다.

이를 위해서 스프링은 @TransactionalEventListener 어노테이션을 지원하는데, 이는 스프링 트랜잭션 상태에 따라 이벤트 핸들러를 실행할 수 있게 해준다.

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

위의 코드에서 phase에 대한 설정을 AFTER_COMMIT 으로 하였는데, 이를 통해 트랜잭션 커밋에 성공한 뒤 핸들러 메소드를 실행한다.

만약 중간에 에러가 발생해 트랜잭션이 롤백되면 핸들러 메소드를 실행하지 않는다.