본문 바로가기
Spring/Spring Boot

@TransactionEventListener의 phase 옵션을 주의해서 사용하자

by yoon_seon 2024. 8. 6.

스프링에서 이벤트 리스너를 사용한다면 보통 ApplicationEventPublisher 를 통해 이벤트를 발생시키고, @TransactionEventListener가 적용된 이벤트 리스너에서 이 이벤트를 받아 처리합니다.

 

이때, 이벤트를 발생시킨 트랜잭션의 상태(단계)에 따라 이벤트 리스너가 동작하는 시점을 조절하게 되는데

이를 조절하는 옵션이 @TransactionEventListenerphase 옵션 입니다.

 

이벤트 리스너를 사용할 때 phase 옵션을 잘 알고 사용하지 않는다면 예상하지 못한 문제가 발생할 수 있습니다.

 

@TransactionEventListener의 phase 옵션

@TransactionEventListenerphase옵션은 4가지로 구성되어 있으며, 따로 설정하지 않는다면 기본값인 AFTER_COMMIT으로 동작하게 됩니다.

  • BEFORE_COMMIT : 트랜잭션이 commit 되기 전에 이벤트를 처리
  • AFTER_COMMIT: 트랜잭션이 성공적으로 commit 된 후에 이벤트를 처리 (기본값)
  • AFTER_ROLLBACK : 트랜잭션이 rollback 된 후에 이벤트를 처리합니다.
  • AFTER_COMPLETION : 트랜잭션이 완료된 후 (commit 되었든 rollback 되었든 상관없이) 이벤트를 처리

TransactionEventListener의 기본값은 AFTER_COMMIT

 

 

그러면 phase 옵션을 주의해서 사용해야 하는 이유를 간단한 테스트 코드를 통해 알아보겠습니다.

 

문제 상황 만들어보기

Service에서 회원을 저장하고 이벤트를 발생시키고, 이벤트 리스너에서는 저장한 회원의 닉네임을 변경해 보는 간단한 시나리오로 문제상황을 만들어 보겠습니다.

 

회원 엔티티

@Entity
@Table(name = "users")
class User(
    @Id
    val id: Long,
    var name: String,
) {

    fun updateName(newName: String) {
        this.name = newName
    }

}

 

Service

@Service
class UserService(
    private val eventPublisher: ApplicationEventPublisher,
    private val userRepository: UserRepository
) {

    @Transactional
    fun saveAndThenUpdateUsername(user: User): Long {
        val savedUser = userRepository.save(user)

        // 이벤트 발생
        eventPublisher.publishEvent(TestEvent.of(savedUser.id!!))

        return savedUser.id
    }
}

 

Event 전달 객체

class TestEvent(
    val userId: Long,
) {
    companion object {
        fun of (userId: Long): TestEvent {
            return TestEvent(userId)
        }
    }
}

 

이벤트 리스너

@Component
class TestEventListener(
    private val userRepository: UserRepository
) {

    private val log = logger()

    @TransactionalEventListener // 기본 값 phase = TransactionPhase.AFTER_COMMIT
    @Transactional
    fun testEventListener(testEvent: TestEvent) {
        log.info("testEventListener 실행!!!")

        val findUser = userRepository.findById(testEvent.userId)
            ?: throw IllegalArgumentException("유저가 존재하지 않습니다.")

        findUser.updateName("전우치")
    }
}

 

시나리오 코드를 작성했으니 이어서 간단한 테스트 코드를 작성해 보겠습니다.

 

테스트 코드

@SpringBootTest
class UserServiceTest @Autowired constructor(
    private val userService: UserService,
    private val userRepository: UserRepository,
) {

    @Test
    @DisplayName("회원을 저장하고, 이벤트 리스너를 통해 회원의 이름을 변경한다.")
    fun saveAndThenUpdateUsernameTest() {

        // given
        val user = User(1L,"홍길동")

        // when
        val savedUserId = userService.saveAndThenUpdateUsername(user)

        // then
        val findUser = userRepository.findById(savedUserId)

        assertThat(findUser!!.name).isEqualTo("전우치")
    }
}

 

위 테스트 코드의 예상 동작 다음과 같습니다.

 

  1. Service에서 이름이 홍길동 인 유저를 저장한다.
  2. 이벤트 리스너에서 홍길동 유저의 이름을 전우치 로 변경한다.
  3. 최종적으로 해당 유저의 이름은 전우치가 되어야 한다.

하지만 예상과는 다르게 테스트가 실패합니다.

 

 

실패 원인을 찾아보던 중 에러 로그에서 다음과 같은 메시지를 확인할 수 있었습니다.

 

 

테스트 실패 원인

@TransactionalEventListener method must not be annotated with @Transactional unless when declared as REQUIRES_NEW or NOT_SUPPORTED: public void io.study.kotlinapiserver.ttest.TestEventListener.testEventListener(io.study.kotlinapiserver.ttest.TestEvent)
  • @TransactionalEventListener는 REQUIRES_NEW 또는 NOT_SUPPORTED로 선언되지 않는 한 @Transactional로 주석을 달아서는 안된다.

해당 내용을 이해하기 위해서는 위에서 언급했던 @TransactionEventListenerphase 옵션을 이해해야 합니다.

 

현재 코드의 @TransactionEventListenerphase 옵션은 기본값인 AFTER_COMMIT 로 설정 있고,

따라서 이벤트 리스너는 이벤트를 발생시킨 주체인 Service의 트랜잭션이 commit 된 후 이벤트 리스너를 실행합니다.

여기서 중요한 점은 메인 트랜잭션이 커밋되었다고 해서 트랜잭션이 종료된 것이 아니라, 트랜잭션이 여전히 유지되고 있다는 것입니다.

 

코드를 다시 한번 살펴보겠습니다.

 

서비스 코드

@Service
class UserService(
    // ...
) {

    @Transactional // 메인 트랜잭션
    fun saveAndThenUpdateUsername(user: User): Long {
        // ...
    }
}

 

이벤트 리스너

@Component
class TestEventListener(
    // ...
) {
    // ...

    @TransactionalEventListener
    @Transactional // 기본 값 propagation = Propagation.REQUIRED
    fun testEventListener(testEvent: TestEvent) {
        // ...
    }
}

 

원인

Service에서 메인 트랜잭션을 실행시킨 후 트랜잭션이 커밋되고, 이벤트 리스너에서 작업을 수행하지만,

이벤트 리스너의 @Transactional 전파 레벨(propagation)이 기본값인 REQUIRED로 설정되어 있어서 메인 트랜잭션이 커밋된 후에도 메인 트랜잭션 내에서 실행되고 있었습니다.

 

하지만 메인 트랜잭션은 이미 commit이 되었기 때문에 다시 commit이 불가능하기에 리스너에서 작업한 유저 이름의 UPDATE가 commit 되지 않아 오류가 발생한 것입니다.

 

해결

이벤트 리스너의 @Transactional 전파 레벨을 REQUIRES_NEW로 설정한다면 메인 트랜잭션이 종료되지 않더라도 이벤트 리스너가 새로운 트랜잭션을 만들어서 사용하기 때문에 문제를 해결할 수 있습니다.

 

수정된 이벤트 리스너

@Component
class TestEventListener(
    private val userRepository: UserRepository
) {

    private val log = logger()

    @TransactionalEventListener
    @Transactional(propagation = Propagation.REQUIRES_NEW) // 전파 레벨 설정
    fun testEventListener(testEvent: TestEvent) {
        log.info("testEventListener 실행!!!")

        val findUser = userRepository.findById(testEvent.userId)
            ?: throw IllegalArgumentException("유저가 존재하지 않습니다.")

        findUser.updateName("전우치")
    }
}

 

 

@Transaction의 전파 레벨을 변경하고 테스트가 성공한 것을 확인할 수 있습니다.

 


 

참고 : 트랜잭션 전파 레벨과 @TransactionEventListener의 phase 옵션을 신중하게 선택해야 하는 이유

위 사례를 통해 테스트 코드의 문제를 해결해 보았습니다.

 

@Transaction의 전파 레벨과 @TransactionEventListener의 phase 옵션을 신중하게 선택해야하는 이유를 알아보기 위해

이번에는 위 테스트 코드에서 @TransactionEventListenerphase 옵션을 BEFORE_COMMIT 으로 변경하고 테스트를 실행해 보겠습니다.

@Component
class TestEventListener(
    private val userRepository: UserRepository
) {

    private val log = logger()

    @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) // 임의로 변경
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun testEventListener(testEvent: TestEvent) {
        log.info("testEventListener 실행!!!")

        val findUser = userRepository.findById(testEvent.userId)
            ?: throw IllegalArgumentException("유저가 존재하지 않습니다.")

        findUser.updateName("전우치")
    }
}

 

phase 옵션을 BEFORE_COMMIT 으로 변경했을 뿐인데 테스트가 실패합니다.

 

테스트 실패 원인은 메인 트랜잭션이 commit 되기 이전에 @TransactionalEventListener가 실행되었기 때문에 이벤트 리스너의 트랜잭션에서는 아직 DB에 반영되지 않은 해당 유저를 찾을 수 없는 것입니다.

 

이처럼 트랜잭션 전파레벨과 @TransactionEventListenerphase 옵션을 잘 알고 신중하게 사용해야 예상치 못한 에러를 방지할 수 있습니다.

 


참고 블로그

 

댓글