Notice
Recent Posts
Recent Comments
Link
«   2024/10   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
Archives
Today
Total
관리 메뉴

코딩블로그

Spring 도메인 이벤트 (AbstractAggregateRoot)란? + 적용 본문

카테고리 없음

Spring 도메인 이벤트 (AbstractAggregateRoot)란? + 적용

_hanbxx_ 2024. 8. 21. 17:37
728x90

높은 응집도와 낮은 결합도 실현하기

소프트웨어 개발에서 높은 응집도낮은 결합도를 실현하는 것은 복잡한 비즈니스 로직을 유지보수하기 쉽게 만드는 핵심 원칙입니다. 높은 응집도는 각 모듈이나 클래스가 하나의 책임에 집중하는 것을 의미하며, 낮은 결합도는 서로 다른 모듈이나 클래스 간의 의존성을 최소화하여 시스템을 확장하고 수정하기 쉽게 만드는 원칙입니다. 이번 프로젝트 개선 작업을 통해 이를 적용해보았습니다.

스프링 이벤트 도입 배경

GDSC 프로젝트에서 준회원 승급 로직을 개선하기 위한 작업을 진행할 때, 기존 코드는 아래와 같이 구현되어 있었습니다.

  public void verifyDiscord(String discordUsername, String nickname) {
        validateStatusUpdatable();
        this.requirement.verifyDiscord();
        this.discordUsername = discordUsername;
        this.nickname = nickname;

        if (isAssociateAvailable()) {
            advanceToAssociate();
        }
    }
  /**
     * GUEST -> 준회원으로 승급됩니다.
     * 모든 조건을 충족하면 서버에서 각각의 인증과정에서 자동으로 advanceToAssociate()호출된다
     * 조건 1 : 기본 회원정보 작성
     * 조건 2 : 재학생 인증
     * 조건 3 : 디스코드 인증
     * 조건 4 : Bevy 인증
     */
    public void advanceToAssociate() {
        validateStatusUpdatable();
        validateAssociateAvailable();

        this.role = ASSOCIATE;
        registerEvent(new MemberGrantEvent(discordUsername, nickname));
    }
private boolean isAssociateAvailable() {
    if (!this.requirement.isInfoVerified()) {
        return false;
    }

    if (!this.requirement.isDiscordVerified() || this.discordUsername == null || this.nickname == null) {
        return false;
    }

    if (!this.requirement.isBevyVerified()) {
        return false;
    }

    if (!this.requirement.isUnivVerified() || this.univEmail == null) {
        return false;
    }
    return true;
}

 

이 로직은 복잡한 조건을 반복적으로 검증하며, 단일 메서드 안에서 여러 가지 비즈니스 로직과 도메인 로직이 혼합되어 있었습니다. 이로 인해 결합도가 높아지고, 유지보수가 어려워지는 문제가 있었습니다. 검증 로직이 여러 곳에 흩어져 있어 로직을 변경할 때마다 중복된 코드를 수정해야 하는 부담이 컸습니다.

이러한 문제를 해결하기 위해 스프링 이벤트 처리를 도입하여 결합도를 낮추고 관심사를 분리했습니다. 특히 Spring Data JPA에서 제공하는 AbstractAggregateRoot를 활용하여 도메인 이벤트를 처리함으로써, 비즈니스 로직과 도메인 로직을 명확히 분리할 수 있었습니다.

 

도메인 이벤트와 관심사 분리

기존에는 verify 함수에서 검증 로직과 준회원 승급 로직이 혼합되어 있었습니다. 이를 해결하기 위해 도메인 이벤트를 활용하여 관심사를 분리하였습니다. 이제 각 검증 로직은 단일 책임을 가지며, 조건이 충족될 때마다 이벤트가 발생하고, 그 이벤트를 처리하는 별도의 핸들러가 승급 로직을 수행하게 됩니다.

Spring data jpa의 Domain event는 안티패턴?

하지만 이 domain event 방식에도 단점은 존재합니다. Domain event를 트리거하려면 명시적으로 save를 호출해야 합니다. 
기본적으로 @Transactional을 사용하면 jpa의 Dirty Checking 덕분에 repository.save()를 호출하지 않고 코드를 짤 수 있는데, AbstractAggregationRoot를 통해 registerEvent로 등록해둔 이벤트를 발행하고 싶다면 꼭 반드시 명시적으로 save를 호출 해야하는 안티 패턴이 존재합니다.

 

이 이슈의 원인을 깊이 파악하기 위해 디버깅한 결과, save나 delete 메서드를 명시적으로 호출할 때 발생하는 동작을 살펴보면 다음과 같았습니다.

EventPublishingRepositoryProxyPostProcessor에서는 도메인 이벤트를 처리하기 위해 registerEvent로 등록된 이벤트들을 추적하고, 그와 관련된 것들을 스프링에 등록하는 역할을 수행합니다. 즉, 저장(save)이나 삭제(delete) 메서드가 호출되면 이 프로세서가 해당 메서드가 호출되는지를 ReflectionUtil을 활용하여 확인하고, 이를 스프링 이벤트 시스템에 등록하여 처리 과정을 이어나가 이벤트가 발행되게끔 합니다.

그럼에도 불구하고..

Spring Data JPA에서 제공하는 registerEvent()와 AbstractAggregateRoot를 사용하는 방식은 공식적인 메커니즘으로, 안티패턴으로 보일 수 있는 부분이 있더라도 충분히 장점을 제공합니다. 

만약 ApplicationEventPublisher로 도메인 이벤트를 직접 발행하는 방식으로 구현할 경우, 모든 이벤트가 제대로 이벤트 큐에 잡히지 않을 가능성에 대비한 추가적인 로직을 구현해야 합니다. 이러한 추가 작업은 비즈니스 로직을 구현하는 데 집중해야 할 리소스를 소모하게 됩니다.

반면, Spring Data JPA의 registerEvent()와 save 메서드를 활용한 도메인 이벤트 방식은 이미 검증된 방법이며, 트랜잭션 내에서 안전하게 이벤트를 처리할 수 있는 구조를 제공합니다. 이로 인해, 비즈니스 로직에 더 집중할 수 있고, 이벤트 처리에 대한 복잡한 예외 처리나 큐 관련 코드를 따로 작성할 필요가 없다는 큰 이점을 얻을 수 있습니다.

따라서 이벤트 처리의 안정성을 확보하면서도, 개발 생산성을 극대화할 수 있다는 판단 때문에 저희는 Domain Event를 활용하여 개발을 진행하였습니다.

적용 과정

기본설정

먼저, abstractAggregationRoot를 적용하기 위해서는 원하는 도메인 엔티티 선언부에 해당 코드를 추가해줘야 합니다.

extends AbstractAggregateRoot<>

 

코드 개선

관심사 분리하기

기존에는 하나의 verify함수안에 (verify하기 + 준회원 승급 여부 판단하기)라는 관심사가 혼합되어 있었다면,

관심사 분리를 위해 verify를 하면 이벤트를 발행하고 Application Context가 이를 넘겨 받아 Listener가 받아 처리하게 만들었습니다.

즉, 가입 조건이 총 4개가 있는데 각각의 조건을 수행할 때마다 함수로 또 검증하는 것이 아닌, 이벤트 처리를 통해 로직이 느슨하게 결합될 수 있도록 만들었습니다.

event handler에서, 즉 비즈니스 로직에서 해당 이벤트가 호출되면 밑에 적어둔 로직만을 처리하게끔MemberAssociateEventListener와 MemberAssociateEventHandler에 선언해 두었습니다. 

모든 준회원 승급 조건을 만족했는지 체크 -> 만족했다면 승급

 

(관심사 분리 -> 준회원 승급 조건이 무엇인지 알 필요도 없고 exception을 던지는 로직 또한 필요가 없다.

이런 예외처리 로직들은 도메인 로직에서 따로 처리해주면 되는 것이다. 오로지 이 Handler는 "승급"에만 관심사를 두게끔 분리를 한것이다)

 

@Slf4j
@Component
@RequiredArgsConstructor
public class MemberAssociateEventHandler {
    private final MemberRepository memberRepository;

    public void advanceToAssociate(MemberAssociateEvent memberAssociateEvent) {
        Member member = memberRepository
                .findById(memberAssociateEvent.memberId())
                .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND));
        try {
            member.advanceToAssociate();
        } catch (CustomException e) {
            log.info("{}", e.getErrorCode());
        }
    }
}

 

 

@RequiredArgsConstructor
public class MemberAssociateEventListener {

    private final MemberAssociateEventHandler memberAssociateEventHandler;

    @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT, classes = MemberAssociateEvent.class)
    public void handleMemberAssociateEvent(MemberAssociateEvent event) {
        memberAssociateEventHandler.advanceToAssociate(event);
    }
}

 

코드 전체 흐름

  1. 도메인 객체에서 이벤트 등록:
    도메인 객체가 상태 변화를 감지하면 AbstractAggregateRoot의 registerEvent() 메서드를 통해 도메인 이벤트를 등록합니다. 이때, 이벤트는 트랜잭션 내에서 큐에 저장됩니다. 즉, 실제로 이벤트가 발행된 것은 아니며, 트랜잭션이 커밋될 때까지 대기합니다.
  2. 트랜잭션 커밋 시점:
    Spring Data JPA는 트랜잭션이 커밋되기 직전에 AbstractAggregateRoot에 등록된 도메인 이벤트를 추적하고, 이 이벤트를 발행합니다. 중요한 점은, 이 이벤트는 트랜잭션이 성공적으로 커밋되기 전까지 발행되지 않습니다. 즉, 트랜잭션이 롤백되면 이벤트는 발행되지 않습니다.
  3. 이벤트 발행:
    트랜잭션이 커밋되기 직전, 스프링 이벤트 시스템에 의해 등록된 이벤트가 실제로 발행됩니다. 이때 이벤트는 트랜잭션의 상태에 따라 처리됩니다.
  4. 이벤트 핸들링:
    @TransactionalEventListener로 등록된 리스너는 발행된 이벤트를 감지합니다. 이때, 리스너가 언제 이벤트를 처리할지는 @TransactionalEventListener에 설정된 TransactionPhase에 따라 달라집니다.
    1. TransactionPhase.BEFORE_COMMIT: 트랜잭션이 커밋되기 직전에 이벤트를 처리합니다.
    2. TransactionPhase.AFTER_COMMIT: 트랜잭션이 성공적으로 커밋된 후에 이벤트를 처리합니다.
    3. TransactionPhase.AFTER_ROLLBACK: 트랜잭션이 롤백된 후에 이벤트를 처리합니다.
    4. TransactionPhase.AFTER_COMPLETION: 트랜잭션이 종료된 후(성공 또는 실패) 이벤트를 처리합니다.
  5. 일반적으로, 비즈니스 로직은 트랜잭션이 성공적으로 커밋된 이후(TransactionPhase.AFTER_COMMIT)에 수행됩니다. 하지만, 제가 구현한 상황처럼 특정 상황에서는 트랜잭션이 커밋되기 전(TransactionPhase.BEFORE_COMMIT)에 로직을 처리해야 할 수도 있습니다.
순서 정리
1단계: 도메인 객체에서 이벤트 등록 (registerEvent()).
2단계: 트랜잭션 커밋 전 대기.
3단계: 트랜잭션 커밋 시점에서 이벤트 발행.
4단계: @TransactionalEventListener가 트랜잭션 단계에 맞춰 이벤트를 감지하고 비즈니스 로직을 처리.

 

TransactionPhase.BEFORE_COMMIT을 사용한 이유

  • 이벤트 핸들링 시점: 트랜잭션이 커밋되기 직전에 이벤트를 처리합니다.
  • 트랜잭션 상태 반영: 이벤트 핸들러에서 처리된 로직의 결과가 트랜잭션 커밋의 상태에 영향을 미칠 수 있습니다. 즉, 이벤트 핸들러에서 발생하는 예외가 트랜잭션 롤백을 유발할 수 있으며, 상태 변경이 트랜잭션 커밋의 일부로 간주됩니다. 특히 이벤트를 통해 객체의 상태가 변화가 되는 로직에서는 이벤트 핸들러에서 발생하는 예외를 롤백시켜야 하기때문에 BEFORE_COMMIT을 사용하였습니다.
  • 장점: 상태 변화가 트랜잭션의 일부분으로 간주되어 데이터 일관성이 유지됩니다. 트랜잭션이 커밋되기 전 상태를 확정할 수 있습니다.
  • 적용 이유: 회원 승급 과정에서 승급 여부를 결정해야 하고, 이 결과가 트랜잭션 커밋에 영향을 미쳐야 하기 때문입니다.

 

테스트 코드

@Slf4j
public class MemberIntegrationTest extends IntegrationTest {
    @Autowired
    private MemberAssociateEventHandler memberAssociateEventHandler;

    @Test
    void 준회원_승급조건_만족됐으면_MemberRole은_ASSOCIATE이다() {
        // given
        Member member = createMember();

        // when
        memberAssociateEventHandler.advanceToAssociate(new MemberAssociateEvent(member.getId()));
        member = memberRepository.save(member);

        // then
        assertThat(member.getRole()).isEqualTo(ASSOCIATE);
    }
}

 

 

728x90