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쿼리 때문에 불필요하게 트랜잭션을 길게 잡고 있어 응답을 늦게 받는다.
따라서 비동기로 처리하면 속도 면에서 빠르며 하나의 트랜잭션에서 도메인의 관심사를 분리할 수 있게 된다.
물론 비동기에도 단점이 있다. 각각 다른 트랜잭션으로 처리하기 때문에 이벤트 쪽에서 실패한다면 롤백 처리가 힘들어진다는 것이다.
가자맵에서 그룹을 생성할 때는 동기를 선택하였다. 그 이유는 다음과 같다.
사용자 권한에 따라 그룹을 생성할 수 있는 수가 제한되어 있다. 그래서 그룹을 생성하기 전 User의 권한과 group_count를 확인하고 그룹을 생성해야 한다.
그룹 생성 시 User의 group_count 증가를 비동기로 처리한다면
만약 사용자 권한에 따라 그룹은 1개만 생성할 수 있고 그룹 생성을 동시에 여러 번 요청했을 때 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