5.04 도메인 이벤트 구현
사용자 도메인에서 발생하는 중요한 상태 변경을 도메인 이벤트로 표현하여, 시스템의 다른 부분에 이를 알립니다. 이 섹션에서는 회원과 사서 에그리거트 루트에서 발생하는 이벤트 구현 방법을 살펴보겠습니다.
도메인 이벤트 정의
회원 가입 완료 이벤트
package net.badnom.library.user.domain.user.event;
public record MemberRegisteredEvent(
UserId memberId,
LocalDateTime occurredAt
) implements TimedDomainEvent {
public MemberRegisteredEvent(UserId memberId) {
this(memberId, LocalDateTime.now());
}
public MemberRegisteredEvent {
Objects.requireNonNull(memberId, "Member ID must not be null");
occurredAt = Objects.requireNonNullElseGet(occurredAt, LocalDateTime::now);
}
@Override
public String toString() {
return String.format("Member registered: %s at %s",
memberId, occurredAt);
}
}회원 등급 변경 이벤트
package net.badnom.library.user.domain.user.event;
public record MemberTierChangedEvent(
UserId memberId,
MembershipTier previousTier,
MembershipTier newTier,
boolean isUpgrade,
LocalDateTime occurredAt
) implements TimedDomainEvent {
public MemberTierChangedEvent(
UserId memberId,
MembershipTier previousTier,
MembershipTier newTier,
boolean isUpgrade) {
this(memberId, previousTier, newTier, isUpgrade, LocalDateTime.now());
}
public MemberTierChangedEvent {
Objects.requireNonNull(memberId, "Member ID must not be null");
Objects.requireNonNull(previousTier, "Previous tier must not be null");
Objects.requireNonNull(newTier, "New tier must not be null");
occurredAt = Objects.requireNonNullElseGet(occurredAt, LocalDateTime::now);
}
public boolean isDowngrade() {
return !isUpgrade;
}
@Override
public String toString() {
String changeDirection = isUpgrade ? "upgraded to" : "downgraded to";
return String.format("Member %s's tier %s %s from %s at %s",
memberId, changeDirection, newTier, previousTier, occurredAt);
}
}이벤트 핸들러 구현
회원 가입 완료 핸들러
package net.badnom.library.user.application.event;
@Component
@RequiredArgsConstructor
public class MemberRegisteredEventHandler {
private final EmailSender emailSender;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(MemberRegisteredEvent event) {
emailSender.sendWelcomeEmail(event.memberId());
}
}회원 등급 변경 핸들러
package net.badnom.library.user.application.event;
@Component
@RequiredArgsConstructor
public class MemberTierChangedEventHandler {
private final NotificationService notificationService;
private final AuditService auditService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(MemberTierChangedEvent event) {
// 회원에게 변경 알림 발송
String message = String.format(
"회원님의 등급이 %s에서 %s로 변경되었습니다.",
event.previousTier(),
event.newTier()
);
notificationService.notifyMember(event.memberId(), message);
// 변경 이력 기록
auditService.logMembershipChange(
event.memberId(),
event.previousTier(),
event.newTier(),
event.occurredAt()
);
}
}이벤트 발행 메커니즘
package net.badnom.library.shared.core.event;
@Aspect
@Component
@RequiredArgsConstructor
public class DomainEventPublisher {
private final ApplicationEventPublisher publisher;
@AfterReturning(
pointcut = "execution(* net.badnom.library.user.infrastructure.persistence.*Repository.save(..))",
returning = "result"
)
public void publishEvents(JoinPoint jp, Result<?> result) {
if (result.isSuccess() && result.getValue() instanceof AbstractAggregateRoot<?> aggregate) {
aggregate.getDomainEvents().forEach(event -> {
publisher.publishEvent(event);
log.debug("Published domain event: {}", event);
});
aggregate.clearDomainEvents();
}
}
}패키지 구조
net.badnom.library.user
├── domain
│ └── user
│ └── event
│ ├── MemberRegisteredEvent
│ └── MemberTierChangedEvent
└── application
└── event
├── MemberRegisteredEventHandler
└── MemberTierChangedEventHandler도메인 이벤트 발행 예시
public final class Member extends User {
public static Result<Member> create(Username username,
HashedPassword password,
Email email,
Specification<Email> uniqueEmailSpec,
Specification<Username> uniqueUsernameSpec,
FullName fullName) {
return uniqueEmailSpec.isSatisfiedBy(email)
.flatMap(__ -> uniqueUsernameSpec.isSatisfiedBy(username))
.map(__ -> new Member(
UserId.newId(),
username,
password,
email,
fullName,
MembershipTier.getDefaultTier()))
.onSuccess(member ->
member.registerDomainEvent(new MemberRegisteredEvent(member.getId())));
}
public Result<Void> changeTier(MembershipTier newTier) {
return ContractValidator.start()
.requireNotNull(newTier, ErrorCodes.ERROR_NULL_NEW_TIER)
.require(!newTier.equals(currentTier), ErrorCodes.SAME_TIER)
.validate()
.onSuccess(__ -> {
MembershipTier previousTier = this.currentTier;
this.currentTier = newTier;
registerDomainEvent(new MemberTierChangedEvent(
this.getId(),
previousTier,
newTier,
newTier.isHigherThan(previousTier)
));
});
}
}이벤트 처리 시 주의사항:
-
트랜잭션 관리
- 이벤트 핸들러는 트랜잭션 커밋 후 실행
- 실패 시 재시도 메커니즘 구현
- 멱등성 보장
-
이벤트 설계
- 과거 시제 사용
- 필요한 컨텍스트 정보 포함
- 불변 객체로 구현
-
성능 고려사항
- 비동기 처리 활용
- 이벤트 순서 보장 필요성 검토
- 배치 처리 고려
-
모니터링과 추적
- 이벤트 발행과 처리 로깅
- 실패한 이벤트 추적
- 성능 모니터링
도메인 이벤트를 통해 시스템의 다른 부분에 상태 변경을 알리고, 이를 통해 느슨한 결합도를 유지하면서도 필요한 동작을 수행할 수 있습니다.