엔티티 구성요소 - 3 DomainEvent사용
JPA의 기본 사항을 정리한다.
- 사용해보기
- JPA 콜백 (@DomainEvent 사용시 인지할 것 1)
- 비영속 필드 선언 (@DomainEvent 사용시 인지할 것 2)
- 스프링 도메인 이벤트 구현 방식 (@DomainEvent 사용시 인지할 것 3)
- 참고 자료
사용해보기
@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하게 되는 경우 이후 핸들러의 엔티티 상태 수정 작업이 반영되지 않는다.
- 이벤트 호출자가 먼저 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, 계산된 값을 저장하는데 사용된다.
비영속 필드 선언 방법
- @Transient로 getter 생성하기
- 메서드가 매번 계산되기 때문에 호출될 때 마다 오버헤드가 있다.
@Transient public int getCalculatedValue() { return this.value * 2; }
- 필드에 @Transient + @PostLoad 콜백 메서드 선언
- 필드에 @Transient를 선언하고, @PostLoad 콜백 메서드를 선언하여 필드를 초기화한다.
- 최초 로드시에만 초기화되기 때문에 성능상 이점이 있다.
@Transient private int calculatedValue; @PostLoad private void initCalculatedValue() { this.calculatedValue = this.value * 2; }
- @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에서 사용하기 유용하기에 스프링에서도 이를 지원한다.
옵저버 패턴 구현 방법
- 다른 코드와 독립적인 ‘Publisher’ 인터페이스를 선언한다. 구독자 리스트 추가/제거 메서드를 선언한다.
- ‘Subscriber’ 인터페이스를 선언하는데 ‘update’같이 변경시 이벤트 발행에 반응해 처리할 메서드를 선언한다.
- ‘Publisher’ 객체는 ‘Subscriber’ 객체를 등록하고, 이벤트 발생시 ‘Subscriber’ 객체들에게 알린다.
- 실제 ‘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) {}
역할을 잘 분류하니 작동 원리에 대한 이해가 쉬워졌다.