IT/기초 지식

Domain 이벤트 (도메인 이벤트)

개발자 두더지 2026. 3. 31. 21:46
728x90

이 포스트는 일본의 한 블로그 글을 번역한 포스트입니다. 오역 및 직역, 의역이 있을 수 있으며 틀린 내용은 지적해주시면 감사하겠습니다.

 

 

시작하기에 앞서


 도메인 이벤트는 도메인 중심 설계에서 사용되는 설계 패턴 중 하나로, 도메인 이벤트 자체는 단순한 개념이지만, 여러 문맥에 사용되기 때문에, 좀처럼 이해가 어려운 부분이 있다. 따라서 이번 기회를 통해 관련 내용을 정리하고자 한다.

 

 

Domain 이벤트란?


 이벤트는 "과거에 발생한 사건"이며 도메인 이벤트는 "비즈니스 도메인에서 발생한 중요한 사건을 나타내는 메시지"이다(예: 주문이 할당되었으나 주문이 취소됨). 도메인 이벤트는 시스템의 상태 변경(=집약 상태의 변화)을 나타내며, 일반적으로 집약이 도메인 이벤트의 출처가 된다.

 

 

용도 


 도메인 이벤트는 주로 다음과 같은 목적으로 사용된다.

 

1. 이벤트 발생을 기점으로, 다른 처리에 대한 트리거가 된다.

 도메인 이벤트는 시스템의 다른 부분간 연동을 위해 사용된다.

도메인 요구사항으로 '...하고 싶은 경우...'라는 문구가 나오면 도메인 이벤트의 사용에 적합한 부분일지도 모른다.

  • 여러 집약간의 무결성을 가져옵니다(예: 주문이 취소된 경우 재고 반환)
  • 외부 시스템과의 연계(예: 신규 예약이 들어오면 메일 송신, 외부 API 호출)
  • 로그 기록
  • etc.

장점은 다음과 같습니다.

  • 이벤트의 발생원이 되는 처리와 관련되어 연동되는 처리를 분리할 수 있다(관심의 분리)
  • 기존 코드를 변경하지 않고 이벤트에 반응하는 새로운 처리를 추가할 수 있다(확장성).

이 포스트에서는 이 내용을 중심으로 구현 방법을 설명하려고 한다.

 

2. 도메인 이벤트 기록 및 활용

도메인 이벤트를 지속하고 축적함으로써 현재 상태에 이르는 과정을 추적할 수 있다.

  • 감사 추적
  • 조사 및 디버깅
  • 사용자 활동 분석

 

3. 이벤트를 정보원으로 한 상태 관리(이벤트 소싱)

이벤트 소싱 은 시스템 상태를 이벤트의 연속으로 저장하는 설계 패턴이다. 도메인 개체의 상태를 직접 저장(=상태 소싱)하는 대신 발생한 모든 이벤트를 순차적으로 기록하고 해당 이벤트를 재생하여 현재 상태를 다시 작성한다. 이러한 이벤트 소싱은 종종 CQRS와 함께 사용된다.

 

4. 명령 및 쿼리 책임 분리(CQRS)

CQRS(Command Query Responsibility Segregation) 는 명령(쓰기 작업)과 쿼리(읽기 작업)를 분리하는 아키텍처 패턴이다. 집약(명령 모델)에 대한 변경사항을 이벤트로 알리고, 이를 수신한 이벤트 핸들러가 읽기 전용 뷰(쿼리 모델)를 구축하여 쓰기 및 읽기 각각에 최적화된 모델 및 아키텍처 특성을 가질 수 있게 할 수 있다.

 

5. 이벤트를 중심으로 한 비즈니스 도메인 분석 및 모델링 (이벤트 스토밍)

시스템 구현과 직접 관련이 있는 것은 아니지만 도메인 이벤트를 중심으로 비즈니스 도메인을 분석하는 기법으로 이벤트 스토밍 이 있다. 이는 비즈니스 도메인에 대한 이해를 높이고 공유하고 모델링과 디자인에 활용하는 데 사용된다.

도메인 이벤트는 비즈니스 도메인에서 일어나는 중요한 결과이며 여기에서 거슬러 올라가 전체를 탐색해 나간다(이벤트 → 이벤트를 일으킨 명령 → 명령 실행 주체(액터, 정책) → 액터가 의사결정을 위해 참조한 리드 모델).

 

 

도메인 이벤트를 처리하기 위한 구현


도메인 이벤트를 생성해, 바운디드 컨텍스트내에서 처리하기 위해서 필요한 구현을 소개하도록 하겠다.

  • 도메인 이벤트 발생 ~ 소비는 동기화 혹은 비동기.
  • 일반적으로 도메인 이벤트는 발생한 바운디드 컨텍스트 내에서 소비.
    • 문맥 밖에 통지하고 싶은 경우는, 공개용으로 변환·정형한 후에 ​​통지하는 것이 일반적.

 

1. 도메인 이벤트 설계

도메인 이벤트는 집약에 대한 작업(명령)의 결과로 발생한다. 예를 들어, "주문"이라는 집약에 대한 "취소" 작업은 집약 상태를 취소됨으로 변경하고 "주문이 취소됨"이라는 도메인 이벤트를 발생시킨다. 우선은 도메인 이벤트를 설계, 구현해보자.

/**「주문이 취소되었다」이벤트 */
data class OrderCancelled(
  // 일어난 일을 설명하기 위한 정보를 속성으로 가진다.
  val orderId: OrderId,
  val reason: String,
  val occurredAt: Instant,
  ...
) : DomainEvent

포인트:

  • 과거에 발생한 사건이기 때문에 과거형으로 명명(예: OrderCancelled, ItemShipped)
  • 비즈니스 도메인 단어에서 어떤 일이 발생했는지 이름에 정확하게 반영
  • 과거에 발생한 사건이기 때문에 이뮤터블
  • 도메인 이벤트는 도메인 모델의 일부

도메인 이벤트에 제공하는 정보

도메인 이벤트에는 도메인에서 무슨 일이 일어났는지 설명하는 완전한 정보가 존재한다.

  • 이벤트를 설명하는 데 필요한 모든 정보가 존재.
  • 이벤트가 암시적으로 의존하는 외부 정보 포함.
    • 예를 들어, 세금 포함 가격을 계산하고 기록하는 이벤트라면, 그 계산에 사용한 소비세율의 스냅샷도 포함한다(장래 세율이 바뀌어도 계산 결과를 재현할 수 있도록)
  • 그 외, 구독자가 필요로 하는 정보도 포함할 수도 있다.
  • 위의 내용을 충족시킨 후 필요한 최소한의 정보를 유지.

모든 도메인 이벤트에 공통된 정보

모든 도메인 이벤트에 공통적으로 가져야 하는 정보가 있습니다.

  • 집약 ID (필수) ... 이벤트는 집약에서 발생하므로 집약 식별자를 가짐.
  • 이벤트 발생 시간 (필수) ... 과거의 이벤트이므로 발생 시간이 있음.
  • 이벤트 식별자 ... 비동기로 처리 할 때 이벤트 중복을 제외하는 데 유용함 (아래 참조).
  • 집계 버전 ... 이벤트를 지속할 때 낙관적 배타적 제어를 수행하거나 이벤트 순서를 보장하는 데 사용함.

이러한 항목은 공통 인터페이스에 정의하는 것이 좋다.

 

2. 도메인 이벤트 생성

집약이 도메인 이벤트를 생성하고 발행하는 구현 방법으로 다음 세 가지 패턴이 존재한다.

패턴 1. 집약 자체가 Event Publisher를 사용하여 도메인 이벤트를 발행

집약 자체가 도메인 이벤트를 발행한다. 실천 도메인 구동 설계 등에서 소개되고 있는 패턴이다.

class Order {
  fun cancel(reason: String) {
    check(canCancel())
    // 집약의 상태를 변화시킴
    this.status = OrderStatus.CANCELLED
    this.cancelReason = reason

    // 이트를 생성하고 발행함
    val event = OrderCancelled(id, reason, ...)
    DomainEventPublisher.publish(event)
  }
}

(여기서는 DomainEventPublisher이벤트를 받아 디스패치하는 부품으로 사용하고 있다. 구현에 대해서는 후술하도록 하겠다)

이 패턴은 구현은 간단합니다만, 아래의 문제가 있기 때문에 남용해서는 안된다.

  • 집약이 이벤트 발행 처리에 의존하기 때문에 안정성과 시험 가능성이 떨어짐
  • 즉시 디스패치되므로 집약을 지속하기 전에 핸들러가 이벤트를 처리할 수 있음

패턴 2. 집약이 도메인 이벤트를 유지하고 나중에 검색하고 게시

이 패턴이 가장 자주 소개되는 패턴이다 (Jimmy Bogard의 A better domain events pattern 등).

class Order {
  // 집약내에서 발생한 미발행의 이벤트를 보유할 필드
  // 공통의 기저 클래스를 가지도록 해도 좋을 것이다
  private val events = mutableListOf<DomainEvent>()

  fun cancel(reason: String) {
    check(canCancel())
    this.status = OrderStatus.CANCELLED
    this.cancelReason = reason

    // 생성한 이벤트를 events에 추가
    // 이 시점에서는 이벤트는 디스패치되지 않는다
    val event = OrderCancelled(id, reason, ...)
    events.add(event)
  }

  fun events(): List<DomainEvent> { // 외부에서 꺼낼 수 있도록 해둠
    return events.toList()
  }
}

// Use case
fun cancelOrder(orderId: OrderId, reason: String) {
  val order = orderRepository.findById(orderId)
  order.cancel(reason)
  orderRepository.save(order)

  // 생성된 이벤트를 발행
  // 리포지터리의 구현 내에 이를 구현하는 사람들도 있다(리포지터리의 책임 범위 밖이지만)
  domainEventPublisher.publish(order.events())
}

패턴 3. 집계 명령이 이벤트를 반환합니다.

집약에 대한 조작이 이벤트를 리턴하게 함으로써, 이벤트의 발행을 명시적으로 실시할 수 있다.

class Order {
  fun cancel(reason: String): List<DomainEvent> {
    check(canCancel())
    this.status = OrderStatus.CANCELLED
    this.cancelReason = reason

    // 이벤트를 생성하고 반환
    val event = OrderCancelled(orderId, reason, ...)
    return listOf(event)
  }
}

// Use case
fun cancelOrder(orderId: OrderId, reason: String) {
  val order = orderRepository.findById(orderId)
  val events = order.cancel(reason)
  orderRepository.save(order)

  domainEventPublisher.publish(events) // 이벤트를 발행
}

이 패턴은 집약의 설계를 이뮤터블로 하는 경우에도 사용할 수 있다.

// Use case
fun cancelOrder(orderId: OrderId, reason: String) {
  val order = orderRepository.findById(orderId)
  val (cancelledOrder, events) = order.cancel(reason) // 변경 후의 집약과 이벤트를 반환
  orderRepository.save(cancelledOrder)

  domainEventPublisher.publish(events)
}

 

3. 이벤트 처리

DomainEventPublisher에서 발행된 이벤트를 구독하고 처리하는 구현 예를 살펴보자. 먼저 도메인 이벤트를 처리하기 위한 인터페이스로 DomainEventHandler정의한다.

interface DomainEventHandler<T : DomainEvent> {
  fun eventType(): KClass<T> // 이 핸들러가 이벤트 형으로 반환
  fun handle(event: T) // 이벤트를 처리
}

다음에 이것을 구현한 클래스를 작성한다.

class SendEmailWhenOrderCancelledHandler : DomainEventHandler<OrderCancelled> {
  override fun eventType() = OrderCancelled::class
  override fun handle(event: OrderCancelled) {
    // 이벤트에 반응하여 행할 액션을 기재
    // 이벤트 핸들러는 어플리케이션 층의 주인이며, 리포지터나 서비스를 이용하는 것이 가능
  }
}

명명은 도메인 이벤트: 디자인 및 구현 - .NET | Microsoft Learn 예제를 따른다. ( ${무엇을 할것인가}When${무엇이일어났을때}Handler) 하나의 이벤트에 대해 수행하려는 여러 작업이 있는 경우 일반적으로 처리기를 여러 개 만든다. (단일 책임의 원칙, 개방 폐쇄의 원칙)

 

4-1. 이벤트의 동기 디스패치

마지막으로 발생한 이벤트를 핸들러에 디스패치하는 DomainEventPublisher구현한다. 이벤트의 디스패치를 ​​동기적으로 실시하는 경우와 비동기적으로 실시하는 경우가 있다.

우선은 동기적인 디스패치의 구현 예는 다음과 같다.

class DomainEventPublisher {
  // 이벤트 타입마다 핸들러를 보유한다
  private val handlers = mutableMapOf<KClass<out DomainEvent>, List<DomainEventHandler<out DomainEvent>>>()

  // 이벤트 핸들러를 등록한다
  fun subscribe(handler: DomainEventHandler<out DomainEvent>) {
    val eventType = handler.eventType()
    handlers[eventType] = handlers.getOrDefault(eventType, emptyList()) + handler
  }

  // 발생한 이벤트 타입에 따라 핸들러에 디스패치한다
  // 이벤트에 대응할 핸들러를 순서대로 동기적으로 호출한다
  fun publish(events: List<DomainEvent>) {
    events.forEach { event ->
      handlers[event::class]?.forEach { handler ->
        (handler as DomainEventHandler<DomainEvent>).handle(event)
      }
    }
  }
}

이벤트 핸들러는 애플리케이션이 시작될 때 등록된다. DI 컨테이너를 사용하는 경우 DI 컨테이너에 등록된 모든 핸들러를 자동으로 등록하는 것이 편리하다.

fun registerDomainEventHandlers() { // 어플리케이션 기동시에 호출한다
  val handlers = applicationContext.getBeansOfType(DomainEventHandler::class.java) // Spring Framework の例
  handlers.values.forEach { handler -> domainEventPublisher.subscribe(handler) }
}

 

4-2. 이벤트의 비동기 디스패치

동일한 컨텍스트 (동일한 시스템) 내에서도 이벤트를 비동기적으로 처리하고 싶을 수 있다.

  • 이벤트를 소비하는 측의 처리를 기다리지 않기 때문에 응답성이 향상
  • 이벤트를 소비하는 쪽이 다운되어도 처리를 계속할 수 있으므로 가용성이 향상
  • 트랜잭션이 작아지고 데이터베이스 독점 제어 충돌이 삭감

한편, 이벤트가 확실하게 처리되도록(듯이) 하기 위해서는 복잡한 구현이 필요하게 된다 (이벤트 중복, 순서 보증, 오류 처리, 보상 조치 등)

결과 무결성 정보

이벤트를 비동기적으로 처리하면 이벤트가 발생한 후 이벤트 핸들러 측 처리가 완료될 때까지 시스템 상태에 일시적으로 일관성이 없는 상태가 발생한다 (예: 상품 구입 처리와 포인트 부여 처리가 비동기로 행해지는 경우, 구입했는데 포인트가 미부여 상태가 일시적으로 발생).

이러한 일시적인 불일치를 허용하고 시간이 지남에 따라 결국 일관성을 담보하는 설계를 결과 무결성이라고한다.
참고로 반대되는 단어는 트랜잭션 무결성 이며 항상 무결성을 유지한다.

  • 일관성 지연은 허용되지만 일관성 자체를 생략하는 것은 아니다.
  • 무결성이 취해질 때까지의 지연을 어느 정도 허용하는지는 비즈니스 요건에 따른다.
  • 결과 무결성을 확실하게 담보하기 위한 구현은 꽤 어렵다.
 

Outbox 패턴 + 메시지 버스를 통한 이벤트 전달

Producer - Consumer간을 결과 무결성으로 한다고 해도, 「이벤트의 발생원(=집약)의 상태의 영속화」와 「이벤트가 공개되는 것」은 확실히 정합할 필요가 있다. (한쪽만 성공하는 것은 안되기 때문)

이 경우 Outbox 패턴을 사용하는 것이 일반적이다. (다른 방법으로는 도메인 이벤트만을 영속화하고 집약은 이벤트에서 재구성하는 방법도 있다)

Outbox 패턴을 사용하면 집계를 저장하는 것과 동일한 트랜잭션에서 알림을 받고 싶은 이벤트를 일시적으로 유지하고 다른 프로세스가 이벤트를 가져와서 알리게 된다.

  1. 알리고 싶은 이벤트를 영속화하기 위한 테이블(Outbox 테이블)을 준비한다
  2. 동일한 트랜잭션 내에서 집약 지속성 및 이벤트 지속성을 수행하고 트랜잭션 커밋
  3. 다른 프로세스(메세지 릴레이)가 Outbox 테이블을 모니터링하고 새 이벤트를 검색한다.
  4. 메세지 릴레이는 취득한 이벤트를 메시지 버스(Kafka, RabbitMQ, etc.)에 투입한다
  5. 성공하면 메세지 릴레이는 Outbox 테이블에서 이벤트를 삭제하거나 게시됨으로 표시한다.

5에서 실패하면 3-4가 다시 실행되므로 이벤트가 두 번 이상 전송될 수 있다(At-least-once)

메시지 릴레이 구현

Outbox 테이블에 기록된 새 이벤트를 검색하는 방법에는 두 가지가 있다.

  • 폴링 ... 정기적으로 Outbox 테이블에 액세스하여 새 이벤트를 검색.
  • CDC(Change Data Capture) ... 어떠한 방식으로 데이터 변경 통지를 받음(DB에 따라서는 할 수 없는 경우도 존재)
    • 셀프 호스팅된 RDB라면 Debezium 등의 툴을 사용
    • AWS의 경우,
      • DynamoDB라면 DynamoDB Streams로 구현 가능
      • PostgresSQL이면 논리 복제를 사용하여 구현 가능

이벤트 수신자 구현

메세지 버스로부터 이벤트를 취득해 핸들러에 건네줍다. 핸들러의 구현은 기본적으로 동기적인 경우와 동일하다. 그러나 이벤트 알림이 비동기화되어 다음과 같은 문제가 발생할 수 있으므로 이를 해결해야 한다.

  • 이벤트가 중복되어 도착
  • 이벤트가 순서에 따라 도착
  • 핸들러가 이벤트 처리에 실패함

이벤트 중복 제거

일반적으로 이벤트는 적어도 한 번(At-least-once) 통지된다(메시지 버스나 Outbox가 배송 과정에서 잠재적인 재시도를 수행하기 때문에). 따라서 받은 이벤트의 중복을 제거하는 메커니즘이 필요하다.

  • 이벤트 핸들러의 처리를 멱등으로 설계해, 같은 이벤트가 복수회 처리되어도 결과가 변하지 않도록 한다
  • 각 이벤트에 고유 식별자를 부여하고 처리된 이벤트 ID를 기록합니다. 중복 이벤트가 도착하면 무시하도록 말이다.

이벤트 순서 보증

이벤트를 비동기적으로 알리는 경우 이벤트가 소비자에게 도달하는 순서가 발생 순서와 일치하지 않을 수 있다. 기본적으로 동일한 집약에 관한 이벤트는 발생순으로 처리되도록 한다.

  • 메시지 버스 순서 보증 기능 사용
    • 예: Kafka 파티션(파티션 키를 집계 ID로 설정)
  • 수신자의 이벤트 순서 재정렬

오류 처리

이벤트 수신 측에서 예기치 않게 오류가 발생할 수 있습니다. (네트워크 오류 등)
일반적으로 다음과 같은 접근 방식을 취합니다. 구현 방법은 메시지 버스에 따라 다릅니다.

  • 자동 재시도(재시도 간격을 지수 백오프로 조정)
  • 오류가 발생한 이벤트를 교착 상태 큐로 이동하고 나중에 다시 처리

보상 액션

이벤트 핸들러가 어떤 이유로 처리를 완료하지 못할 수 있다. 예를 들면 주문 이벤트에 응답하여 결제를 수행하는 처리기에서 지정된 결제 방법으로 결제할 수 없는 경우 등 말이다.

이벤트는 이미 발생한 것을 설명하며 이벤트가 없었던 걸로 할 수는 없다. 이벤트를 뒤집는 유일한 방법은 이벤트의 효과를 상쇄하기 위한 추가 조치를 수행하는 것 이다. 앞의 예에서 '주문 취소 및 재고 되돌리기'와 같은 작업을 수행하는 보상 작업이 가능하다.

 

도메인 이벤트를 컨텍스트 외부에 알리기(통합 이벤트)

여러 컨텍스트의 연동을 위해 도메인 이벤트를 바운디드 컨텍스트 외부에 알리고 싶을 수 있다. 이벤트를 문맥내에 통지하는 경우와는 몇 가지 다른 점이 있다.

도메인 이벤트를 그대로 외부에 공개하는 것

우선 도메인 이벤트를 외부에 그대로 공개해도 좋은가 하는 문제가 있다.

  • 도메인 이벤트는 도메인 모델의 일부이며 바운디드 컨텍스트의 내부 표현이다.
    • 외부에 공개하면 캡슐화가 무너진다
    • 내부 표현과 외부 시스템이 결합하면 이벤트 구조 변경이 어려워진다
  • 외부와의 협력에 필요한 정보와 도메인 이벤트 정보가 일치하지 않을 수 있다.

이러한 이유로 일반적으로 도메인 이벤트를 그대로 외부에 게시하는 것은 피해야 하지만, 책이나 기사에 따라서는 그대로 공개하는 실장예를 소개하고 있는 것도 있다.

변환하고 공개하는 구현

도메인 이벤트를 외부 컨텍스트로 전달하기 전에 게시 이벤트로 변환한다. (이후 통합 이벤트( Integration Event )라고 한다.)
변환에는 상태 비저장 변환과 상태 저장 변환이 있다.

  • 스테이트리스 변환(=도메인 이벤트만을 출처로 한다)
    • 불필요한 메시지와 속성을 생략
      • 컨텍스트 간의 우발적인 결합을 피하기 위해
      • 보안을 위해
    • 연계에 적합한 형식이나 명칭으로 변환
  • 상태 저장 변환 (= 과거 도메인 이벤트 및 다른 데이터 스토어에서 얻은 정보 사용)
    • 여러 이벤트 집계 및 통합
    • 리포지토리에서 얻은 정보 추가

관련 예나 블로그 포스트는 다음과 같다.

  • 도메인 이벤트: 디자인 및 구현 - .NET | Microsoft Learn 은 도메인 이벤트를 통합 이벤트(Integration Event)로 변환하여 외부에 게시한다.
    • 이 변환은 DomainEventHandler에서 수행하고 통합 이벤트 전송 서비스에 전달한다.
    • 속성은 도메인 이벤트와 동일하지 않으며 필요에 따라 추가 및 삭제된다.
  • Learning Domain-Driven Design 에서는 도메인 이벤트를 공개 언어로 변환하여 외부에 게시한다.
    • 도메인 이벤트를 전달하기 전에 프록시를 통해 변환
      • 스테이트리스 모델 변환
      • 상태 저장 모델 변환
    • 도메인 이벤트 및 기타 이벤트를 구분하는 것이 좋다.
      • Event notification ... 사건의 발생을 통지하지만, 상세 정보는 가지지 않는다. 자세한 정보가 필요한 경우 소비자로부터 명시적으로 문의
      • Event-carried state transfer ... 컨텍스트간에 데이터 복제를 동기화하기 위해 업스트림 컨텍스트의 데이터 변경을 다른 컨텍스트로 전송
      • Domain event ... 도메인 내에서 발생한 사건을 설명하는 메시지

그대로 공개하는 구현

이벤트를 변환하는 데 어려움이 있다면 여기를 선택하는 것도 가능하다. 다만 위에서 언급한 문제가 있으므로 주의가 필요가 있다.

  • 실습 도메인 구동 설계 에서는 JSON 직렬화된 도메인 이벤트를 외부에 통지하는 예가 소개되어 있다
    1. 시스템 내에서 발생한 모든 도메인 이벤트를 받는 핸들러 만들기
    2. 이벤트 핸들러는 수신한 이벤트를 JSON으로 직렬화하여 이벤트 스토어(RDB의 1 테이블)에 영속화
    3. 이벤트 스토어에 저장된 이벤트는 다음 방법 중 하나로 외부에 통지
      • 이벤트를 얻기 위해 RESTful API를 공개하고 외부 서비스에서 폴링
      • 메시징 미들웨어로 전송
  • 마이크로서비스 패턴 에서도 도메인 이벤트를 외부에 알리는 예가 소개되어 있다.
    • 원래 도메인 이벤트를 외부와의 연계를 위한 것으로 설계하고 있다
    • '이벤트 인리치먼트'라고 하여 외부 서비스가 요구하는 추가 정보를 도메인 이벤트에 포함시키는 설계를 소개하고 있다.

 

통합 이벤트 설계

도메인 이벤트는 "도메인상의 사건을 정확하게 설명하는 과부족한 정보"라는 관점에서 설계된다. 한편, 통합 이벤트는 바운디드 컨텍스트의 공개 I/F의 일부이며, 「외부 컨텍스트와의 제휴를 위한 계약」이라고 하는 성질이 있다.

도메인 이벤트 통합 이벤트
도메인의 사건을 설명하는 정보 외부 컨텍스트와의 연계를 위한 계약
경계화된 컨텍스트 내에서 소비 경계가 붙은 컨텍스트 외부에 게시
이벤트 처리는 동기화 또는 비동기일 수 있습니다. 항상 비동기적으로 처리됨

계약으로서의 통합 이벤트

함수형 도메인 모델링 에는 두 가지 컨텍스트가 계약에 동의하는 패턴으로 다음 세 가지가 있다.

  • 공유 커널 관계 ... 컨텍스트간에 도메인 디자인을 공동 소유한다. 이벤트의 정의는 다른 공동 소유자와의 협의에 기초.
  • 고객 / 공급자 관계 ... 다운 스트림 컨텍스트가 업스트림 컨텍스트에 대해 원하는 계약을 정의한다 ( 소비자 중심 계약 ). 이 계약을 충족하는 한 각 컨텍스트는 독립적으로 발전할  수 있다.
  • 순응자 관계 ... 하류 컨텍스트는 업스트림 컨텍스트가 제공하는 계약을 수락합니다.

소비자 구동 계약은 이벤트뿐만 아니라 마이크로 서비스 간의 I / F 설계에 사용되는 일반적인 패턴이다.계약을 끊지 않았음을 소비자와 공급자 모두가 테스트하여 통신이 끊어지지 않도록 보장할 수 있다. 계약을 테스트하는 도구로 Pact 가 있다.

스키마 진화

이벤트의 스키마는 비즈니스 요구 사항의 변경 등에 따라 변경된다. 이벤트의 생산자와 소비자가 서로 독립적으로 진화할 수 있고 모든 시점에서 중단없이 협력할 수 있도록 하려면 스키마 호환성을 고려해야 한다.

호환성 유형:

  • 정방향 호환성 : 새 스키마 데이터를 이전 스키마로 읽을 수 있음
    • 생산자 측에서 새 스키마 이벤트를 제출해도 소비자 측은 코드를 변경할 필요가 없음.
    • 소비자는 새로운 스키마 정보가 필요한 경우에만 코드를 수정.
  • 역호환성 : 이전 스키마 데이터를 새 스키마로 읽을 수 있음
    • 스키마가 미리 정의된 경우 소비자는 생산자보다 먼저 코드 업데이트를 릴리스할 수 있음.
    • 이전 이벤트를 재처리해야 하는 경우에 대응 가능.
  • 완전 호환성 : 전방 호환성과 후방 호환성 모두
    • 가능한 한 목표로 삼아야함.
    • 호환성 요구 사항을 나중에 푸는 것은 쉽지만 엄격하게하기는 어려움.

파괴적 변경이 필요한 경우에는 오래된 이벤트와 새 이벤트를 모두 지원하고 마감일을 사용하여 이전 이벤트를 폐지하는 것이 일반적이다.

 

통합 이벤트로 변환 및 전송

도메인 이벤트의 경우와 달리 통합 이벤트는 항상 비동기적으로 통지 및 처리된다. 통합 이벤트를 처리하는 메커니즘은 도메인 이벤트의 메커니즘을 기반으로 구축한다.

패턴 1. 도메인 이벤트 핸들러에서 통합 이벤트로 명시적으로 변환 및 전송

도메인 이벤트를 받은 핸들러가 통합 이벤트로 변환하여 전송한다.

  1. 대상 도메인 이벤트를 구독하는 핸들러 만들기
  2. 1. 핸들러 내에서 도메인 이벤트에서 통합 이벤트로 변환
  3. 2. 통합 이벤트를 Outbox 패턴 및 메시지 버스를 사용하여 비동기 적으로 전송
// OrderCancelled 이벤트를 통합 이벤트로 변환 송신하는 핸들러
class PublishIntegrationEventWhenOrderCancelledHandler(
  private val integrationEventPublisher: IntegrationEventPublisher
) : DomainEventHandler<OrderCancelled> {
  override fun eventType() = OrderCancelled::class

  override fun handle(domainEvent: OrderCancelled) {
    // 통합 이벤트로 변환
    // - 연계를 통해 정보를 변환한다
    // - 불필요한 정보는 생략한다
    // - 리포지터리를 사용하여 추가 정보를 획득한다
    // - 여러 이벤트를 집약, 통합
    val integrationEvent = OrderCancelledIntegrationEvent(
      domainEvent.orderId,
      ...
    )
    // IntegrationEventPublisher는 Outbox 패턴 & 메세지를 이용한 비동기적으로 이벤트를 송신한다
    // (도메인 이벤트를 비동기로 디스패치할 경우와 같이 동일한 기술을 사용하여 구현한다)
    integrationEventPublisher.publish(integrationEvent)
  }
}

위 예제에서는 도메인 이벤트를 개별적으로 처리하지만 모든 도메인 이벤트를 중앙 집중식으로 처리하는 핸들러를 만들 수도 있다. 단일 책임의 원칙에 반하는 것 같지만 편한 방법이다.

class PublishIntegrationEventHandledHandler(
  private val integrationEventPublisher: IntegrationEventPublisher
) : DomainEventHandler<DomainEvent> {
  override fun eventType() = DomainEvent::class

  override fun handle(domainEvent: DomainEvent) {
    val integrationEvent = when (domainEvent) {
      is OrderCancelled -> OrderCancelledIntegrationEvent(
        domainEvent.orderId,
        ...
      )
      is OrderShipped -> OrderShippedIntegrationEvent(
        domainEvent.orderId,
        ...
      )
      ...
    }
    integrationEventPublisher.publish(integrationEvent)
  }
}

패턴 2. 이벤트 변환 레이어 배치

도메인 이벤트를 비동기적으로 보내는 메커니즘이 있는 경우 어딘가에서 이벤트 흐름을 가로채고 통합 이벤트로 변환할 수 있다. 이벤트 변환 레이어를 배치하는 장소로는 다음과 같은 후보들이 있다.

  • Outbox 테이블에서 도메인 이벤트를 검색한 직후 통합 이벤트로 변환하여 메시지 버스에 입력
  • 메시지 버스에 투입된 도메인 이벤트를 검색하여 통합 이벤트로 변환한 후 다시 메시지 버스에 투입
  • 메시지 버스에 따라서는 변환 처리를 실시하기 위한 후크를 제공하고 있는 경우가 있다
    • AWS Kinesis Data Firehose, Kafka Connect 등

패턴 3. 이벤트를 검색하는 API를 제공하고 구독자로부터 폴링

전혀 다른 접근법으로는 이벤트를 발행하는 측이 이벤트 취득의 엔드 포인트를 공개하고 이벤트를 수신하는 측에서 폴링하는 방법도 있다. 이 방법은 실습 도메인 기반 설계 에서 "RESTful 리소스를 통한 알림 발행"이라는 타이틀로 소개되었다.

이벤트 발행자:

  • 시스템 내에서 발생한 모든 이벤트를 이벤트 저장소에 영속화
  • 새로운 이벤트를 얻기 위한 엔드포인트 준비
    • RSS 및 Atom 피드와 같은 이미지
    • 내부에서는 이벤트 스토어에서 이벤트를 검색하고 반환
  • 이벤트 수신자:
    • 이벤트 발행자의 엔드포인트를 정기적으로 폴링
    • 어디까지 처리했는지 직접 관리

통합 이벤트로의 변환은, 이벤트 스토어에의 기입시 또는, 응답을 돌려주기 전에 실시하면 좋을 것이다. (서적에서는 변환 처리는 생략되어 있다)


참고자료

https://zenn.dev/kohii/articles/4a68e768c93573

728x90

'IT > 기초 지식' 카테고리의 다른 글

Node.js 버전 관리 툴 Volta  (0) 2026.03.11
의지를 구현하는 아키텍처 모더나이제이션  (0) 2026.03.10
의존성 주입 (DI: Dependency Injection)  (0) 2026.03.09
git rebase의 두 가지 사용법  (0) 2026.01.06
git revert  (0) 2026.01.06