[가자맵] 도메인 이벤트 관심사 분리

김상인·2024년 1월 14일
1

가자맵

목록 보기
7/8
post-thumbnail

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는 응용 서비스로 그룹을 생성하는 상황이다.

  • groupCommandService는 사용자 권한 확인 후 그룹을 생성하는 도메인 서비스
  • user.increaseGroupCount()는 그룹이 생성되면 User의 group_count를 증가

주 관심사는 그룹 생성인데 User의 도메인 로직도 포함하고 있다. 이를 이벤트로 분리한다면 그룹을 생성하면 이벤트를 발행 후 이벤트 핸들러에서 User의 group_count를 증가시키는 것으로 해결할 수 있다.

구현

구현은 DDD 책을 참고하였다. 그럼 스프링에서 이벤트 발생과 출판을 제공하는 ApplicationEventPublisherAbstractAggregateRoot 를 사용하여 비교해보자.

ApplicationEventPublisher

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

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를 호출한다는 것이 의미상 모호하기 때문이다.

동기 or 비동기?

이벤트 처리는 비동기로도 처리할 수 있다.
동기로 하면 단점이 사용자 입장에선 그룹 생성이 중요하지 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

profile
백엔드 희망자

0개의 댓글

관련 채용 정보