엔티티 구성요소 - 3 DomainEvent사용

엔티티 구성요소 - 3 DomainEvent사용

JPA의 기본 사항을 정리한다.

사용해보기

@DomainEvent, AbstractAggregateRoot class를 사용해 Aggregate에서 생성된 도메인 이벤트를 편리하게 발행하고 처리하는 방법을 알아보자.

이벤트 등록 방법 1-1.@DomainEvent

@DomainEvent가 달린 ‘메서드’는 엔티티가 스프링 Data Repository를 통해 save될 때 마다 자동으로 호출된다.
@DomainEvent로 반환된 이벤트들은 ‘ApplicationEventPublisher’를 통해 발행된다.

@Entity
public class Aggregate{
    @Transient
    private final List<DomainEvent> domainEvents;
    
    public void 비즈니스로직(){
      //... 상태 변경 할 일
      domainEvents.add(new DomainEvent());        
    }
    
    @DomainEvents
    public List<DomainEvent> getDomainEvents() {
        return domainEvents;
    }//비즈니스 로직에 의해 등록된 이벤트를 발행해달라고 전달, repository의 save에 감응한다.
  
    @AfterDomainEventPublication
    public void clearEvent(){
        domainEvents.clear();
    }//이벤트 발행 후 중복 발행 방지를 목적으로 주로 비워주는 역할을 담당한다.
    
}

이벤트 등록 방법 1-2. AbstractAggregateRoot

AbstractAggregateRoot를 상속받아 도메인 이벤트를 발행하는 메서드를 구현하면 더 간단하게 구현할 수 있다.
새로운 도메인 이벤트를 컬렉션에 추가하기 위해 ‘register’ 메서드를 호출하면 된다.

@Entity
public class Aggregate extends AbstractAggregateRoot<Aggregate>{
    public void 비즈니스로직(){
        //... 상태 변경 할 일
        registerEvent(new DomainEvent());
    }
}

앞의 방법1과 동일한 동작을 한다. 보일러 플레이트 코드를 줄일 수 있다.

@TransactionalEventListener의 종류

@TransactionalEventListener로 이벤트를 구독하여 처리하는 메서드에 선언해줄 수 있다.
관리 포인트는 트랜잭션과 동기/비동기 이다.

  • @TransactionalEventListener의 종류
    • AFTER_COMMIT : 트랜잭션이 성공적으로 커밋된 후에 이벤트를 발행한다.
    • AFTER_ROLLBACK : 트랜잭션이 롤백된 후에 이벤트를 발행한다.
    • AFTER_COMPLETION : (트랜잭션이 완료 or 롤백)된 후에 이벤트를 발행한다.
    • BEFORE_COMMIT : 트랜잭션이 커밋되기 전에 이벤트를 발행한다.

[BAD] 이벤트 처리 방법 2-1. 동기식 AFTER_COMMIT

동기식으로 AFTER_COMMIT을 사용하면 트랜잭션이 성공적으로 커밋된 후에 이벤트를 발행한다.
성능상 안좋은 상황이 발생하는데 트랜잭션 동작 방식을 알아보자.

    
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void handleCheckReviewEvent(CheckReviewEvent event){
        }

호출자 트랜잭션이 이미 COMMIT이 완료된 상황이라 이후 쓰기작업이 발생한다면 상태변경이 되지 않아 @Transactional(propagation = Propagation.REQUIRES_NEW)로 새로 열어줘야한다.

TransactionPhase

시나리오 : bookReview가 save된 후, 이벤트 발행. 이후 bookReview의 정합성을 판단하여 ACCEPT or REJECT로 상태 변경

  • 이벤트 호출자인 bookReview#save 호출하는 bookService#create 메서드 Transaction 실행 #1
    • insert 쿼리 실행
    • commit #1
    • handleCheckReviewEvent 호출, Transaction 생성 #2
      • bookReview 체크 / 상태 변경
      • bookReview#save 호출
      • commit #2
      • update 쿼리 실행
      • #2 Transaction 종료
  • #1 Transaction 종료

#2가 끝나기 전에 #1이 끝나지 않으므로 장기 점유 트랜잭션이 2개가 생긴다. 상당히 별로

[GOOD] 이벤트 처리 방법 2-2. 비동기 @Async

트랜잭션 단위를 잘 잘라보자. 리뷰의 생성 시점과 리뷰의 검증 시점을 분리하여 비동기로 처리한다.
사용자 입장에서 리뷰가 검증된 이후에나 생성완료 응답을 받을 이유는 없다.
사용자는 그냥 리뷰생성만 하면 장땡이기 때문

    //사전에 컨피규레이션에 @EnableAsync로 활성화 시켜줘야한다.
    @Async
    @TransactionalEventListener
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void handleCheckReviewEvent(CheckReviewEvent event){
        }

TransactionPhase

  • 이벤트 발행 관련 메서드 Transaction #1
    • insert 쿼리 실행
    • commit #1
  • #1 Transaction 종료
  • 이벤트 처리 관련 메서드 Transaction #2
    • 리뷰 정합성 체크로직
    • commit #2
    • update 쿼리 실행 (db 커넥션은 실제 사용 직전에 획득하기 때문에 db 커넥션 절약 이점이 있다.)
  • #2 Transaction 종료

트랜잭션 단위를 잘게 쪼개놨기 때문에 각각 의존성 없이 알아서 종료되면 대기한다.

기본 동작으로 eventHandler는 호출자의 Thread에서 실행되기 때문에,@Async를 선언해줘 비동기로 실행할 경우 별도의 쓰레드 할당이 필요하다.

한 트랜잭션에서 커밋이 된다는 의미는 이후 상태를 더이상 바꾸거나 손댈 수 없어진다는 의미와 같다.
따라서 변경을 추가적으로 원하면 새 트랜잭션을 열고 처리해야 한다.


동작에 대한 주의사항

  • 변경사항을 저장하기 위해 반드시 save()를 사용하는 것이 아니기 때문에 SpringDataRepository를 통해 save()를 호출해야만 발행된다는 점을 기억해야 한다.
    • DirtyChecking 변경만 할 때 이벤트 발행이 되지 않을 수도 있음을 주의해야 한다.
  • 발행 중 예외가 발생하면, 이벤트는 그냥 소실된다.
    • Spring개발팀도 인지하고 있는 문제
  • 도메인 이벤트는 간단한 ApplicationEventPublisher 인터페이스를 다른 분산 클라이언트/시스템이 알림을 받길 원하면 이벤트를 메세지브로커(예: RabbitMQ, Kafka)로 전달하는 방법을 사용해야 한다.
    • 아니면 서드파티 솔루션 (예: Axon Framework, Spring Integration)을 사용해야 한다.

인프라를 크게 단순화 시켜서 도메인 로직에 집중할 수 있는 장점이 있다.

Handler 주의사항

비동기 실행시

  • 비동기에 적합한 작업할 경우, AFTER_COMPLETION(COMMIT or ROLLBACK)과 함께 Handler 사용을 추천한다.
  • DB에 읽기, 쓰기를 하지 않는 경우 @Transactional설정을 제외하는게 좋다.
    • 새 트랜잭션을 실행하지 않아야 한다.
  • 반면, 읽기만 하는 경우 @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW)를 사용한다.
  • 쓰기 작업이 발생하는 경우 @Transactional(propagation = Propagation.REQUIRES_NEW)를 사용한다.
  • @TransactionalEventListener의 BEFORE_COMMIT 설정인 경우, 이벤트 호출자의 commit 이전에 비동기 작업이 마무리된다는 보장이 없으니 사용하지 않는다.
    • 이벤트 호출자가 먼저 commit하게 되는 경우 이후 핸들러의 엔티티 상태 수정 작업이 반영되지 않는다.

동기 실행시

  • 가급적 트랜잭션단위를 쪼개서 비동기가 가능해지게 수정한다.
    • 잘 해줘봐야 장기점유 트랜잭션이 1~2개 생기고 DB 커넥션도 유지하기 때문에 성능상 이점이 없다.
  • BEFORE_COMMIT을 쓰면 Transaction 하나에 작업을 진행할 수 있다.
    • 대신 어느구간에서든 실패하면 전체 ROLLBACK이 일어난다.
  • 이벤트 핸들러가 간단하고 짧을 때 AFTER_COMPLETION을 사용한다.
    • 열려있는 영속성 context를 바로 읽기 때문에 간단한 작업을 하기 좋다.

JPA 콜백 (@DomainEvent 사용시 인지할 것 1)

JPA 콜백은 Application이 영속성 메커니즘 내에서 발생되는 특정 이벤트에 대응하도록 지시하는데 사용된다.

  • @PrePersist
  • @PreRemove
  • @PreUpdate
  • @PostPersist
  • @PostRemove
  • @PostUpdate
  • @PostLoad

비동기 이벤트 동작과 JPA 콜백은 어떻게 상호작용될까?

대충  JPA 콜백으로 이벤트 상황 발생시 log 찍어보는 내용. 추후에 테스트해서 올리겠읍니다.

비영속 필드 선언 (@DomainEvent 사용시 인지할 것 2)

영속 필드와 비영속 필드란?

  • 영속 필드 : 데이터베이스에 저장되고 JPQL 쿼리에서 사용할 수 있는 필드
  • 비영속 필드 : 데이터베이스에 저장되지 않고 동기화되지 않는다. JPQL또한 사용 불가하다.
    • 주로 임시 Data, 계산된 값을 저장하는데 사용된다.

비영속 필드 선언 방법

  1. @Transient로 getter 생성하기
    • 메서드가 매번 계산되기 때문에 호출될 때 마다 오버헤드가 있다.
    • @Transient
      public int getCalculatedValue() {
          return this.value * 2;
      }
      
  2. 필드에 @Transient + @PostLoad 콜백 메서드 선언
    • 필드에 @Transient를 선언하고, @PostLoad 콜백 메서드를 선언하여 필드를 초기화한다.
    • 최초 로드시에만 초기화되기 때문에 성능상 이점이 있다.
    • @Transient
      private int calculatedValue;
           
      @PostLoad
      private void initCalculatedValue() {
          this.calculatedValue = this.value * 2;
      }
      
  3. @Formula로 Sql에서 산출하기
    • @Formula 어노테이션을 사용하여 SQL에서 필드를 계산하고, 결과를 엔티티에 매핑한다.
    • @Formula("price - price * 0.25")
      private double discountedValue;
           
      @Transient
      public double getDiscounted(){
          return discountedValue;
      }
      
    • select
             .
             .
             .
      price - price * 0.25 as formula0
      from book book0
      

이벤트로 호출된 경우에도 비영속 필드 사용 시 잘 동작하는가? 고려해봐야 한다.


스프링 도메인 이벤트 구현 방식 (@DomainEvent 사용시 인지할 것 3)

스프링 도메인 이벤트는 옵저버 패턴 기반으로 구현되어 있다.

옵저버 패턴

한 객체의 상태가 변경되어 다른 객체들을 변경해야 할 필요성이 있을 때, 사용한다.
변경되어야 하는 객체들은 런타임시 유동적으로 변경이 가능하고, 결합도를 낮출 수 있는 장점이 있다.
DDD에서 사용하기 유용하기에 스프링에서도 이를 지원한다.

옵저버 패턴 구현 방법

  1. 다른 코드와 독립적인 ‘Publisher’ 인터페이스를 선언한다. 구독자 리스트 추가/제거 메서드를 선언한다.
  2. ‘Subscriber’ 인터페이스를 선언하는데 ‘update’같이 변경시 이벤트 발행에 반응해 처리할 메서드를 선언한다.
  3. ‘Publisher’ 객체는 ‘Subscriber’ 객체를 등록하고, 이벤트 발생시 ‘Subscriber’ 객체들에게 알린다.
  4. 실제 ‘Subscriber’ 구현체들을 생성한다. 구독에 대한 등록은 추상클래스를 정의하여 공통으로 빼는게 좋다.

장점

  • OCP로 새 구독자를 추가하기가 쉽다.
  • 결합도가 낮아진다
  • 런타임시에 관계 형성이 가능해진다.

단점

  • 구독자들이 무작위로 알림을 받는다.

옵저버 패턴과 도메인 이벤트 상황 대입

  • Subject : BookReview extends AbstractAggregateRoot<BookReview> 처럼 상태 변경에 대해 감시하는 대상
    • @Entity
      public class BookReview extends AbstractAggregateRoot<BookReview> implements Serializable {
      public void registerReviewEvent() { registerEvent(new CheckReviewEvent(this)); }}
      
  • Publisher : ApplicationEventPublisher 의 구현체가 AbstractAggregateRoot#registerEvent로 등록된 이벤트를 Data Repository의 save에 감응하여 발행한다.
    • package org.springframework.context;
          
      @FunctionalInterface
      public interface ApplicationEventPublisher {
      default void publishEvent(ApplicationEvent event) { publishEvent((Object) event); }
      void publishEvent(Object event);}
      
  • Subscriber : @TransactionalEventListener 어노테이션을 사용하여 이벤트를 수신하는 객체
    • @Async
      @TransactionalEventListener
      @Transactional(propagation = Propagation.REQUIRES_NEW)
      public void handleCheckReviewEvent(CheckReviewEvent event) {}
      

역할을 잘 분류하니 작동 원리에 대한 이해가 쉬워졌다.


참고 자료