스프링 Docs - Publishing Events from Aggregate Roots
서비스가 ApplicationEventPublisher로 직접 발행하는 대신, Aggregate가 이벤트를 내부에 기록하고 Spring Data Repository가 저장 시점에 대신 발행하는 방식이에요.
상위 MOC: Spring 이벤트
1. 핵심 흐름
Aggregate 내부에서 이벤트 기록
↓
repository.save(aggregate)
↓
@DomainEvents 메서드 호출
↓
Spring Application Event로 발행 → [[@EventListener]] / [[@TransactionalEventListener]]
↓
@AfterDomainEventPublication 메서드 호출
↓
기록한 이벤트 목록 제거
애노테이션 이름은 단수 @DomainEvent가 아니라 복수형 **@DomainEvents**예요.
2. 서비스 직접 발행과의 차이
[서비스 직접 발행] [Aggregate가 기록]
OrderService Order
→ order.complete() → complete()
→ publisher.publishEvent(...) ├─ 상태 변경
└─ OrderCompletedEvent 기록
OrderRepository
→ save(order) → 기록된 이벤트 발행
역할 분리:
- Aggregate: 어떤 도메인 사건이 발생했는지 결정
- Repository: 기록된 사건을 Spring Application Event로 발행
- Listener: 사건에 어떻게 반응할지 결정
Aggregate가 ApplicationEventPublisher를 직접 주입받지 않으므로 핵심 도메인 행위와 발행 인프라의 직접 결합을 피할 수 있어요.
NOTE · 프레임워크 의존성이 완전히 사라지는 건 아니에요
ApplicationEventPublisher에 직접 의존하지는 않지만,@DomainEvents/@AfterDomainEventPublication애노테이션이나AbstractAggregateRoot상속을 쓰면 Spring Data와의 구조적 의존성은 남아요. 완전 분리가 필요하면 순수 도메인 객체는 이벤트 목록만 관리하고, Repository 어댑터가 저장 후 꺼내 발행하도록 직접 구성해야 해요(대신 코드가 늘어남).
3. 직접 구현
@DomainEvents
Collection<Object> domainEvents() {
return List.copyOf(domainEvents);
}
@AfterDomainEventPublication
void clearDomainEvents() {
domainEvents.clear();
}
@DomainEvents메서드는 인자를 받지 않고, 단일 이벤트 또는 컬렉션을 반환해요. Spring Data가 Repository 호출 과정에서 직접 호출해요.- 도메인 행위는 상태를 바꾸면서 이미 일어난 사실을 이벤트로 기록만 해요.
public void complete() {
this.status = OrderStatus.COMPLETED;
this.domainEvents.add(new OrderCompletedEvent(this.id));
}
이 시점엔 이벤트가 목록에 들어갔을 뿐, 아직 Spring 이벤트로 발행되지 않았어요.
4. 발행 계기는 JPA 변경 감지가 아니라 Repository 호출
@Transactional
public void completeOrder(Long id) {
Order order = orderRepository.findById(id).orElseThrow();
order.complete(); // 상태는 변경 감지로 DB 반영되지만...
// save(order)를 부르지 않으면 @DomainEvents는 발행되지 않음
}
JPA 변경 감지 → 영속 상태 변경을 트랜잭션 종료 시 DB에 반영
Spring Data @DomainEvents → 지원되는 Repository 메서드 호출 시 이벤트 발행
save()를 호출하지 않으면 DB 상태는 COMPLETED가 되지만 완료 이벤트는 발행되지 않고, 미발행 이벤트가 Aggregate에 남아 나중에 저장될 때 뒤늦게 발행될 수 있어요.
IMPORTANT · 핵심 도메인 사건의 발생 시점은 Aggregate 메서드 호출이지만, Spring 이벤트의 발행 시점은 Repository 메서드 호출이에요.
5. @AfterDomainEventPublication — 목록 비우기
발행 직후 호출되는 콜백이에요. 목록을 비우지 않으면 같은 Aggregate를 다시 저장할 때 같은 이벤트가 중복 발행돼요.
order.complete() → 목록: [OrderCompletedEvent]
첫 save(order) → 발행 → (목록 안 비움)
둘째 save(order) → 같은 OrderCompletedEvent 다시 발행
중복 발행은 이메일 중복 발송, 쿠폰 중복 지급, 통계 중복 증가 같은 부수 효과로 이어져요.
WARNING · 발행 ≠ 커밋
@AfterDomainEventPublication은 이벤트 발행 후 콜백이지 트랜잭션 커밋 후 콜백이 아니에요. 발행 후 후속 코드에서 예외가 나 롤백돼도, 일반 @EventListener는 이미 실행된 상태예요. 커밋 이후에만 반응해야 하는 로직(이메일 등)은 @TransactionalEventListenerAFTER_COMMIT으로 받아야 해요.
6. Repository 메서드의 제약
Spring Data는 Aggregate 인스턴스를 인자로 받는 메서드에서만 이벤트를 꺼내 발행할 수 있어요.
save(...) / saveAll(...) / delete(entity) / deleteAll(...) → 객체 있음 → 발행 가능
deleteById(id) → 객체 없음 → 발행 보장 불가
deleteById(id)는 Aggregate 객체 없이 삭제 쿼리만 실행하므로 @DomainEvents를 호출할 대상이 없을 수 있어요. 삭제 이벤트가 필요하면 먼저 조회해서 상태를 바꾼 뒤 delete(entity)에 넘겨요.
7. AbstractAggregateRoot
반복 코드(목록 보관/등록/노출/제거)를 줄여주는 Spring Data 베이스 클래스예요.
@Entity
public class Order extends AbstractAggregateRoot<Order> {
public void complete() {
this.status = OrderStatus.COMPLETED;
registerEvent(new OrderCompletedEvent(id)); // 목록에 등록
}
}
| 방식 | 장점 | 단점 |
|---|---|---|
AbstractAggregateRoot 상속 |
구현 간단, 반복 코드 적음 | 도메인 객체가 Spring Data 클래스를 상속 |
@DomainEvents 직접 구현 |
보관/노출 정책 직접 통제 | 목록 관리·콜백 코드 반복 |
8. 도메인 이벤트의 의미
이미 발생한 사실을 표현하므로 보통 과거형으로 이름 지어요.
OrderCompleted / BookingCancelled / PaymentApproved / WaitingPromoted
이벤트 분리가 항상 정답은 아니에요. 예약 취소와 대기 승격이 반드시 하나의 원자적 작업이어야 한다면 서비스에서 함께 처리하는 편이 더 명확해요. 알림·통계처럼 독립적인 후속 반응일 때 도메인 이벤트가 특히 유용해요.
9. 애노테이션 정의와 내부 동작
@DomainEvents와 @AfterDomainEventPublication은 Spring Data Commons(org.springframework.data.domain)에 있고, 속성이 없는 마커 애노테이션이에요.
@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface DomainEvents { }
@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface AfterDomainEventPublication { }
동작 방식은 Repository 프록시가 가로채는 방식이에요. 별도 인터페이스를 구현하는 게 아니라, 애노테이션 붙은 메서드를 프록시가 찾아서 호출해요.
EventPublishingRepositoryProxyPostProcessor
↓ Repository에 인터셉터 추가
EventPublishingMethodInterceptor
↓ save/delete 등 호출을 가로챔
EventPublishingMethod (엔티티에서 @DomainEvents / @AfterDomainEventPublication 메서드 탐색)
↓
ApplicationEventPublisher.publishEvent(...) → [[@EventListener]]
↓
@AfterDomainEventPublication 메서드 호출 (목록 비우기)
@DomainEvents가 없는 엔티티면 인터셉터가 그냥 통과시켜요.
AbstractAggregateRoot의 정체
7절의 AbstractAggregateRoot는 위 두 애노테이션을 미리 구현해둔 베이스 클래스예요. 직접 구현할 코드를 대신 담고 있어요.
public class AbstractAggregateRoot<A extends AbstractAggregateRoot<A>> {
private final transient List<Object> domainEvents = new ArrayList<>();
protected <T> T registerEvent(T event) { // 도메인 메서드에서 호출
domainEvents.add(event);
return event;
}
@DomainEvents // Repository가 저장 시 호출
protected Collection<Object> domainEvents() {
return Collections.unmodifiableList(domainEvents);
}
@AfterDomainEventPublication // 발행 직후 호출
protected void clearDomainEvents() {
domainEvents.clear();
}
}
registerEvent()로 등록 → @DomainEvents로 노출 → @AfterDomainEventPublication으로 정리까지 3절의 직접 구현과 동일한 흐름이에요.
최종 정리
- Aggregate 메서드 호출은 이벤트를 기록만 하고, 지원되는 Repository 메서드가 호출돼야 발행돼요.
- 변경 감지로 상태가 저장돼도 Repository 호출이 없으면 이벤트는 발행 안 될 수 있어요.
@AfterDomainEventPublication은 발행 후 정리 콜백이지 커밋 콜백이 아니에요.- 목록을 안 비우면 중복 발행 → 중복 부수 효과.
deleteById()처럼 객체를 안 넘기는 메서드에선 발행 보장 안 됨.- 커밋 이후 반응이 필요하면 @TransactionalEventListener
AFTER_COMMIT. - 신뢰성 있는 외부 전달엔
@DomainEvents만으론 부족 → Outbox 등 별도 설계.
참고 문서
관련 노트
- @TransactionalEventListener — 커밋 이후 실행
- @EventListener — 발행된 이벤트 받기
- ApplicationEvents 테스트 — 발행 검증
- C. Working with Application Events — Spring Modulith 관점
- Spring 이벤트 (MOC)
이 글에 대해 이야기해요
질문이나 다른 관점을 남겨 주세요.