이 연재글은 Spring Domain Event 발행 실습의 2번째 글입니다.
  • SpringBoot Events
  • SpringBoot @DomainEvent, AbstractAggregateRoot 이용한 Domain Event 발행

이번 실습에서는 도메인 중심 설계에서 Aggregate에 의해 생성된 Domain Event를 편리하게 게시하고 처리하는 방법에 대해 설명합니다. 스프링에서 제공하는 @DomainEvent, AbstractAggregateRoot를 사용하면 간편하게 이벤트를 연동할 수 있습니다. 실습 진행을 위해 jpa와 h2를 프로젝트 dependencies에 추가합니다.

build.gradle

plugins {
	id 'org.springframework.boot' version '2.4.3'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	id 'java'
}

group = 'com.daddyprogrammer'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

test {
	useJUnitPlatform()
}

Aggregate / Repository 작성

실습에서는 User Aggregate를 작성하고 해당 Aggreate에 수정이 발생할 경우 이벤트를 발행해보겠습니다.

User

다음과 같이 User Aggregate Entity를 작성하고 나이를 한 살 증가시키는 간단한 Domain Operation을 작성합니다.

package com.daddyprogrammer.springevents.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.time.LocalDateTime;

@Getter
@Builder
@AllArgsConstructor
@Table(name = "user")
@Entity
public class User {

    public User() {}

    @Id // pk
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long userNo;
    private String userId;
    private int age;
    private LocalDateTime created;

    // domain operation
    public void addAge() {
        this.age++;
    }
}

UserRepository

package com.daddyprogrammer.springevents.repository;

import com.daddyprogrammer.springevents.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUserId(String userId);
}

Service에서 수동으로 Event 게시하기

도메인 이벤트를 게시할 수 있는 부분은 2가지가 존재하는데 첫 번째는 서비스 레이어에 게시하는 것이고 두 번째는 Aggregate안에 게시하는 것입니다. 아래와 같이 도메인 서비스 레이어에서 Repository save를 진행하고 간단하게 event를 게시할 수 있습니다.

DomainService

@RequiredArgsConstructor
@Service
public class DomainService {

    private final UserRepository userRepository;
    private final CustomEventPublisher customEventPublisher;

    @Transactional
    public void addAge(String userId) {
        userRepository.findByUserId(userId)
                .ifPresent(entity -> {
                    entity.addAge();
                    userRepository.save(entity);
                    customEventPublisher.publish("user age is "+entity.getAge());
                });
    }
}

다음 코드로 이벤트 검증을 수행합니다.

유저 정보 저장 -> 서비스 레이어에서 유저의 나이를 한 살 증가 + 이벤트 발행 -> CustomEventLister가 1회 동작하는지 확인

package com.daddyprogrammer.springevents.service;

import com.daddyprogrammer.springevents.entity.User;
import com.daddyprogrammer.springevents.event.CustomEvent;
import com.daddyprogrammer.springevents.event.CustomEventListener;
import com.daddyprogrammer.springevents.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;

import java.time.LocalDateTime;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;

@SpringBootTest
class DomainServiceTest {

    @Autowired
    private UserRepository userRepo;

    @Autowired
    private DomainService domainService;

    @MockBean
    private CustomEventListener customEventListener;

    @Test
    void serviceEventTest() {
        User user = User.builder().userNo(1).userId("happydaddy").age(30).created(LocalDateTime.now()).build();
        userRepo.save(user);

        // when
        domainService.addAge(user.getUserId());

        // then
        Mockito.verify(customEventListener, times(1)).onApplicationEvent(any(CustomEvent.class));
    }
}

Aggregate 내에서 Domain event 게시하기

이번에는 서비스 레이어가 아닌 Domain Operation이 발생한 Aggregate안에서 Domain Event를 게시해보겠습니다. 그런데 User Aggregate의 경우 Plain Java이므로 ApplicationEventPublisher를 주입받기가 어렵습니다. 이를 위해 Spring에서는 @DomainEvent 어노테이션을 지원합니다. 아래와 같이 작성하면 ApplicationEventPublisher에 이벤트를 발행할 수 있습니다.

간단히 설명하면 도메인 이벤트 발행을 위한 CustomEvent 목록을 만들고 이벤트를 하나 발행하면 @DomainEvent 어노테이션에 의해 해당 Event 목록이 ApplicationEventPublisher에 발행되는 것입니다.

도메인 이벤트가 발행된 후 @AfterDomainEventsPublication 어노테이션이 달린 메서드가 호출됩니다. 모든 이벤트 목록을 지움으로써 향후 중복으로 이벤트가 발행되는 것을 막습니다.

User

@Getter
@Builder
@AllArgsConstructor
@Table(name = "user")
@Entity
public class User {

    @Transient
    private final Collection<CustomEvent> customEvents;

    public User() {
        customEvents = new ArrayList<>();
    }

    @DomainEvents
    public Collection<CustomEvent> events() {
        return customEvents;
    }

    @AfterDomainEventPublication
    public void clearEvents() {
        customEvents.clear();
    }

    @Id // pk
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long userNo;
    private String userId;
    private int age;
    private LocalDateTime created;

    // domain operation
    public void addAge() {
        this.age++;
        customEvents.add(new CustomEvent(this,"user age is "+this.age));
    }
}

DomainService에서 수동으로 이벤트를 발행하는 코드를 제거하고 위에서 작성한 Test 코드를 다시 수행하면 이벤트 발행이 성공하는 것을 확인할 수 있습니다.

@RequiredArgsConstructor
@Service
public class DomainService {

    private final UserRepository userRepository;
    private final CustomEventPublisher customEventPublisher;

    @Transactional
    public void addAge(String userId) {
        userRepository.findByUserId(userId)
                .ifPresent(entity -> {
                    entity.addAge();
                    userRepository.save(entity);
// 삭제 또는 주석   customEventPublisher.publish("user age is "+entity.getAge());
                });
    }
}

AbstractAggregateRoot Template 사용

@DomainEvents를 사용하면 Aggregate에 작성해야 하는 코드가 많아지는데 AbstractAggregateRoot를 사용하면 작성해야 하는 코드를 매우 단순화할 수 있습니다. 아래와 같이 AbstractAggregateRoot를 상속받고 registerEvent를 통해 이벤트가 등록되도록 처리만 하면 됩니다. 보시다시피 훨씬 적은 코드를 생성하고 정확히 동일한 효과를 얻을 수 있습니다.

User

@Getter
@Builder
@AllArgsConstructor
@Table(name = "user")
@Entity
public class User extends AbstractAggregateRoot<User> {

    public User() {
    }

    @Id // pk
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long userNo;
    private String userId;
    private int age;
    private LocalDateTime created;

    // domain operation
    public void addAge() {
        this.age++;
        registerEvent(new CustomEvent(this,"user age is "+this.age));
    }
}

실습을 통해 Aggregate 도메인 이벤트를 관리하는 몇 가지 방법을 알아보았습니다. 이 접근 방식은 이벤트 인프라를 크게 단순화하여 도메인 로직에만 집중할 수 있도록 해줍니다.

실습에서 사용한 코드는 아래 Github에서 확인할 수 있습니다.
https://github.com/codej99/spring-event

연재글 이동[이전글] SpringBoot Events