5.04 도메인 이벤트 구현

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)
                    ));
                });
    }
}

이벤트 처리 시 주의사항:

  1. 트랜잭션 관리

    • 이벤트 핸들러는 트랜잭션 커밋 후 실행
    • 실패 시 재시도 메커니즘 구현
    • 멱등성 보장
  2. 이벤트 설계

    • 과거 시제 사용
    • 필요한 컨텍스트 정보 포함
    • 불변 객체로 구현
  3. 성능 고려사항

    • 비동기 처리 활용
    • 이벤트 순서 보장 필요성 검토
    • 배치 처리 고려
  4. 모니터링과 추적

    • 이벤트 발행과 처리 로깅
    • 실패한 이벤트 추적
    • 성능 모니터링

도메인 이벤트를 통해 시스템의 다른 부분에 상태 변경을 알리고, 이를 통해 느슨한 결합도를 유지하면서도 필요한 동작을 수행할 수 있습니다.