0522

BE

백엔드에서 이벤트와 도메인 이벤트란 무엇일까?

| 서론

안녕하세요, 팡일입니다.

백엔드 코드를 살펴보다 보면 Event, EventHandler, EventPublisher와 같은 이름을 자주 발견할 수 있습니다.

프론트엔드에서는 버튼 클릭이나 입력값 변경처럼 사용자의 행동을 이벤트로 다루는 경우가 많습니다. 백엔드에서도 이벤트의 기본 개념은 크게 다르지 않습니다. 특정한 일이 발생했다는 사실을 표현하고, 그 사실에 관심 있는 로직이 후속 작업을 처리하도록 만드는 방식입니다.

특히 도메인 중심으로 백엔드를 설계하는 프로젝트에서는 UserRegisteredEvent, OrderCompletedEvent와 같은 도메인 이벤트를 사용하기도 합니다.

그렇다면 일반적인 이벤트는 무엇이고, 도메인 이벤트는 어떤 점에서 다를까요?

| 이벤트란?

이벤트는 간단히 말해 이미 발생한 일을 나타내는 메시지입니다.

예를 들어 쇼핑몰에서 주문이 완료되었다면 다음과 같은 일이 이어질 수 있습니다.

  • 주문 완료 이메일 발송

  • 상품 재고 차감

  • 결제 내역 기록

  • 포인트 적립

  • 배송 준비 요청

  • 관리자에게 알림 전송

이 모든 작업을 OrderService가 직접 실행할 수도 있습니다.

TypeScript
class OrderService {
  async completeOrder(orderId: number) {
    const order = await this.orderRepository.findById(orderId);

    order.complete();

    await this.orderRepository.save(order);
    await this.inventoryService.decrease(order.items);
    await this.pointService.add(order.userId, order.totalPrice);
    await this.emailService.sendOrderCompleted(order);
    await this.notificationService.notifyAdmin(order);
  }
}

처음에는 처리 과정을 한눈에 볼 수 있어 단순해 보입니다. 하지만 주문 완료 이후의 작업이 늘어날수록 OrderService가 재고, 포인트, 이메일, 알림 등 많은 서비스에 의존하게 됩니다.

이때 주문이 완료되었다는 사실을 이벤트로 발행할 수 있습니다.

TypeScript
await this.eventPublisher.publish(
  new OrderCompletedEvent(order.id, order.userId),
);

이 이벤트를 필요로 하는 각각의 핸들러가 후속 작업을 처리합니다.

TypeScript
class AddPointHandler {
  async handle(event: OrderCompletedEvent) {
    await this.pointService.add(event.userId);
  }
}

class SendOrderEmailHandler {
  async handle(event: OrderCompletedEvent) {
    await this.emailService.send(event.orderId);
  }
}

이제 주문 서비스는 주문 완료라는 핵심 작업에 집중하고, 이후에 필요한 작업은 각 이벤트 핸들러가 담당할 수 있습니다.

| 이벤트가 필요한 이유

이벤트를 사용하는 가장 큰 이유는 특정 작업 이후 실행되어야 하는 여러 로직을 분리하기 위해서입니다.

OrderService가 이메일 서비스와 포인트 서비스 등을 직접 호출하면 각 서비스가 서로 강하게 연결됩니다.

text
OrderService
 ├─ InventoryService
 ├─ PointService
 ├─ EmailService
 └─ NotificationService

이 구조에서는 이메일 발송 기능을 변경하거나 새로운 후속 작업을 추가할 때마다 주문 서비스도 함께 수정해야 할 가능성이 큽니다.

반면 주문 완료 이벤트를 발행하면 주문 서비스는 어떤 후속 작업이 실행되는지 자세히 알 필요가 없습니다.

text
OrderService
 └─ OrderCompletedEvent
     ├─ 재고 차감
     ├─ 포인트 적립
     ├─ 이메일 발송
     └─ 관리자 알림

새로운 후속 작업이 필요하다면 주문 서비스를 수정하는 대신 해당 이벤트를 처리하는 핸들러를 추가할 수 있습니다.

이를 통해 다음과 같은 효과를 얻을 수 있습니다.

  • 하나의 서비스가 너무 많은 책임을 가지는 것을 방지할 수 있습니다.

  • 서비스 사이의 직접적인 의존성을 줄일 수 있습니다.

  • 새로운 후속 기능을 비교적 쉽게 추가할 수 있습니다.

  • 각 후속 작업을 독립적으로 테스트할 수 있습니다.

  • 비동기 처리를 통해 사용자 응답 시간을 줄일 수 있습니다.

| 명령과 이벤트의 차이

이벤트를 이해할 때 명령과의 차이를 함께 알아두면 좋습니다.

명령은 어떤 작업을 실행해 달라는 요청입니다.

text
CompleteOrder
SendOrderEmail
AddUserPoint

반면 이벤트는 어떤 일이 이미 발생했다는 사실입니다.

text
OrderCompleted
OrderEmailSent
UserPointAdded

따라서 명령은 보통 현재형이나 명령형으로 표현하고, 이벤트는 이미 발생한 일을 나타내기 때문에 과거형으로 표현합니다.

TypeScript
// 명령
new CompleteOrderCommand(orderId);

// 이벤트
new OrderCompletedEvent(orderId);

명령을 전달받은 대상은 요청된 작업을 수행할 책임이 있습니다. 반면 이벤트는 해당 사실에 관심 있는 대상이 없을 수도 있고, 여러 대상이 동시에 처리할 수도 있습니다.

즉, 이벤트를 발행하는 쪽은 일반적으로 누가 그 이벤트를 처리하는지 알지 못합니다.

| 도메인이란?

도메인 이벤트를 이해하려면 먼저 도메인의 의미를 알아야 합니다.

도메인은 소프트웨어가 해결하려는 실제 업무 영역과 그 안의 규칙을 의미합니다.

쇼핑몰을 예로 들면 다음과 같은 개념이 도메인에 포함됩니다.

  • 사용자는 상품을 주문할 수 있습니다.

  • 결제가 완료되면 주문 상태가 변경됩니다.

  • 주문을 취소하면 결제 금액을 환불해야 합니다.

  • 상품의 재고가 부족하면 주문할 수 없습니다.

  • 배송이 시작된 주문은 바로 취소할 수 없습니다.

이처럼 서비스가 다루는 핵심 개념과 규칙을 코드로 표현한 것이 도메인 모델입니다.

| 도메인 이벤트란?

도메인 이벤트는 도메인 안에서 의미 있는 상태 변화가 발생했다는 사실을 나타내는 이벤트입니다.

쇼핑몰에서는 다음과 같은 사건이 도메인 이벤트가 될 수 있습니다.

  • 사용자가 가입했습니다.

  • 주문이 생성되었습니다.

  • 결제가 완료되었습니다.

  • 주문이 취소되었습니다.

  • 배송이 시작되었습니다.

  • 상품의 재고가 소진되었습니다.

TypeScript
class OrderCompletedEvent {
  constructor(
    readonly orderId: number,
    readonly userId: number,
    readonly completedAt: Date,
  ) {}
}

단순히 데이터베이스의 값이 변경되었다고 해서 모두 도메인 이벤트가 되는 것은 아닙니다.

예를 들어 주문 테이블의 updatedAt 값이 변경된 것은 기술적으로 상태가 바뀐 일이지만, 비즈니스 관점에서 중요한 사건이라고 보기는 어렵습니다.

반면 주문이 완료된 것은 포인트 적립, 배송 준비, 알림 전송 등 여러 후속 업무를 발생시키는 중요한 사건입니다. 따라서 OrderCompletedEvent와 같은 도메인 이벤트로 표현할 수 있습니다.

| 일반 이벤트와 도메인 이벤트의 차이

이벤트는 넓은 의미의 개념이며, 도메인 이벤트는 이벤트의 한 종류입니다.

구분

의미

예시

일반 이벤트

시스템에서 발생한 모든 종류의 사건

파일 업로드 완료, 서버 연결 종료

도메인 이벤트

비즈니스 영역에서 의미 있는 사건

주문 완료, 결제 실패, 회원 탈퇴

인프라 이벤트

기술적인 환경에서 발생한 사건

데이터베이스 연결 실패, 캐시 만료

UI 이벤트

사용자 화면에서 발생한 사건

버튼 클릭, 입력값 변경

두 이벤트의 가장 큰 차이는 비즈니스적인 의미를 담고 있는가입니다.

FileUploadedEvent는 시스템의 기술적인 처리 결과를 나타낼 수 있습니다. 반면 ProfileImageChangedEvent는 사용자의 프로필 이미지가 변경되었다는 서비스의 업무적인 의미를 담고 있습니다.

같은 동작이라도 어떤 관점에서 표현하는지에 따라 이벤트의 성격이 달라질 수 있습니다.

| 도메인 이벤트는 어디에서 발생시킬까?

도메인 중심으로 설계한다면 도메인 이벤트는 보통 해당 상태를 변경한 도메인 객체에서 생성합니다.

TypeScript
class Order {
  private status: OrderStatus;
  private domainEvents: object[] = [];

  complete() {
    if (this.status !== OrderStatus.PAID) {
      throw new Error('결제가 완료된 주문만 완료할 수 있습니다.');
    }

    this.status = OrderStatus.COMPLETED;

    this.domainEvents.push(
      new OrderCompletedEvent(
        this.id,
        this.userId,
        new Date(),
      ),
    );
  }

  pullDomainEvents() {
    const events = [...this.domainEvents];
    this.domainEvents = [];

    return events;
  }
}

애플리케이션 서비스는 주문 객체에 완료 처리를 요청하고 저장합니다.

TypeScript
class OrderService {
  async completeOrder(orderId: number) {
    const order =
      await this.orderLookupService.findByIdOrThrow(orderId);

    order.complete();

    await this.orderRepository.save(order);

    const events = order.pullDomainEvents();

    for (const event of events) {
      await this.eventPublisher.publish(event);
    }
  }
}

이 구조에서는 주문이 완료될 수 있는 조건과 주문 완료 이벤트의 발생이 Order라는 도메인 객체 안에 함께 존재합니다.

따라서 애플리케이션 서비스가 실수로 이벤트 발행을 위한 조건을 별도로 판단하거나, 도메인 규칙을 중복해서 작성하는 일을 줄일 수 있습니다.

다만 모든 프로젝트에서 반드시 도메인 객체가 이벤트를 생성해야 하는 것은 아닙니다. 프로젝트 구조에 따라 애플리케이션 서비스에서 직접 이벤트를 생성하고 발행하기도 합니다.

| 이벤트는 동기적으로 처리될까?

이벤트는 동기 또는 비동기 방식으로 처리할 수 있습니다.

동기 방식은 이벤트를 발행한 요청 안에서 모든 핸들러의 처리가 끝날 때까지 기다립니다.

TypeScript
await eventPublisher.publish(event);

구조가 단순하고 처리 결과를 바로 확인할 수 있지만, 이메일 발송과 같은 작업이 오래 걸리면 전체 요청도 느려질 수 있습니다. 또한 한 핸들러에서 오류가 발생하면 원래 요청까지 실패할 가능성이 있습니다.

비동기 방식은 메시지 큐 등에 이벤트를 전달한 뒤, 별도의 작업자가 이벤트를 처리합니다.

text
주문 완료
   ↓
메시지 큐에 이벤트 저장
   ↓
사용자에게 주문 완료 응답
   ↓
별도 작업자가 이메일·포인트 처리

비동기 처리를 사용하면 요청에 빠르게 응답하고, 실패한 작업을 다시 시도할 수 있습니다. 하지만 이벤트 처리 결과가 즉시 반영되지 않을 수 있으며, 중복 처리와 재시도 등을 함께 고려해야 합니다.

따라서 반드시 즉시 성공해야 하는 핵심 작업과 나중에 처리해도 되는 부가 작업을 구분하는 것이 중요합니다.

예를 들어 주문 데이터 저장과 결제 승인은 하나의 핵심 흐름으로 처리하고, 이메일 발송이나 관리자 알림은 비동기 이벤트로 처리할 수 있습니다.

| 이벤트와 트랜잭션 문제

도메인 이벤트를 사용할 때 주의해야 할 부분은 데이터베이스 트랜잭션입니다.

다음 코드에서 주문 저장은 성공했지만 이벤트 발행이 실패할 수 있습니다.

TypeScript
await this.orderRepository.save(order);
await this.eventPublisher.publish(event);

반대로 이벤트는 발행되었지만 주문 저장이 실패할 수도 있습니다.

TypeScript
await this.eventPublisher.publish(event);
await this.orderRepository.save(order);

첫 번째 경우에는 주문이 완료되었지만 후속 작업이 실행되지 않습니다. 두 번째 경우에는 실제 주문은 완료되지 않았는데 이메일이나 포인트 적립이 실행될 수 있습니다.

작은 프로젝트에서는 재시도나 오류 로그를 통해 대응할 수 있지만, 이벤트 유실이 중요한 문제가 되는 시스템에서는 Transactional Outbox Pattern과 같은 방법을 사용하기도 합니다.

이 방식은 도메인 데이터와 발행할 이벤트를 같은 데이터베이스 트랜잭션 안에 저장합니다. 이후 별도의 작업자가 저장된 이벤트를 메시지 브로커로 전달합니다.

text
하나의 트랜잭션
 ├─ 주문 상태 저장
 └─ 발행할 이벤트 저장

트랜잭션 완료
 └─ 별도 작업자가 이벤트 발행

이를 통해 도메인 데이터만 저장되고 이벤트는 사라지는 문제를 줄일 수 있습니다.

| 도메인 이벤트와 통합 이벤트

도메인 이벤트와 함께 Integration Event라는 용어를 접할 수도 있습니다.

도메인 이벤트는 주로 같은 애플리케이션이나 도메인 내부에서 발생한 일을 표현합니다.

TypeScript
OrderCompletedEvent

통합 이벤트는 다른 서비스에도 해당 사실을 전달하기 위한 외부 메시지입니다.

TypeScript
OrderCompletedIntegrationEvent

두 이벤트가 비슷한 내용을 가질 수 있지만, 사용 목적은 다릅니다.

도메인 이벤트에는 내부 도메인 객체나 세부 정보가 포함될 수 있습니다. 반면 외부로 전달되는 통합 이벤트는 다른 서비스와의 계약이므로, 변경에 더 신중해야 하고 명확한 데이터 구조를 가져야 합니다.

예를 들어 주문 도메인 이벤트를 처리한 핸들러가 외부 서비스에 전달할 통합 이벤트를 생성할 수 있습니다.

text
OrderCompletedEvent
        ↓
IntegrationEventHandler
        ↓
OrderCompletedIntegrationEvent
        ↓
배송 서비스·알림 서비스

하나의 서버 안에서 이벤트를 사용한다고 해서 반드시 Kafka나 RabbitMQ 같은 메시지 브로커가 필요한 것은 아닙니다. 프로세스 내부 이벤트 처리만으로도 역할을 분리할 수 있습니다.

반대로 여러 서버가 이벤트를 주고받는다면 메시지 브로커, 이벤트 스키마, 재시도, 중복 처리 등을 함께 설계해야 합니다.

| 이벤트를 사용할 때 주의할 점

이벤트를 사용하면 서비스 간 결합도를 낮출 수 있지만, 모든 로직을 이벤트로 처리하면 오히려 전체 흐름을 파악하기 어려워질 수 있습니다.

코드만 보았을 때 어떤 이벤트가 발행되고, 몇 개의 핸들러가 실행되는지 바로 알기 어려울 수 있기 때문입니다. 하나의 요청이 여러 이벤트를 연속으로 발생시키면 디버깅도 복잡해질 수 있습니다.

또한 비동기 이벤트는 동일한 메시지가 두 번 전달될 가능성을 고려해야 합니다.

예를 들어 포인트 적립 이벤트가 중복으로 처리되면 사용자에게 포인트가 두 번 지급될 수 있습니다. 따라서 이벤트 ID나 주문 ID를 기록하여 이미 처리한 이벤트인지 확인하는 멱등성 처리가 필요할 수 있습니다.

이벤트를 도입할 때는 다음 내용을 함께 고려하는 것이 좋습니다.

  • 이벤트 이름만으로 발생한 일을 이해할 수 있는가?

  • 이벤트에 필요한 정보가 충분히 포함되어 있는가?

  • 반드시 즉시 처리해야 하는 작업인가?

  • 실패하면 다시 처리할 수 있는가?

  • 동일한 이벤트가 여러 번 전달되어도 안전한가?

  • 이벤트 처리 상태와 실패 원인을 추적할 수 있는가?

  • 데이터 저장과 이벤트 발행 사이의 일관성을 어떻게 보장할 것인가?

이벤트는 서비스의 책임을 분리하는 데 유용하지만, 그만큼 처리 흐름이 눈에 보이지 않게 분산될 수 있습니다. 따라서 로그, 모니터링, 재시도 정책도 함께 준비하는 것이 중요합니다.

| 이벤트가 항상 필요할까?

하나의 작업 이후 실행되는 로직이 적고, 서비스 간 의존 관계도 단순하다면 직접 메서드를 호출하는 방식이 더 이해하기 쉬울 수 있습니다.

TypeScript
await this.orderRepository.save(order);
await this.emailService.sendOrderCompleted(order);

반대로 다음과 같은 상황에서는 이벤트 도입을 고려해볼 수 있습니다.

  • 하나의 상태 변화 이후 여러 후속 작업이 실행될 때

  • 핵심 로직과 부가적인 작업을 분리하고 싶을 때

  • 서로 다른 도메인이나 서비스에 상태 변화를 알려야 할 때

  • 이메일과 알림처럼 비동기로 처리할 수 있는 작업이 있을 때

  • 새로운 후속 기능이 자주 추가될 가능성이 있을 때

  • 비즈니스에서 중요한 사건을 코드로 명확하게 표현하고 싶을 때

중요한 것은 이벤트를 사용하는 것 자체가 아니라, 이벤트를 통해 코드의 책임과 의존 관계가 실제로 더 명확해지는지 판단하는 것입니다.

| 결론

이벤트는 시스템에서 어떤 일이 발생했다는 사실을 표현하는 메시지입니다. 이벤트를 발행하면 해당 사실에 관심 있는 여러 핸들러가 각자의 후속 작업을 처리할 수 있습니다.

도메인 이벤트는 그중에서도 비즈니스 영역에서 의미 있는 상태 변화가 발생했다는 사실을 나타냅니다. 주문 완료, 결제 실패, 회원 가입과 같은 사건이 대표적인 도메인 이벤트입니다.

도메인 이벤트를 적절하게 사용하면 핵심 비즈니스 로직과 이메일, 알림, 포인트 적립 등의 후속 작업을 분리할 수 있습니다. 또한 서비스 간 직접적인 의존성을 줄이고, 도메인에서 중요한 사건을 코드로 더욱 분명하게 표현할 수 있습니다.

다만 이벤트를 사용하면 처리 흐름이 여러 핸들러로 분산되고, 비동기 처리에서는 이벤트 유실, 중복 처리, 트랜잭션 일관성 등의 문제도 함께 고려해야 합니다.

따라서 이벤트와 도메인 이벤트는 무조건 적용해야 하는 구조가 아니라, 복잡해진 비즈니스 흐름과 서비스 간 의존 관계를 더 명확하게 관리하기 위한 하나의 설계 방법이라고 이해하면 좋습니다.