Domain Event와 Integration Event를 잘 구분짓고 있나요?

배경
EDA(Event-Driven Architecuture) 환경을 설계할 때 Event
에 대해서 여러 종류가 존재한다는것을 대부분 알고있을것이다.
필자가 최근 기여하고있는 제품은 다양한 이벤트들을 생산(produce)하고 소비(consume)하여 많은 도메인 서비스 컴포넌트 간 데이터를 동기화하고 일관성을 최종적으로 확보하는 구조이다.
팀 내에서 제품을 만들 때 DDD(Domain-Driven Design) 방법론을 적극적으로 활용하고 있음에도, 욕구적으로 도메인 이벤트라 정의했는데도 불구하고 애그리것의 상태만 담긴것이 아닌 다른 컴포넌트에서 처리까지를 기대하여 개념에 위배되는 이벤트 설계를 하는 좋지않은 사고가 생긴다 생각되어 이번 기회에 명확하게 개념들을 구분지어보려한다.
개념들은 크게는 도메인 이벤트(Domain Event)
, 통합 이벤트(Integration Event)
, 외부 이벤트(External Event)
로 나눌 수 있다. 외부 이벤트는 정말 외부에서 호출되는 훅이나 API 요청을 말하는것이기때문에 이번 글에서 생략한다.
이벤트 기반 시스템은 왜 채택하는걸까?
전통적인 모놀리식(monolithic) 아키텍처에서는 서비스 간 강한 결합 (tight coupling) 때문에 확장성과 유지보수성이 떨어지는 경우가 많았다. 이런 문제점을 해결하기 위해 서비스들을 분리했고, 단순 컴포넌트들을 분리만 한다고 위 문제들을 해결할 수 없기에 이벤트 기반 시스템(Event-Driven Architecture, EDA)을 구축해 비동기 메시징을 통해 서비스 간 결합도를 낮추고, 확장성을 높이는 방식을 택하면서 많은 기업에서 채택하기 시작했다.
(물론 비동기 프로세싱을 통해 동시 처리량을 높이는 이유도 있을 수 있겠지만, 이는 이번글에서 큰 관련이 없어 넘어간다)
그래서 이벤트 기반 시스템이 각광받는 이유를 정리해보면 다음과 같다.
- 비동기 처리 및 확장성
이벤트를 큐잉하여 병렬로 처리가 가능해진다. (e.g. 결제 승인 후 즉시 배송 준비 or 배치 잡에서 메시징을 이용해 동시 처리 성능을 올려야할 때 등)
- 마이크로서비스 간 결합도 감소
직접적인 API 호출 없이 서비스 간 독립적인 비동기 통신이 가능해진다. 즉, 각 서비스들 간 직접 통신이 아닌 메시지 브로커를 통해 처리하는것이다.
- 최종적 일관성 (Eventual Consistency) 보장
중앙 데이터베이스가 없더라도, 이벤트 기반으로 데이터 정합성을 맞출 수 있다.
- 실시간 반응형 시스템 구축 용이
고객 행동 데이터를 실시간 분석해야하거나, 특정 도메인에서 사건이 발생했을 때 리액티브하게 알림을 처리하거나 등등의 유즈케이스에서 최적화를 할 수 있다.
그러나 여기서 말하는 이벤트들은 모두 동일한 책임과 관심사를 갖고있진않다. 이는 이벤트를 설계할 때 정말 중요한 요소이다. 하나씩 알아보자.
도메인 이벤트 (Domain Event)
도메인 이벤트는 도메인 모델 내부에서 발생하는 비지니스적으로 중요한 사건을 말한다. 주로 특정 애그리거트(Aggregate)에서 비지니스 규칙을 반영하고 같은 Bounded Context 내에서 동작한다.
도메인 이벤트는 어떻게 동작하나요?
특정 Aggregate에서 상태 변화가 발생하면, 트랜잭션 내에서 이벤트를 발생시킨다.
같은 Bounded Context 내에서 이벤트 핸들러를 통해 비지니스 로직을 확장하거나 후속 처리를 수행한다.
보통 실무에선 동기 또는 내부 비동기 메시징 방식으로 처리한다.
예시
일반적인 커머스에서 주문을 처리하는 과정을 묘사해보면, 주문 완료 시 재고가 할당되고, 결제가 요청될것이다.
이를 주문됨(OrderPlaced)
=> 재고 할당됨(ItemStockAllocated
) => 결제 요청됨(PaymentInitiated)
순으로 후속 트리거 되어 주문의 프로세스가 처리된다.
이 때, 주의를 해야하는것은 주문됨 사건은 Order Aggregate의 도메인 이벤트임으로 외부 서비스(e.g. 배송)에 대한 내용을 담으면 안된다. 예시 코드로 보면 다음과 같다.
잘못된 예시
data class OrderPlaced(
val orderId: String,
val userId: String,
val shippingAddress: String, // X 배송 관련 정보가 포함되어 있다
val deliveryDate: LocalDate // X 주문 서비스의 도메인 이벤트에서 배송 컨텍스트가 포함되어있다
)
@Service
class OrderService(private val applicationEventPublisher: ApplicationEventPublisher) {
fun placeOrder(orderId: String, userId: String, shippingAddress: String) {
// place order domain logic...
println("주문이 생성되었습니다. 주문 ID: $orderId")
// 여기서 주문됨 이벤트를 생산할 때, 배송 서비스의 처리를 위해 배송 컨텍스트가 주문 애그리것 도메인 이벤트에 포함이 되어있다.
val event = OrderPlaced(orderId, userId, shippingAddress, LocalDate.now().plusDays(3))
applicationEventPublisher.publishEvent(event)
}
}
주문을 할 때 보통 배송지와 같은 정보를 입력한다. 이 때, 배송지에 대한 컨텍스트가 주문 애그리거트의 속성인지 아닌지를 이벤트 설계할 때 판단해야한다. 단순하게 해당 이벤트를 브로커를 통해 발행해 배송 컴포넌트에서 처리가 되어야하기에 넣는다면, 이는 도메인 이벤트가 아닌 통합이벤트가 된다.
대게 주문의 생성과 배송의 생성은 별개이다. 주문이 생성되었다고 반드시 배송이 생성된다는 보장이 없으며, 위 논리라면 배송 서비스가 OrderPlaced 도메인 이벤트에 강하게 의존되며 주문 서비스가 배송 서비스의 변경에 취약해진다.
올바르게 수정한 OrderService
data class OrderPlaced(val orderId: String, val userId: String)
@Service
class OrderService(
private val applicationEventPublisher: ApplicationEventPublisher,
private val kafkaTemplate: KafkaTemplate<String, OrderCreatedIntegrationEvent>
) {
fun placeOrder(orderId: String, userId: String, shippingAddress: String) {
// place order domain logic...
println("주문이 생성되었습니다. 주문 ID: $orderId")
val domainEvent = OrderPlaced(orderId, userId)
applicationEventPublisher.publishEvent(domainEvent)
val integrationEvent = OrderCreatedIntegrationEvent(orderId, userId, shippingAddress, LocalDate.now().plusDays(3))
kafkaTemplate.send("order-created-topic", integrationEvent)
}
}
ShippingService
data class OrderCreatedIntegrationEvent(
val orderId: String,
val userId: String,
val shippingAddress: String,
val deliveryDate: LocalDate
)
@Component
class ShippingService {
// 주문 애그리것의 도메인 이벤트가 아닌 통합 이벤트를 구독하고있다.
@KafkaListener(topics = ["order-created-topic"], groupId = "shipping-service")
fun handleOrderCreatedEvent(event: OrderCreatedIntegrationEvent) {
println("배송 준비 시작! 주문 ID: ${event.orderId}, 주소: ${event.shippingAddress}")
}
}
그래서 위와 같이 생성된 Order Aggregate의 변화만 담기는 것이 중요하다. 도메인 이벤트와 통합 이벤트를 잘 구분지어 발행 논리를 설계하고 구현해야한다. 외부 서비스에선 통합 이벤트에 집중할 뿐이다. 이 때, 해당 애그리것의 전역 식별자(Aggregate Root Identifier)를 통해 추가 정보는 조회하여 얻어 처리하는 방식을 택할 수 있다.
통합 이벤트 (Integration Event)
위 도메인 이벤트 설명에서 말했듯 통합 이벤트는 다른 서비스와 데이터를 공유할 때 사용된다.
주문이 완료되면 결제 요청이 이루어지며 결제가 완료 된 이후에 배송 요청이 되어야한다는 프로세스의 가정으로 이벤트를 설계하면 다음과 같이 도출될것이다.
주문 서비스
OrderPlacedEvent (도메인 이벤트) 발생
OrderCreatedIntegrationEvent (통합 이벤트) 발행 → Kafka → 결제 서비스 전달
결제 서비스
OrderCreatedIntegrationEvent를 구독하여 결제 진행
PaymentCompletedIntegrationEvent (통합 이벤트) 발행 → Kafka → 배송 서비스 전달
배송 서비스
- PaymentCompletedIntegrationEvent를 구독하여 배송 시작
근데 통합 이벤트는 왜 사용하는것일까?
도메인 이벤트를 소비하는 시점에 비지니스 요구사항에 맞춰 다른 서비스를 호출하면 될텐데 왜 통합 이벤트를 굳이 발행해서 처리하는것일까?
위에서 언급한 EDA가 각광받는 이유에 답이 있다. 메시징을 이용하지않으면 각 서비스들을 아무리 잘 분리시켜놔도 결합도를 느슨하게 만들기 힘들다. 즉, 서비스들을 마이크로하게 가져간 이득이 사라지는셈이다. 오히려 시스템 관리 복잡도와 코스트만 올라가고 MSA의 이점을 보기힘들어진다.
fun placeOrder() {
// ... order domain logic
// 주문 생성 후 결제 API 호출
paymentService.processPayment(orderId, amount)
// 결제 후 배송 API 호출
shippingService.startShipping(orderId)
}
통합 이벤트를 메시징 하지않으면 위와 같이 동기이던 비동기 요청이던 각 서비스들이 서로간 강한 결합이 유지된다. 그런데 통합 이벤트를 이용하여 메시징 처리를 하면 서비스들은 브로커를 의존하게 되어 서비스들 간의 결합도를 낮출 수 있고, 브로커의 특성에 따라 다르긴하지만 대게 순차처리, 파티셔닝 등을 통해 동시 처리량도 높게 확보할 수 있어진다.
Subscribe to my newsletter
Read articles from Jeongkyun An directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
