DDD(도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지) 책을 읽다가 도메인 이벤트라는 기술을 알게 되었다. 항상 새로운 기술이 등장하면 내가 하고 있는 프로젝트에는 어떻게 적용할 수 있을지 고민하면서 읽기 때문에 개발하고 있는 프로젝트에 한 번 적용해 보기로 하였다.
도메인 이벤트는 도메인의 상태 변화가 발생했을 때 발생하는 이벤트를 말한다. 예를 들면 사용자가 회원 가입을 했다면 이메일을 전송하는 상황이다. 이러한 이벤트는 복잡한 비즈니스 로직을 분리할 수 있게 해준다.
이벤트 생성 주체가 이벤트를 발행하면 이벤트 핸들러가 처리하는 구조로 중간에 이벤트 디스패처가 이벤트를 처리할 수 있는 핸들러에게 이벤트를 전파한다.
그렇다면 가자맵에서는 어떻게 적용해야 될까?
사용자 권한에 따라 생성할 수 있는 그룹의 수가 제한되어 있다. 그래서 그룹을 생성 및 삭제할 경우 User필드에 있는 group_count는 증가 및 감소해야 된다.
하지만 응용 서비스 메소드에 2개의 도메인 로직이 있다는 것은 복잡성을 증가시키고 가독성을 저해한다.
public class GroupService {
...
public Long create(Long userId, GroupCreateRequest request) {
User user = findByEmailAndActiveWithLock(userRepository, userId);
Group group = groupCommandService.create(request.getName(), user);
groupRepository.save(group);
user.increaseGroupCount()
return group.getId();
}
...
}
GroupService는 응용 서비스로 그룹을 생성하는 상황이다.
주 관심사는 그룹 생성인데 User의 도메인 로직도 포함하고 있다. 이를 이벤트로 분리한다면 그룹을 생성하면 이벤트를 발행 후 이벤트 핸들러에서 User의 group_count를 증가시키는 것으로 해결할 수 있다.
구현은 DDD 책을 참고하였다. 그럼 스프링에서 이벤트 발생과 출판을 제공하는 ApplicationEventPublisher와 AbstractAggregateRoot 를 사용하여 비교해보자.
ApplicationEventPublisher는 디스패처 역할로 이벤트를 발행시키면 중간에 디스패처가 이벤트를 처리할 이벤트 핸들러에게 제공한다.
@Component
public class Events {
private static ApplicationEventPublisher publisher;
@Autowired
private Events(ApplicationEventPublisher publisher) {
Events.publisher = publisher;
}
public static void raise(Object event) {
publisher.publishEvent(event);
}
}
도메인에서 ApplicationEventPublisher 를 매개변수로 받거나 직접 의존해서 이벤트를 발행할 수 있지만 위와 같이 Events 클래스에서만 ApplicationEventPublisher를 의존받아 static으로 이벤트 발행을 제공하면 여러 응용 서비스나 도메인 서비스에서 ApplicationEventPublisher 를 굳이 직접적으로 참조 안 해도 된다. 그러면 도메인에서 매개변수로 받을 필요 없이 이벤트 발행할 수 있게 된다.
@Getter
public class GroupCreatedEvent {
private final User user;
public GroupCreatedEvent(User user) {
this.user = user;
}
}
그런 다음 이벤트 클래스를 만들었다. 이벤트는 과거에 벌어진 상태 변화나 사건을 의미하므로 이름은 과거 시제를 사용해야 한다고 한다.
@Service
public class GroupCommandService {
private final ApplicationEventPublisher publisher;
public Group create(String groupName, User user) {
user.checkCreateGroupPermission();
publisher.publishEvent(new GroupCreatedEvent(user));
return new Group(groupName, user);
}
}
책에선 이벤트 생성 주체는 엔티티, 벨류, 도메인 서비스와 같은 도메인 객체라고 한다. 그러면 꼭 엔티티에서 이벤트를 발행하지 않아도 된다는 것이므로
사용자 권한을 확인 후 그룹을 생성하는 GroupCommandService 도메인 서비스에서 이벤트를 발행하였다.
@Service
public class GroupEventListener {
@EventListener(GroupCreatedEvent.class)
public void create(GroupCreatedEvent event) {
User user = event.getUser();
user.increaseGroupCount();
}
}
그러면 이벤트 디스패처가 GroupCreatedEvent.class값을 갖는 @EventListener가 붙인 메서드를 찾아 실행하게 된다.
AbstractAggregateRoot는 DDD를 구현하기 편리하게 해주는 이벤트를 발행하는 기능을 포함하고 있으며 도메인 로직 내에서 이벤트를 발행하는데 사용된다. 내부적으로는 ApplicationEventPublisher 를 사용하고 있긴 하지만 직접 의존하지 않아도 된다는 장점이 있는 것 같다.
public class Group extends AbstractAggregateRoot {
...
public Group(String name, User user) {
this.name = name;
this.user = user;
clientCount = 0;
isDeleted = false;
registerEvent(new GroupCreatedEvent(user));
}
...
}
AbstractAggregateRoot를 상속받고 registerEvent()로 이벤트를 등록하면 되는데 이때 이벤트가 발행하는 것이 아닌 spring-data-jpa 의 save, saveAll, delete, deleteAll 메소드를 호출해야지 이벤트가 발행된다는 특징이 있다.
가자맵에서는 위 예시 중 여러 도메인에서 공동으로 사용하기 위해 Events클래스에서 이벤트 발행을 static 으로 제공하였다.
AbstractAggregateRoot를 사용하지 않은 이유는 삭제를 할 때 실제로 삭제하는 것이 아닌 필드에 isDeleted를 true로 해주는데 이때 save를 호출한다는 것이 의미상 모호하기 때문이다.
이벤트 처리는 비동기로도 처리할 수 있다.
동기로 하면 단점이 사용자 입장에선 그룹 생성이 중요하지 User 의 group_count가 어떻게 되든 상관없는데 User쿼리 때문에 불필요하게 트랜잭션을 길게 잡고 있어 응답을 늦게 받는다.
따라서 비동기로 처리하면 속도 면에서 빠르며 하나의 트랜잭션에서 도메인의 관심사를 분리할 수 있게 된다.
물론 비동기에도 단점이 있다. 각각 다른 트랜잭션으로 처리하기 때문에 이벤트 쪽에서 실패한다면 롤백 처리가 힘들어진다는 것이다.
이러한 이유로 그룹 생성에 대한 이벤트는 가장 심플하면서 데이터 일관성을 문제가 발생하지 않는 동기를 선택하였다.
https://www.baeldung.com/spring-events
https://docs.spring.io/spring-modulith/reference/events.html
https://junuuu.tistory.com/832
https://jeong-pro.tistory.com/250
https://product.kyobobook.co.kr/detail/S000001810495