4.2 도메인 이벤트 구현 가이드

4.2 도메인 이벤트 구현 가이드

도메인 이벤트 구현 가이드

1. 도메인 이벤트의 이해

도메인 이벤트는 도메인 주도 설계(DDD)의 핵심 구성 요소로, 도메인에서 발생한 의미 있는 변화나 중요한 사건을 표현합니다. 이를 통해 시스템의 다른 부분들이 해당 변화에 적절히 반응할 수 있게 합니다. 도메인 이벤트는 항상 과거 시제로 명명되며, 이미 발생한 사실을 나타냅니다.

2. 기본 구현 구조

2.1. 도메인 이벤트 인터페이스

도메인 이벤트의 가장 기본적인 형태는 마커 인터페이스입니다:

public interface DomainEvent { }

시간 정보가 필요한 이벤트를 위한 확장 인터페이스를 별도로 정의할 수 있습니다:

public interface TimedDomainEvent extends DomainEvent {
    LocalDateTime occurredAt();
}

2.2. Record를 활용한 이벤트 구현

도메인 이벤트는 불변성을 보장하기 위해 record를 사용하여 구현합니다:

public record MemberRegisteredEvent(
    UserId memberId,
    LocalDateTime occurredOn
) implements TimedDomainEvent {
    public MemberRegisteredEvent {
        Objects.requireNonNull(memberId, "회원 ID는 필수입니다");
        occurredOn = Objects.requireNonNullElse(occurredOn, LocalDateTime.now());
    }
}

2.3. 이벤트 등록 기반 구조

이벤트의 등록은 도메인 모델에서 AbstractAggregateRoot 클래스를 통해 이루어집니다:

public abstract class AbstractAggregateRoot<ID> {
    private final List<DomainEvent> domainEvents = new ArrayList<>();

    protected void registerDomainEvent(DomainEvent event) {
        domainEvents.add(Objects.requireNonNull(event));
    }

    public List<DomainEvent> getDomainEvents() {
        return Collections.unmodifiableList(domainEvents);
    }

    public void clearDomainEvents() {
        domainEvents.clear();
    }
}

3. 이벤트 발행 전략

이벤트 발행은 크게 두 가지 관점에서 결정해야 합니다:

  1. 발행 위치: 저장소 vs 서비스
  2. 발행 타이밍: 트랜잭션 내 vs 트랜잭션 커밋 후

3.1. 저장소에서 발행하는 방식

저장소에서 이벤트를 발행하는 방식은 다음과 같이 구현할 수 있습니다:

public class JpaMemberRepository implements MemberRepository {
    private final JpaMemberJpaRepository jpaRepository;
    private final DomainEventPublisher domainEventPublisher;

    @Override
    @Transactional
    public Member save(Member member) {
        MemberEntity saved = jpaRepository.save(toEntity(member));

        domainEventPublisher.publishEvents(member.getDomainEvents());
        member.clearDomainEvents();
        return toModel(saved);
    }
}

장점:

  • 이벤트 발행 누락 위험 감소
  • 이벤트 발행 로직의 중앙화
  • 서비스 계층 코드 단순화

단점:

  • 단일 책임 원칙 위배
  • 복잡한 트랜잭션에서 제어의 어려움
  • 테스트 복잡도 증가

3.2. 애클리케이션에서 발행하는 방식

애클리케이션 계층에서 이벤트를 발행하는 방식입니다:

public class UpgradeMembershipCommandHandler {
    private final MemberRepository memberRepository;
    private final DomainEventPublisher domainEventPublisher;
    
    public void upgradeMembership(UserId userId) {
        Member member = memberRepository.findById(userId)
            .orElseThrow();
            
        member.upgradeTier(MembershipTier.GOLD, "연간 이용 실적");
        memberRepository.save(member);

        domainEventPublisher.publishEvents(member.getDomainEvents());
        member.clearDomainEvents();
    }
}

장점:

  • 트랜잭션 범위 내 상황 통제 가능
  • 명확한 책임 분리
  • 단순한 테스트

단점:

  • 이벤트 발행 누락 가능성
  • 중복 코드 발생 가능성

3.3. 트랜잭션 내 발행 vs 커밋 후 발행

트랜잭션 내 발행의 특징:

  • 데이터 일관성 완벽 보장
  • 즉각적인 실패 감지
  • 트랜잭션 시간 증가
  • 부가 기능 실패가 핵심 기능 실패로 이어짐

커밋 후 발행의 특징:

  • 빠른 트랜잭션 종료
  • 핵심 기능 독립성 보장
  • 실패 감지 및 대응의 어려움
  • 데이터 일관성 보장 어려움

4. 이벤트 처리 구현

4.1. 이벤트 핸들러

public class MembershipUpgradeEventHandler {
    private final NotificationService notificationService;
    private final AuditService auditService;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handle(MembershipTierChangedEvent event) {
        String message = String.format(
            "회원님의 등급이 %s에서 %s로 변경되었습니다. (사유: %s)",
            event.oldTier(),
            event.newTier(),
            event.reason()
        );
        
        notificationService.notifyMember(event.memberId(), message);
        auditService.logMembershipChange(
            event.memberId(),
            event.oldTier(),
            event.newTier(),
            event.occurredOn()
        );
    }
}

4.2. 실패 처리와 재시도

@Component
public class ResilientEventPublisher implements EventPublisher {
    @Override
    public void publishEvents(List<DomainEvent> events) {
        TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronizationAdapter() {
                @Override
                public void afterCommit() {
                    try {
                        events.forEach(eventPublisher::publishEvent);
                    } catch (Exception e) {
                        eventRetryQueue.add(events);
                        log.error("이벤트 발행 실패", e);
                    }
                }
            }
        );
    }
}

5. 권장 사항과 모범 사례

5.1. 이벤트 발행 위치

  • 기본적으로 애플리케이션 계층에서 이벤트를 발행합니다
  • 복잡한 트랜잭션이 필요한 경우 특히 중요합니다

5.2. 이벤트 발행 시점

  • 핵심 비즈니스 로직과 강한 일관성이 필요한 경우: 트랜잭션 내 발행
  • 알림, 통계 등 부가 기능: 트랜잭션 커밋 후 발행

5.3. 이벤트 데이터 설계

이벤트에 포함할 데이터의 범위를 결정하는 것은 중요한 설계 결정입니다. 크게 두 가지 접근 방식이 있으며, 각각의 장단점을 이해하고 상황에 맞는 선택을 해야 합니다.

5.3.1. 최소한의 정보만 포함하는 방식

public record MembershipTierChangedEvent(
    UserId memberId,
    MembershipTier oldTier,
    MembershipTier newTier,
    String reason,
    LocalDateTime occurredOn
) implements DomainEvent { }

이 방식의 장점:

  • 이벤트의 의도가 명확하게 드러납니다
  • 유지보수가 용이합니다
  • 이벤트 객체가 가벼워 성능상 이점이 있습니다
  • 불필요한 데이터 의존성을 줄일 수 있습니다

단점:

  • 이벤트 처리기에서 추가 정보 조회가 필요할 수 있습니다
  • 이벤트 발생 시점의 전체 상태를 나중에 확인하기 어렵습니다

5.3.2. 전체 정보를 포함하는 방식

public record MembershipTierChangedEvent(
    UserId memberId,
    Username username,
    Email email,
    FullName fullName,
    MembershipTier oldTier,
    MembershipTier newTier,
    String reason,
    int totalPurchaseAmount,
    int purchaseCountThisYear,
    LocalDateTime memberSince,
    LocalDateTime lastPurchaseDate,
    LocalDateTime occurredOn
) implements DomainEvent { }

이 방식의 장점:

  • 이벤트 처리에 필요한 모든 정보가 포함되어 있습니다
  • 이벤트 발생 시점의 정확한 상태를 보존합니다
  • 추가적인 데이터 조회가 필요 없습니다
  • 감사(audit)와 디버깅이 용이합니다

단점:

  • 이벤트 객체가 무거워집니다
  • 불필요한 정보가 포함될 수 있습니다
  • 이벤트 생성 코드가 복잡해집니다
  • 객체 크기가 커져 성능에 영향을 줄 수 있습니다

5.3.3. 권장하는 접근 방식

상황에 따라 적절한 수준의 정보를 포함하는 것이 가장 바람직합니다:

  1. 사용 문맥에 따른 설계
// 내부용 간단 버전
public record MembershipTierChangedEvent(
    UserId memberId,
    MembershipTier oldTier,
    MembershipTier newTier
) implements DomainEvent { }

// 외부 통합용 상세 버전
public record MembershipTierChangedIntegrationEvent(
    UserId memberId,
    Username username,
    Email email,
    FullName fullName,
    MembershipTier oldTier,
    MembershipTier newTier,
    String reason,
    LocalDateTime occurredOn
) implements IntegrationEvent { }
  1. 주요 사용 사례 기반 설계
// 알림 발송이 주요 사용 사례인 경우
public record MembershipTierChangedEvent(
    UserId memberId,
    Email email,        // 알림 발송에 필요
    FullName fullName,  // 알림 발송에 필요
    MembershipTier oldTier,
    MembershipTier newTier,
    String reason,
    LocalDateTime occurredOn
) implements DomainEvent { }
  1. 성능 요구사항 기반 설계
// 고성능이 필요한 경우
public record OrderCreatedEvent(
    OrderId orderId,
    UserId userId,
    BigDecimal totalAmount  // 필수 정보만 포함
) implements DomainEvent { }

// 감사가 중요한 경우
public record OrderCreatedEvent(
    OrderId orderId,
    UserId userId,
    List<OrderItem> items,  // 주문 상세 정보 포함
    Address shippingAddress,
    PaymentMethod paymentMethod,
    BigDecimal totalAmount,
    BigDecimal taxAmount,
    BigDecimal shippingFee
) implements DomainEvent { }

5.3.4. 이벤트 데이터 설계 지침

  1. 기본 원칙:

    • 이벤트의 본질적 의미를 전달하는 최소한의 정보를 기본으로 합니다
    • 시간 정보(occurredAt)는 필요한 경우에만 포함합니다
    • 명확한 이름과 불변성을 보장합니다
  2. 추가 정보 포함 기준:

    • 이벤트 처리기에서 자주 필요한 정보
    • 이벤트 발생 시점의 상태가 중요한 정보
    • 감사나 디버깅에 필수적인 정보
  3. 상황별 고려사항:

    • 시스템 내부용 이벤트는 간단하게 유지
    • 외부 시스템과 공유하는 이벤트는 더 많은 문맥 정보 포함
    • 성능이 중요한 경우 최소한의 정보만 포함
    • 감사나 규정 준수가 중요한 경우 더 많은 정보 포함

5.4. 테스트

  • 도메인 로직과 이벤트 처리를 분리하여 테스트
  • 이벤트 발행과 처리를 각각 검증
  • 실패 시나리오 고려

6. 결론

도메인 이벤트는 시스템의 다양한 부분을 느슨하게 결합하면서도 필요한 동작을 보장하는 강력한 메커니즘입니다. 이벤트의 발행 위치와 타이밍을 신중히 선택하고, 각 이벤트의 특성에 맞는 구현 방식을 사용함으로써 시스템의 안정성, 확장성, 유지보수성을 모두 확보할 수 있습니다.