프로그래밍 노트/도메인 주도 설계(DDD)

[도메인 주도 설계] 이벤트 활용하기

깡냉쓰 2023. 8. 22. 17:30
728x90
반응형

시스템 간 강결합은 어떻게 없앨 수 있을까?

쇼핑몰에서 구매 취소를 하면 환불 처리가 되어야 한다.
이때 환불기능을 실행하는 주체는 주문 도메인이 될 수 있으며, 환불 기능을 실행하기 위해서 환불 도메인 서비스를 파라미터로 전달받아 실행할 수 있다.

class Order {
  // 외부 서비스 실행을 위해 도메인 서비스를 파라미터로 전달 받음
  fun cancel(refundService: RefundService) {
    verifyNotYetShipped()
    this.state = OrderState.CANCELED

    this.refundStatus = State.REFUND_STARTED
    try {
      refundService.refund(getPaymentId())
      this.refundStatus = State.REFUND_COMPLETED
    } catch(e: Exception)
      ???
    }
  }
}

혹은 아래와 같이 응용 서비스에서 환불 기능을 실행할 수도 있다.

class CancelOrderService {
  private refundService: RefundService

  @Transactional
  fun cancel(orderNo: OrderNo) {
    val order = findOrder(orderNo)
    order.cancel()

    order.refundStarted()
    try {
      refunsService.refund(order.getPaymentId())
      order.refundCompleted()
    } catch(e: Exception) {
      ??
    }
  }
}

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

1. 외부 서비스가 정상이 아닌 경우 트랜잭션을 어떻게 해야할지 애매하다는 것

  • 환불 기능을 실행하는 과정에서 익셉션이 발생하면 롤백/커밋 무엇을 실행해야 할까?
  • 환불에 실패했으므로 취소 트랜잭션을 롤백 하는 것이 맞아 보이나,, 주문은 취소 상태로 변경하고 환불만 나중에 다시 시도하는 방식으로 처리하는 식도 가능하다.

2. 외부 서비스 성능에 직접적인 영향을 받게 되는 것

  • 환불을 처리하는 외부 시스템의 응답 시간이 길어지면 주문 시스템의 대기시간도 그만큼 길어진다.
    • 환불 처리 30초 걸리면, 주문 취소 처리도 30초 대기

위의 두 가지 문제 외에 도메인 객체에 서비스를 전달하면 추가로 설계상 문제가 나타나게 되는데 주문 로직결제 로직이 섞이는 문제다.

class Order {
  fun cancel(refundService: RefundService) {

    // 주문 로직
    verifyNotYetShipped()
    this.state = OrderState.CANCELED

    // 결제 로직
    this.refundStatus = State.REFUND_STARTED
    try {
      refundService.refund(getPaymentId())
      this.refundStatus = State.REFUND_COMPLETED
    } catch(e: Exception)
      ???
    }
  }
}
  • Order는 주문을 표현하는 도메인 객체인데 결제 도메인의 환불 관련 로직이 뒤섞이게 된다.
    • 환불 기능이 바뀌면 Order도 영향을 받게 된다는 것을 의미
  • 기능 추가시 문제가 발생할 수 있다.
    • 만약 주문을 취소한 뒤 환불뿐만 아니라 통지도 필요하다면 통지 로직도 섞이게 된다.
    • 트랜잭션 처리가 더 복잡해지며 영향을 주는 외부 서비스가 두 개로 증가한다.
class Order {
  fun cancel(refundService: RefundService, notiService: NotiService) {

    // 주문 로직
    verifyNotYetShipped()
    this.state = OrderState.CANCELED

    // 결제 로직 + 통지 로직 섞임
    // refund 성공하고, noti 실패한다면 어떻게 처리 할지?
    // refunt, noti 중 무엇을 먼저 처리해야할지?
  }
}

이 문제가 발생하는 이유는 주문 바운디드 컨텍스트와 결제 바운디드 컨텍스트간의 강결합(high coupling)때문이다.

  • 주문이 결제와 강하게 결합되어 있어 주문 바운디드 컨텍스트가 결제 바운디드 컨텍스트에 영향을 받게 되는 것이다.
  • 이벤트를 사용하면 이 강결합을 없앨 수 있는데, 이벤트에 관해 알아보자.

이벤트(Event)를 적용해보자

이벤트 구성

이벤트 요소

  • 이벤트 생성 주체
    • 도메인 객체(엔티티, 밸류, 도메인 서비스), 도메인 로직을 실행해서 상태가 바뀌면 이벤트를 발생시킨다.
  • 이벤트 핸들러
    • 이벤트 생성 주체가 발생한 이벤트에 반응
    • 생성 주체가 발생한 이벤트를 전달받아 이벤트에 담긴 데이터를 이용해 원하는 기능을 실행 (ex. SMS 통지)
  • 이벤트 디스패처
    • 이벤트 생성 주체와 이벤트 핸들러를 연결시켜 줌

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

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

배송지를 변경할 때 발생하는 이벤트를 구현해보자.

구현1. 배송지 변경 이벤트 ShippingInfoChangedEvent

class ShippingInfoChangedEvent {
  val orderNumber: String
  val timestamp: Long
  val newShippingInfo: ShippingInfo
}
  • 클래스 이름은 과거 시제(Changed)를 사용하자. 이벤트는 현재 기준으로 과거에 벌어진 것을 표현하기 때문

구현2. 이벤트를 발생하는 주체인 Order 애그리거트

class Order {
  fun changeShippingInfo(newShippingInfo: ShippingInfo) {
    verifyNotYetShipped()
    setShippingInfo(newShippingInfo)
    // 이벤트를 발생시킨다.
    // Events.raise()는 디스패처를 통해 이벤트를 전파하는 기능을 제공
    Evernts.raise(ShippingInfoChangedEvent(number, newShippingInfo))
  }
}

구현3. ShippingInfoChangedEvent를 처리하는 핸들러 구현

  • 디스패처로부터 이벤트를 전달받아 필요한 작업을 수행
class ShippingInfoChangeHanlder {
  @EventListener(ShippingInfoChangedEvent::class)
  fun handle(evt: ShippingInfoChangedEvent) {
    val order = orderRepository.findById(evt.orderNo)
    shippingInfoSynhronizer.sync(
      order.getNumber().getValue()
      order.getShippingInfo()
    )
  }
}

이벤트 용도 및 장점

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

용도1. 트리거(Trigger)

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

이벤트 용도 - 트리거

  • 주문 취소 이벤트(트리거) 발생 -> 이벤트 핸들러에서 환불 처리 구현
  • 예매 완료 이벤트(트리거) 발생 -> 이벤트 핸들러에서 SMS 발송 방식 구현

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

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

이벤트 적용시 장점

  • 서로 다른 도메인 로직이 섞이는 것을 방지해준다.

before

class Order {
  fun cancel(refundService: RefundService) {
    verifyNotYetShipped()
    this.state = OrderState.CANCELED

    this.refundStatus = State.REFUND_STARTED
    try {
      refundService.refund(getPaymentId())
      this.refundStatus = State.REFUND_COMPLETED
    } catch(e: Exception)
      ???
    }
  }
}

After

class Order {
  fun changeShippingInfo(newShippingInfo: ShippingInfo) {
    verifyNotYetShipped()
    setShippingInfo(newShippingInfo)
    Evernts.raise(ShippingInfoChangedEvent(number, newShippingInfo))
  }
}
  • 구매 취소에 더 이상 환불 로직이 존재하지 않음
  • 환불 서비스를 실행하기 위한 파라미터도 없어짐
    • 환불 실행 로직은 주문 취소 이벤트를 받는 이벤트 핸들러로 이동됨
  • 확장도 용이해짐
    • 이메일로 취소 내용을 보내고 싶다면, 이메일 발송을 처리하는 핸들러만 구현하면 됨

이벤트 핸들러 - 기능 확장 용이

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

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

이벤트 클래스

  • 원하는 클래스를 이벤트로 사용(별도 상위 타입이 존재하지 않는다.)
  • 과거 시제 사용
abstract class Event {
  // 공통으로 갖는 프로퍼티 구현
  val timestamp = System.currentTimeMillis()
}

class OrderCanceledEvent(
  val orderNumber: String
): Event()

Events 클래스와 ApplicationEventPublisher

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

Events 클래스

class Events {
  companion object {
    private lateinit var publisher: ApplicationEventPublisher

    // Events 클래스가 사용할 ApplicationEventPublisher 셋팅
    fun setPublisher(publisher: ApplicationEventPublisher) {
      this.publisher = publisher
    }

    // ApplicationEventPublisher 이용하여 이벤트 발행
    fun raise(event: Any) {
      this.publisher.publishEvent(event)
    }
  }
}

EventsConfiguration

@Configuration
class EventsConfiguration(
    private val applicationContext: ApplicationContext
) {

    @Bean
    fun eventsInitializer(): InitializingBean {
        return InitializingBean {
            Events.setPublisher(this.applicationContext)
        }

    }
}
  • InitializingBean 타입 객체를 빈으로 설정하여 Events 클래스를 초기화한다.
  • 스프링 컨테이너는 ApplicationEventPublisher도 된다. ApplicationContext가 ApplicationEventPublisher를 구현하기 때문
public interface ApplicationContext extends EnvironmentCapable, ListableBeanFactory, HierarchicalBeanFactory,
        MessageSource, ApplicationEventPublisher, ResourcePatternResolver {

}

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

이벤트 발생

class Order {
  fun cancel() {
    verifyNotYetShipped()
    this.state = OrderState.CANCELED
    Events.raise(OrderCanceledEvent(number.getNumber())
  }
}

이벤트 핸들러(처리)

@Service
class OrderCanceledEventHandler(
  private val refundService: RefundService
){
  @EventListener(OrderCanceledEvent::class)
  fun handle(event: OrderCanceledEvent) {
    refundService.refund(event.getOrderNumber())
  }
}
  • ApplicationEventPublisher#publishEvent() 메서드에 OrderCanceledEvent 타입을 전달하면, OrderCanceledEvent::class값을 갖는 @EventListener 애너테이션을 붙인 메서드를 찾아 실행하게 됨
  • 같은 트랜잭션 범위에서 실행됨

이벤트 동기 vs 비동기 처리

이벤트 처리를 통하여 서비스간 강결합(high coupling)문제는 해결했지만 외부 서비스에 영향을 받는 문제는 아직 존재한다.

// 응용 서비스 코드
@Transactional
fun cancel(orderNo: OrderNo) {
  // 외부 연동 과정에서 익셉션이 발생하면 트랜잭션 처리는?
  val order = findOrder(orderNo)
  order.cancel() // order.cancel()에서 Exception 발생
}

// 이벤트를 처리하는 코드
@Service
class OrderCanceledEventHandler(
  private val refundService: RefundService
){
  @EventListener(OrderCanceledEvent::class)
  fun handle(event: OrderCanceledEvent) {
    // refundService.refund()가 느려지거나 Exception이 발생하면?
    refundService.refund(event.getOrderNumber())
  }
}
  • refundService.refund()가 외부 환불 서비스와 연동
    • 성능
      • 외부 환불 기능이 느려지면 cancel()메소드도 함께 느려짐
      • 외부 서비스 성능 저하 -> 시스템 성능 저하 초래
    • 트랜잭션 처리
      • refundService.refund()에서 Exception 발생
      • cancel() 롤백? 커밋?
      • 만약 롤백을 한다면 구매 취소 기능도 실패한다.
      • 외부 환불 서비스 실행이 실패하였다고 반드시 트랜잭션 롤백을 진행해야할까?
        • 구매 취소 자체는 처리하고 환불만 재처리하거나 수동으로 처리할 수 있진 않을까?

위의 문제는 이벤트를 비동기로 처리하거나 이벤트와 트랜잭션을 연계하여 해결할 수 있다. 우리는 비동기 이벤트 처리에 대해 알아보자

비동기 이벤트 처리

우리가 구현해야하는 A하면 이어서 B를 해라 라는 요구사항은 실제로 A하면 최대 언제까지 B하라 인 경우가 많다. (모두 그렇지는 않지만..)
이 말은 즉슨, B를 하다가 실패하여도 일정 간격으로 재시도 혹은 수동 처리가 가능하다는 말이다.
대표적인 예가 회원 가입 신청시 이메일 검증을 하는 것이다. 회원 가입 신청 시점에 이메일 발송이 실패하여도 사용자는 이메일 재전송 요청을 이용하여 수동으로 인증 이메일을 다시 받아볼 수 있다.

  • 요구사항 : 회원가입신청을 하면 인증 이메일을 보내라
  • 이벤트 : 회원 가입 신청
  • 이벤트 핸들러 : 인증 이메일을 보냄

우리는 A하면 최대 언제까지 B하라라는 요구사항이 있을 때 비동기 처리(별도 스레드)로 핸들러를 구현할 수 있다.

비동기 이벤트 처리는 아래 네 가지 방식으로 구현할 수 있다.

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

1. 로컬 핸들러를 비동기로 실행

  • 이벤트 핸들러를 별도 쓰레드로 실행하는 것
  • @Async 어노테이션을 활용하거나, 코루틴을 활용할 수 있겠다. (하지만 코루틴에 대해 잘 모른다..ㅜ)
    • config에 @EnableAsync 추가
    • hanlder에 @Async 추가
@Service
class OrderCanceledEventHandler(
  private val refundService: RefundService
){
  @Async
  @EventListener(OrderCanceledEvent::class)
  fun handle(event: OrderCanceledEvent) {
    refundService.refund(event.getOrderNumber())
  }
}

2. 메시지 큐 이용

  • 카프카(kafka)나 래빗MQ(RabiitMQ) 이용

메시징 큐를 이용한 비동기 처리

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

3. 이벤트 저장소를 이용

  • 이벤트를 DB에 저장한 뒤 별도 프로그램을 이용하여 핸들러에 전달하는

3-1. 이벤트 포워드 사용

이벤트 저장소 - 포워드 사용

  • 핸들러가 스토리지에 이벤트 저장(도메인 상태 변환
  • 포워드는 주기적으로 이벤트 저장소에서 이벤트를 가져와 이벤트 핸들러 실행(별도 스레드)
    • 스프링 @Scheduled로 구현이 가능하다. (offset을 저장해야 함)
    • 서버가 여러대라면 동기화 관련해서 생각해보긴 해야할 것 같다.
  • 이벤트 핸들러가 처리에 실패할 경우 포워더는 다시 DB에서 이벤트를 읽어와 핸들러를 실행할 수 있다.
/api/events?offset=0&limit=5
- 5개 이벤트 제공
/api/events?offset=5&limit=5
- 3개 이벤트 제공
/api/events?offset=8&limit=5
- 0개 이벤트 제공
/api/events?offset=8&limit=5

3-2. 이벤트 제공 API 사용

이벤트 저장소 - API 사용

  • 이벤트 포워드와 동일하나, 포워더가 이벤트를 외부에 전달하지만 API 방식은 외부 핸들러가 API를 이용하여 이벤트 목록을 가져간다.
  • 이벤트 추적은 포워더가 아니라 외부 핸들러에서 이루어진다.

이벤트를 적용할 때 추가적으로 고려할 사항에는 무엇이 있을까

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

이벤트 처리를 동기 혹은 비동기로 처리하여도, 이벤트 처리 실패와 트랜잭션 실패를 함께 고려할 수 밖에 없다.
이벤트 실패, 트랜잭션 실패를 모두 고려하면 복잡해지므로 가장 좋은 방법은 경우의 수를 줄이는 것이다.

트랜잭션이 성공할 때만 이벤트 핸들러를 실행하자.

스프링에서는 @TransactionEventListener 애너테이션을 지원하는데 이 애너테이션을 사용하면 트랜잭션 상태에 따라 핸들러 실행을 할 수 있게 한다.

@TransactionEventListener(
  classes = OrderCanceledEvent::class,
  phase = TransactionPhase.AFTER_COMMIT
)
fun hanlde(evt: OrderCanceledEvent) {
  refundService.refund(evt.getOrderNumber())
}
  • 트랜잭션 커밋이 성공한 뒤 핸들러 메서드를 실행시킨다.
  • 중간에 에러가 발생해서 트랜잭션 롤백 되면 핸들러 메서드를 실행하지 않는다.
    • 이벤트 핸들러를 실행했는데 트랜잭션이 롤백 되는 상황은 발생하지 않는다.
  • 이렇게되면 이벤트 처리 실패만 고려하면 된다. 이벤트 특성에 따라 재처리 방식을 결정하거나 하자

데이터 불일치 케이스

case1. 동기 처리 방식

  • 주문 취소
  • 환불 요청 외부 API (성공)
  • 주문 취소 DB UPDATE (실패)
  • 환불 요청은 완료 / 주문 취소는 실패로 데이터 불일치

case2. 비동기 처리 방식

  • 주문 취소
  • 주문 취소 DB UPDATE (성공)
  • 별도 쓰레드로 환불 요청 외부 API (실패)
  • 환불 요청은 실패 / 주문 취소는 성공으로 데이터 불일치

이벤트 처리시 고려할 점

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

  • 포워더에서 이벤트 전송에 실패하면 실패 이벤트부터 다시 읽어와 전송을 시도
  • 특정 이벤트에서 계속 전송 실패가 발생한다면? 남머지 이벤트도 전송이 불가한 상황이 옴
    • 최대 전송 횟수를 정하고 다음 이벤트로 넘어가는 정책 필요
    • 실패한 경우 DB나 메시지 큐에 저장한다면 분석 혹은 후처리에 도움이 된다.

2.이벤트 손실을 어떻게 처리할 것인지

  • 이벤트 저장소를 사용하면 이벤트 발생과 이벤트 저장을 한 트랜잭션으로 묶이기 때문에 문제가 없음
  • 로컬에서 이벤트를 비동기로 처리할 경우 이벤트 처리에 실패하면 이벤트가 유실됨(유실되도 문제 없을 때만 이용)

3. 이벤트 순서는 상관이 없는지

  • 이벤트 발생 순서대로 외부 시스템에 전달해야하는 경우, 이벤트 저장소를 이용하자
    • 이벤트 저장소에서 이벤트 발생 순서를 저장하고 목록을 제공하면 순차적으로 처리가 가능하다.
  • 메시징 시스템은 이벤트 발생 순서와 메시지 전달 순서가 보장되지 않는다.

4. 이벤트 재처리는 어떻게 할 것인지

  • 동일 이벤트를 재처리할 경우 어떻게 할지?
  • 이벤트 순번을 기억해두었다가 이미 처리한 순번이 도착하면 무시하는 방식을 사용할 수 있음
  • 혹은 멱등으로 처리
    • 동일 이벤트를 한 번 적용하나 여러 번 적용하나 시스템이 같은 상태가 되도록 구현하면 동일 이벤트 중복처리를 방지할 수 있음
    • 중복 처리에 대한 부담담에서 벗어나자

 

출처) 도메인 주도 개발 시작하기 - 최범균

728x90
반응형