본문 바로가기

Server/Spring

[Spring/JPA] EntityListener 로 효율적인 이벤트 처리를 구현해보도록 하겠습니다.

왜 Entity Listener를 적용하였는가?

지난 게시글에서 엔티티를 생성하거나 수정할 때 해당 엔티티의 인덱스를 생성 및 수정하는 작업을 위해 ApplicationEvent를 도입하여 서비스 간의 결합도를 줄이고, 트랜잭션 문제를 개선할 수 있었습니다.

 

그러나 지난 게시글에서 ApplicationEvent를 사용하는 방식에는 한 가지 단점이 있습니다. 매번 엔티티를 생성하거나 수정할 때마다 applicationEventPublisher.publishEvent() 메서드를 호출하는 코드를 작성해야 한다는 점입니다. 아래의 코드처럼 모든 이를 위해 모든 비즈니스 로직에서 일일이 이벤트 발행 코드를 추가해야 합니다.

class UserService(
    private val userIndexService: UserIndexService,
    private val applicationEventPublisher: ApplicationEventPublisher
) {
    @Transactional
    fun create(name: String, identity: Identity) : User {
        val user = User(name = "kdmstj", identity = Identity.STUDENT)
        useRepository.save(user)
        
        applicationEventPublisher.publishEvent(UserCreatedEvent(user)) //application event 발간
    }
}

이 방식은 비효율적일 뿐만 아니라, 코드의 중복을 초래하고, 개발자가 실수로 이벤트 발행 코드를 누락할 가능성을 높입니다.

 

이러한 문제를 해결하기 위해 JPA의 EntityListener를 적용하는 방법을 찾게 되었습니다. @EntityListeners 는 엔티티의 생명주기(Lifecycle Events)동안 발생하는 이벤트를 처리하기 위해 JPA 에서 제공하는 어노테이션입니다. 해당 어노테이션을 사용하면 엔티티가 생성, 수정, 삭제되는 시점에 자동으로 특정 메서드를 호출할 수 있습니다.

 

Entity 의 생명주기에 따라 이벤트가 발생합니다.

엔티티의 생명주기는 persist(), update(), remove(), find() 같은 메서드 호출을 통해 시작되며, 각 단계에서 이벤트가 발생합니다. 엔티티의 생명주기에 대해서는 해당 게시물을 참고 부탁드립니다.

 

<<사진 첨부 예정>>

1. PrePersist

persist() 메서드를 호출해서 엔티티를 영속성 컨텍스트에 관리하기 직전에 호출됩니다. 이때, 식별자 생성 전략을 사용한 경우에는 엔티티의 식별자는 존재하지 않는 상태입니다.

 

2. PreUpdate

flush() 나 commit() 을 호출해서 엔티티가 데이터베이스에 수정하기 직전에 호출됩니다.

 

3. PreRemove

remove 메소드를 호출해서 엔티티를 영속성 컨텍스트에서 삭제하기 직전에 호출됩니다. 

 

4. PostPersist

flush() 나 commit() 메서드를 호출로 인해 flush 가 발생하여 데이터베이스에 저장된 직후에 호출됩니다. 특히, 식별자 생성 전략이 IDENTITY인 경우, persist() 메서드를 호출하면서 flush가 발생하여 식별자가 생성된 후, PostPersist 이벤트가 바로 호출됩니다.

 

5. PostUpdate

flush() 나 commit() 메서드를 호출로 인해 flush 가 발생하여 데이터베이스에 수정된 직후에 호출됩니다. 이 이벤트는 persist 시에는 호출되지 않으며, 변경 감지로 인해 엔티티가 업데이트 될 때 호출됩니다.

 

6. PostRemove

flush() 나 commit() 메서드를 호출해서 엔티티가 데이터베이스에서 삭제한 직후에 호출됩니다.

 

7. PostLoad

엔티티가 영속성 컨텍스트에 조회된 직후, 또는 refresh 를 호출한 후에 호출됩니다. 이 이벤트는 2차 캐시에서 데이터를 불러온 경우에도 호출됩니다.

 

EntityListener postPersist 로 인한 삽질 ⛏️

"flush 나 commit 이 호출되어 event 가 발행된다" 라는 문장을 제멋대로 "트랜잭션이 commit 된 후에 event 가 발행 될 것이다"고 생각하면,, 저처럼 삽질을 할 수 있습니다.

 

정확히 하자면 해당 과정을 거칩니다.

: INSERT 문 실행 -> postPersist event 발행 -> 트랜잭션 commit

 

The postPersist event is triggered after the INSERT operations, but before the transaction is committed. If your application dies during the event, the database will roll back your changes.

 

postPersist triggered after or before database operations ? · doctrine orm · Discussion #9831

Hi, I'm searching an Event triggered after database operations on New insert, cause the postUpdate is only on already existing data. So I've found this: "It will be invoked after the database inser...

github.com

 

Entity Listeners 도입 방법

JPA 에서 제공하는 @EntityListeners 를 통해 엔티티의 생명주기에 따른 이벤트를 처리하는 방법은 크게 세가지가 있습니다.

 

1. 엔티티에 직접 적용

2. 별도의 리스너 등록

3. 기본 리스너 사용

 

저는 ApplicationEvent 를 발행하는 UserEventPublisher 별도의 리스너를 생성하여 엔티티에 등록하는 방식을 구현했습니다.

 

별도의 리스너 등록

@Component
class UserEventPublisher() {
    private lateinit var applicationEventPublisher: ApplicationEventPublisher

    constructor(applicationEventPublisher: ApplicationEventPublisher) : this() {
        this.applicationEventPublisher = applicationEventPublisher
    }

    @PostPersist
    fun postPersist(user: User) {
        applicationEventPublisher.publishEvent(UserCreatedEvent(user))
    }

    @PostUpdate
    fun postUpdate(user: User) {
        applicationEventPublisher.publishEvent(UserUpdatedEvent(user))
    }
}

 

엔티티의 생명주기 이벤트에 따라 ApplicationEvent 를 발행하는 UserEventPublisher 리스너의 예시입니다. 위 코드에서 UserEventPublisher 는 @Component 로 우선 EntityListener 에 해당하는 UserEventPublisher 를 @Component 로 빈으로 등록해주었습니다. 해당 리스너는 앞서 말한 이벤트의 종류에 따라서 특정 메서드를 수행하도록 되어 있습니다.

 

의존성 주입 문제

JPA 엔티티 리스너에서 의존성을 주입할 때는 주의가 필요합니다. 아래 예시는 UserListener라는 리스너 클래스에 userIndexRepository를 주입하려고 했으나, userIndexRepository에 null이 할당되어 NullPointerException이 발생할 수 있는 상황이 발생할 수 있습니다. 

@Component
class UserListener(private val userIndexRepository: UserIndexRepository) {

    @PostPersist
    fun postPersist(user: User) {
        userIndexRepository.save(user)
    }

    @PostUpdate
    fun postUpdate(user: User) {
        userIndexRepository.save(user)
    }
}

이 문제는 @EntityListeners로 등록된 클래스가 JPA에 의해 직접 관리되며, Spring의 ApplicationContext에서 관리되는 빈이 아니기 때문입니다. 즉, Spring 빈이 생성되고 등록되기 전에 엔티티 리스너가 먼저 생성될 수 있으며, 이로 인해 의존성 주입이 제대로 이루어지지 않을 수 있습니다.

 

알려진 해결 방법으로는 Spring Bean 이 모두 등록된 후 EntityListeners 로 리정한 클래스를 모두 찾아 의존성 주입을 해야 합니다.

저는 ApplicationEvent를 통한 이벤트 발행/구독 구조로 분리했습니다. ApplicationEventPublisher가 DI 되는 이유는 이 클래스가 Spring이 제공하는 이미 등록된 빈이기 때문입니다. 따라서 리스너가 생성될 때 ApplicationEventPublisher는 이미 존재하며, 이로 인해 NullPointerException 문제가 발생하지 않습니다.

 

아래와 같이 applicationEventPublisehr 를 통해서 이벤트를 발행하였습니다.

@Component
class UserEventPublisher(){
    private lateinit var applicationEventPublisher: ApplicationEventPublisher
    
    constructor(applicationEventPublisher: ApplicationEventPublisher) : this() {
        this.applicationEventPublisher = applicationEventPublisher
    }
    
    @PostPersist
    fun postPersist(user: User) {
        applicationEventPublisher.publishEvent(UserCreatedEvent(user))
    }

    @PostUpdate
    fun postUpdate(user: User) {
        applicationEventPublisher.publishEvent(UserUpdatedEvent(user))
    }
}

 

엔티티에 @EntityListeners 적용

마지막으로 엔티티에 @EntityListeners 어노테이션을 사용하여서 EntityListener 인 UserEventPublisher 를 등록해주면 됩니다.

@Entity
@EntityListeners(UserEventPublisher::class)
class User(
    var name: String,

    @Enumerated(EnumType.STRING)
    var role: Role,

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null
)