상품 개발시에 발생한 이벤트 리스너와 flush 순서 관련 이슈
개요
상품 도메인을 개발하면서 JPA의 이벤트 리스너를 활용해 상품 애그리거트 변경에 대한 이벤트 핸들링 구조를 설계했는데요.
과정에서 상품 애그리거트 변경에 대한 이슈를 발견했고, 이를 확인 과정을 기록해봤습니다.
요구사항
먼저 요구사항은 아래와 같습니다.
- 상품에 묶여있는 테이블의 데이터가 변경될 때, 하나의 트랜잭션에서 상품의 updatedAt를 변경해야한다.
- 상품의 데이터가 변경될 때 이벤트를 발행하고, 이에 따라 UPS 발행, 캐시 업데이트, 스냅샷 저장이 되어야 합니다.
- 결국 상품에 묶여있는 테이블의 데이터가 변경하면 상품도 변경되고 상품 변경 이벤트가 발행되면서 여러 비지니스 로직들이 수행되어야한다.
초반 설계
상품과 엮여있는 엔티티들이 ProductAggregateChangeAuditable이라는 인터페이스를 구현하고, 상품 에그리거트 전체가 ProductAggregateChangedEventListener라는 이벤트 리스너를 엔티티에 달아줌으로써, @PreUpdate에서 상품을 터치하도록 구성했습니다.
기대했던 흐름은 아래와 같습니다.
- 상품 에그리거트 안에 있는 특정 A라는 엔티티가 변경된다.
- A라는 엔티티 변경을 ProductAggregateChangedEventListener의 @PreUpdate가 캡처한다.
- @PreUpdate에서 상품의 updatedAt을 현재 시점으로 변경한다.
상품이 변경되었으므로, JPA 이벤트 리스너의 @PostUpdate에서 Product가 캡처된다.
이렇게 한 이유는, update API가 호출될 때 데이터가 변경되지 않는 경우도 존재하기 때문에, JPA의 더티체킹을 기반으로 데이터가 변경되었을 때만 이벤트를 동작시키고 싶었기 때문입니다.
이벤트 리스너
interface ProductAggregateChangeAuditable {
fun touch()
}
@Component
class ProductAggregateChangedEventListener : ApplicationContextAware {
private lateinit var publisher: ProductEventPublisher
override fun setApplicationContext(applicationContext: ApplicationContext) {
publisher = applicationContext.getBean(ProductEventPublisher::class.java)
}
@PostPersist
@PostUpdate
fun onPostUpdate(entity: Any) {
if (entity is Product && !entity.hasProcessedPostUpdating) {
entity.hasProcessedPostUpdating = true
if (entity.previousStatus != entity.status) {
publisher.publishEvent(
ProductStatusChangedEvent(
productId = entity.id,
previousStatus = entity.previousStatus,
currentStatus = entity.status
)
)
entity.previousStatus = entity.status
}
publisher.publishEvent(ProductAggregateChangedEvent(entity.id))
}
}
@PreUpdate
@PrePersist
fun onPreUpdate(entity: Any) {
when (entity) {
is Product -> {}
is ProductAggregateChangeAuditable -> entity.touch()
else -> throw ExperiencesErrorCode.NOT_VALID_STATE.newException("지원하지 않는 엔티티입니다: ${entity::class.java}")
}
}
}
엔티티 구성
엔티티의 구성은 아래와 같습니다.
상품 엔티티(One)
@Entity
@Audited
@AuditOverride(forClass = BaseEntity::class)
@Where(clause = "deleted_at is null")
@Table(name = "products")
@EntityListeners(ProductAggregateChangedEventListener::class)
class Product(
...
)
이미지 엔티티(Many)
@Entity
@Where(clause = "deleted_at is null")
@Table(name = "product_images")
@EntityListeners(ProductAggregateChangedEventListener::class)
class ProductImage(
...
)
문제 발생과 원인 분석
하지만 실제로 이미지를 변경할 때, 상품을 터치해도 상품의 변경이 이벤트 리스너의 @PostUpdate에 캡처되지 않는 이슈가 발생했습니다.
간단하게는 원인을 파악할 수 없었고, 실제로 프레임워크가 동작하는 과정을 분석해봤습니다.
JPA flush 동작 과정
주요 클래스는 DefaultFlushEventListener.class 입니다.
아래 순서로 동작합니다.
- 스프링 트랜잭션 매니저가 커밋 실행
- 이를 후킹해서 JPA가 플러시 진행
- dirtyCheck 메소드 실행
- dirtyCheck 이후에 바뀐값이 있다고 판단되면, @PreUpdate를 실행
- actionQueue에 이벤트를 담음
- 실제 update query를 실행
우리의 상황
Product와 ProductImage를 기반으로 생각하면 아래 흐름으로 동작합니다.
- 스프링 트랜잭션 매니저가 커밋 실행
- 이를 후킹해서 JPA가 플러시 시작
- Product 엔티티를 dirtyCheck 하지만 바뀐값이 없으므로 아무 일도 일어나지 않음
- 반면에 ProductImage는 dirtyCheck하고, 바뀐 값이 있으므로 다음 스탭으로 넘어감
- ProductImage의 리스너에서 @PreUpdate 실행한다. 여기에서 우리가 Product를 touch하도록 만들었지만 이 시점에 Product의 dirty check은 이미 끝난 상황
- ProductImage의 flushEntityEvent를 actionQueue에 담음
- ProductImage에 대한 update query만을 실행
Product를 더티체킹하고, 그다음에 ProductImage를 더티체킹하기 때문에 ProductImage에 붙은 리스너의 @PreUpdate에서 변경하는 시점은 Product는 이미 더티체킹이 끝난 상황입니다.
Product를 먼저 더티체킹하는 이유
Product와 ProductImage는 OneToMany 관계로 포링키가 ProductImage에 존재합니다.
결과적으로 Product가 먼저 저장되고 ProductImage가 저장되어야 포링키 제약조건에 위배되지 않기 때문에 JPA에서는 연관관계의 주인이 아닌 데이터를 먼저 저장하도록 구성되어 있습니다.
결론
JPA는 One이 무조건 Many보다 먼저 플러시되기 때문에, OneToMany에서 Many가 One을 터치하는 것은 불가능하고, One이 Many를 터치하는 것은 가능합니다.
실제로 Product의 이벤트 리스너의 @PreUpdate에서 ProductImage를 터치하면 ProductImage의 변화는 @PostUpdate에 캡처됩니다.
결과적으로 @PreUpdate에 상품을 터치하는 방법으로는 문제를 해결할 수 없었고, 개발자가 업데이트 메소드에 명시적으로 product.touch()를 작성해줌으로써 해결해야했습니다.
참고
프레임워크를 분석하면서 기록한 스택 트레이스입니다.
- [TransactionAspectSupport.class] CGLIB이 프록시로 invokeWithinTransaction을 호출한다.
- [TransactionAspectSupport.class] 타겟 클래스의 트랜잭션이 붙은 메소드를 실행하고 commitTransactionAfterReturning를 실행한다.
- [AbstractPlatformTransactionManager.class] 스프링의 AbstractPlatformTransactionManager의 commit 메소드를 실행한다.
- [JpaTransactionManager.class] 실제 커밋은 AbstractPlatformTransactionManager의 구현체인 JpaTransactionManager의 doCommit 메소드에서 이루어진다. 여기서부터는 이제 JPA가 스프링이 제공해주는 인터페이스를 구현해서 후킹한다.
- [JdbcResourceLocalTransactionCoordinatorImpl.class] JpaTransactionManager의 doCommit 메소드에서 JdbcResourceLocalTransactionCoordinatorImpl의 beforeCompletionCallback 메소드를 실행한다.
- [SessionImpl.class] SessionImpl의 beforeTransactionCompletion 메소드를 실행한다.
- [SessionImpl.class] SessionImpl의 doFlush 메소드를 실행한다.
- [EventListenerGroupImpl.class] 사전에 미리 정의된 EventListenerGroupImpl 클래스의 여러 이벤트들 중에 eventListenerGroup_FLUSH.fireEventOnEachListener를 실행한다.
- [DefaultFlushEventListener.class] DefaultFlushEventListener의 onFlush 메소드를 실행한다. 이 때, FlushEvent를 넘겨준다.
- [AbstractFlushingEventListener.class] flushEverythingToExecutions 메소드를 실행한다.
- [DefaultFlushEntityEventListener.class] FlushEntityEventListener의 onFlushEntity를 실행한다. 이 때, FlushEntityEvent를 넘겨준다.
- [DefaultFlushEntityEventListener.class] DefaultFlushEntityEventListener의 dirtyCheck 메소드를 실행한다. 실제로 여기에서 값이 달라진게 있는지 체크한다. 그리고 달라진 값이 있으면 이벤트에 기록한다.
- [DefaultFlushEntityEventListener.class] 더티체킹 이후에 업데이트가 필요하다고 판단되면, scheduleUpdate를 실행한다.
- [DefaultFlushEntityEventListener.class] DefaultFlushEntityEventListener는 사전에 @PreUpdate와 같은 어노테이션이 붙은 리스너를 저장하고 있는데, 이를 invokeInterceptor 메소드에서 실행한다.
- [DefaultFlushEntityEventListener.class] sessionImpl의 actionQueue에 update action을 추가한다. 즉 isUpdateNecessary가 true인 경우에만, 더티체킹 결과값을 기반으로 update 쿼리를 만든다.
- [DefaultFlushEventListener.class] performExecutions 메소드를 실행한다. 이 때, sessionImpl 안의 actionQueue에 있는 모든 action들을 실행한다.