본문으로 건너뛰기
Leo Jung
뒤로 가기

[DDD Intro #6] Domain Event: 도메인에서 일어난 일을 표현하기

페이지 수정

도메인 모델은 상태만으로 이루어져 있지 않다. 도메인에서는 계속 어떤 일이 일어난다. 주문이 접수되고, 결제가 완료되고, 배송이 요청되고, 구독이 갱신되고, 환불이 승인되고, 이용 권한이 만료된다.

이런 일들은 단순한 데이터 변경이 아니다. 도메인에서 의미 있는 사건이다.

Domain Event는 도메인에서 이미 일어난 중요한 일을 표현하는 객체다. 이벤트는 명령이 아니다. 요청도 아니다. 이벤트는 과거에 발생한 사실이다.

이 차이를 이해하는 것이 중요하다.

이벤트는 과거형이다

이벤트 이름은 보통 과거형으로 짓는다.

SubscriptionRenewed
PaymentCompleted
SubscriptionCanceled
AccessGranted
RefundApproved

이 이름들은 “무언가를 해라”가 아니라 “무언가가 일어났다”를 말한다.

RenewSubscription은 명령에 가깝다. “구독을 갱신하라”는 요청이다. 반면 SubscriptionRenewed는 이벤트다. “구독이 갱신되었다”는 사실이다.

이 차이는 단순한 네이밍 취향이 아니다. 모델링의 차이다.

명령은 실패할 수 있다. 구독 갱신을 요청했지만 결제가 실패하면 갱신되지 않을 수 있다. 이벤트는 이미 일어난 사실이므로 되돌릴 수 없다. 나중에 보상 이벤트가 발생할 수는 있지만, 발생했다는 사실 자체가 사라지는 것은 아니다.

명령: RenewSubscription
결과: SubscriptionRenewed 또는 SubscriptionRenewalFailed

이렇게 구분하면 도메인 흐름이 더 선명해진다.

왜 Domain Event가 필요한가

도메인 이벤트가 필요한 이유는 여러 가지다. 그중 가장 중요한 이유는 도메인에서 일어난 일을 명시적으로 표현하기 위해서다.

예를 들어 구독이 갱신되면 여러 후속 작업이 필요할 수 있다.

이용 권한을 다음 기간까지 연장한다.
영수증 이메일을 보낸다.
마케팅 분석에 갱신 이벤트를 기록한다.
추천 시스템에 활성 구독자 정보를 반영한다.
고객센터 타임라인에 이력을 남긴다.

이 모든 작업을 Subscription Aggregate나 Application Service 안에 직접 넣으면 어떻게 될까?

subscription.renew(paymentResult);
entitlementService.extend(subscription);
emailService.sendReceipt(subscription);
analyticsService.recordRenewal(subscription);
supportTimelineService.addRenewalHistory(subscription);

처음에는 괜찮아 보일 수 있다. 하지만 후속 작업이 늘어날수록 구독 갱신 유스케이스는 점점 무거워진다. 구독 모델은 이용 권한, 이메일, 분석, 고객센터에 대해 너무 많이 알게 된다.

Domain Event를 사용하면 구독 Aggregate는 자신에게 중요한 사실만 표현할 수 있다.

subscription.renew(paymentResult);

// 내부적으로 SubscriptionRenewed 이벤트가 기록됨

그리고 다른 관심사는 이 이벤트에 반응한다.

SubscriptionRenewed
  -> 이용 권한 연장
  -> 영수증 이메일 발송
  -> 분석 이벤트 적재
  -> 고객센터 이력 추가

이벤트는 도메인에서 일어난 의미 있는 사실을 중심으로 관심사를 연결한다.

이벤트는 결합을 줄인다

Domain Event의 중요한 효과는 결합을 줄이는 것이다. 구독 갱신 로직이 이메일 발송, 분석, 고객센터 이력을 직접 알 필요가 없어진다.

구독 컨텍스트는 “구독이 갱신되었다”는 사실을 발행한다. 그 사실에 관심 있는 다른 컴포넌트나 컨텍스트가 각자 반응한다.

이 구조는 특히 Bounded Context 간 협력에서 유용하다.

결제 컨텍스트에서 결제가 완료되면 구독 컨텍스트는 그 사실을 알아야 할 수 있다. 구독 컨텍스트에서 구독이 갱신되면 이용 권한 컨텍스트는 접근 기간을 연장해야 한다. 이때 한 컨텍스트의 내부 모델을 다른 컨텍스트가 직접 사용하면 모델이 오염된다.

이벤트는 컨텍스트 간 협력의 언어가 될 수 있다.

Billing Context: PaymentCompleted 발행
Subscription Context: PaymentCompleted를 받아 구독 갱신
Subscription Context: SubscriptionRenewed 발행
Entitlement Context: SubscriptionRenewed를 받아 권한 연장

물론 이벤트를 사용한다고 자동으로 좋은 설계가 되는 것은 아니다. 이벤트 이름과 데이터가 도메인 의미를 잘 담아야 한다. 단순히 기술적 메시지로만 쓰면 이벤트는 또 다른 결합 지점이 된다.

Domain Event와 Integration Event

실무에서는 도메인 이벤트와 통합 이벤트를 구분할 필요가 있다.

Domain Event는 도메인 모델 내부에서 발생한 의미 있는 사건이다. 주로 같은 Bounded Context 안에서 도메인 로직을 표현하고 후속 처리를 연결하는 데 사용한다.

Integration Event는 다른 시스템이나 다른 Bounded Context와 통신하기 위해 외부로 발행하는 계약에 가깝다.

둘이 항상 별도 객체여야 한다는 뜻은 아니다. 하지만 목적이 다르다는 점은 이해해야 한다.

예를 들어 SubscriptionRenewed라는 도메인 이벤트가 있다고 하자. 이 이벤트는 내부적으로 다음 정보를 가질 수 있다.

public record SubscriptionRenewed(
    SubscriptionId subscriptionId,
    SubscriberId subscriberId,
    PlanId planId,
    Period renewedPeriod
) {}

외부 시스템에 발행할 통합 이벤트는 더 안정적인 계약을 가져야 할 수 있다.

{
  "eventType": "subscription.renewed.v1",
  "subscriptionId": "sub_123",
  "subscriberId": "mem_456",
  "startsAt": "2026-07-01T00:00:00",
  "endsAt": "2026-08-01T00:00:00"
}

도메인 이벤트는 모델의 언어에 가깝고, 통합 이벤트는 시스템 간 계약에 가깝다. 이 둘을 무조건 분리할 필요는 없지만, 구분하지 않으면 내부 모델 변경이 외부 계약을 깨뜨리거나, 외부 계약 때문에 내부 모델이 경직될 수 있다.

이벤트는 상태 변경의 부산물이 아니다

이벤트를 단순히 “상태가 바뀌었으니 로그를 남기는 것”으로 이해하면 부족하다. Domain Event는 도메인에서 의미 있는 사건이어야 한다.

예를 들어 다음 이벤트는 너무 기술적이다.

SubscriptionStatusChanged

물론 상태 변경 자체가 중요할 수도 있다. 하지만 대부분의 경우 더 도메인적인 이름을 찾을 수 있다.

SubscriptionRenewed
SubscriptionCanceled
SubscriptionExpired
SubscriptionSuspendedDueToPaymentFailure

SubscriptionStatusChanged는 무엇이 일어났는지 말하지 않는다. 단지 상태 값이 바뀌었다고 말한다. 반면 SubscriptionSuspendedDueToPaymentFailure는 결제 실패 때문에 구독이 일시 중지되었다는 도메인 의미를 담고 있다.

좋은 이벤트 이름은 도메인 전문가와 대화할 수 있어야 한다.

“구독 상태 변경 이벤트가 발생했습니다”보다 “결제 실패로 구독이 일시 중지되었습니다”가 훨씬 도메인에 가깝다.

Aggregate와 Domain Event

Domain Event는 보통 Aggregate의 행위 안에서 발생한다. Aggregate는 자신의 상태를 변경하면서, 도메인적으로 중요한 일이 일어났음을 이벤트로 기록할 수 있다.

예를 들어 구독 갱신을 생각해보자.

public class Subscription {
    private SubscriptionId id;
    private SubscriberId subscriberId;
    private SubscriptionStatus status;
    private Period currentPeriod;
    private List<DomainEvent> domainEvents = new ArrayList<>();

    public void renew(PaymentResult paymentResult) {
        if (!paymentResult.isSuccess()) {
            recordPaymentFailure(paymentResult);
            return;
        }

        this.currentPeriod = currentPeriod.next();
        this.status = SubscriptionStatus.ACTIVE;

        domainEvents.add(new SubscriptionRenewed(
            id,
            subscriberId,
            currentPeriod
        ));
    }
}

이 코드는 완성된 구현 예시는 아니지만 핵심 아이디어를 보여준다. Subscription은 갱신 규칙을 수행하고, 갱신되었다는 사실을 이벤트로 남긴다.

Application Service는 Aggregate를 저장한 뒤 이벤트를 발행할 수 있다.

Subscription subscription = repository.findById(subscriptionId);
subscription.renew(paymentResult);
repository.save(subscription);
eventPublisher.publish(subscription.pullDomainEvents());

이 흐름에서 이벤트는 Aggregate의 상태 변경과 분리된 별도 작업이 아니다. 도메인 행위의 결과로 발생한 사실이다.

이벤트를 남용하면 흐름이 숨는다

Domain Event는 강력하지만 남용하면 오히려 시스템을 이해하기 어려워진다. 모든 메서드 호출을 이벤트로 바꾸면 명시적인 흐름이 사라진다. 어디서 무엇이 실행되는지 추적하기 어려워진다.

특히 하나의 트랜잭션 안에서 반드시 순서대로 처리되어야 하는 핵심 도메인 규칙을 이벤트 핸들러로 흩어버리면 위험하다.

예를 들어 구독 갱신 시 반드시 기간이 연장되고 상태가 활성화되어야 한다면, 이것은 Subscription Aggregate 안에서 직접 처리해야 한다. 이벤트 핸들러가 나중에 처리하도록 미루면 일시적으로 불완전한 상태가 생길 수 있다.

이벤트는 “중요하지만 느슨하게 연결해도 되는 후속 반응”에 잘 맞는다. 반대로 Aggregate 내부의 불변식을 지키는 규칙은 이벤트로 흩뜨리지 않는 편이 좋다.

좋은 질문은 이것이다.

이 작업은 원래 행위의 일부인가?
아니면 그 행위가 일어난 뒤 관심 있는 다른 쪽에서 반응하면 되는가?
즉시 일관성이 필요한가?
최종적 일관성으로 충분한가?

이벤트에 무엇을 담을 것인가

Domain Event에는 필요한 정보를 담아야 한다. 하지만 너무 많이 담으면 이벤트가 다른 모델의 복사본이 된다. 너무 적게 담으면 이벤트를 받은 쪽이 다시 원천 Aggregate를 조회해야 해서 결합이 생길 수 있다.

SubscriptionRenewed 이벤트에는 무엇이 필요할까?

최소한 구독 ID와 갱신된 기간은 필요할 수 있다. 이용 권한 컨텍스트가 접근 권한을 연장하려면 구독자 ID와 기간이 필요할 수 있다. 하지만 결제 수단의 상세 정보나 내부 정책 객체까지 담을 필요는 없을 수 있다.

이벤트 설계는 수신자의 편의와 발행자의 캡슐화 사이의 균형이다.

Domain Event라면 도메인 모델 내부에서 의미 있는 데이터를 담으면 된다. 외부로 발행되는 Integration Event라면 장기적으로 안정적인 계약을 더 신중하게 설계해야 한다.

마치며

Domain Event는 도메인에서 일어난 중요한 일을 표현하는 방법이다. 이벤트는 명령이 아니라 이미 발생한 사실이다. 그래서 이름은 도메인 언어의 과거형이어야 한다.

좋은 Domain Event는 모델을 더 선명하게 만든다. 상태 변경의 이유를 드러내고, 관심사를 느슨하게 연결하며, Bounded Context 간 협력을 가능하게 한다. SubscriptionStatusChanged보다 SubscriptionSuspendedDueToPaymentFailure가 더 좋은 이유는 도메인에서 실제로 일어난 일을 말하기 때문이다.

하지만 이벤트가 모든 문제의 답은 아니다. 불변식을 지켜야 하는 핵심 규칙을 이벤트 핸들러로 흩어버리면 모델은 오히려 약해진다. 이벤트는 도메인 행위의 결과로 발생한 중요한 사실을 표현할 때 가장 빛난다.

다음 글에서는 지금까지 다룬 개념들이 현실에서 어떻게 실패하는지 살펴본다. DDD가 실패하는 이유는 DDD가 너무 어려워서만은 아니다. 많은 경우 DDD를 패턴 적용법으로 오해하기 때문에 실패한다.


페이지 수정
공유하기:

이전 글
[DDD Intro #7] DDD가 실패하는 이유
다음 글
[DDD Intro #5] Aggregate: 일관성의 경계