백엔드 멘토링

@TransactionalEventListener(AFTER_COMMIT) 제대로 테스트하기

piedra_de_flor 2025. 12. 14. 21:12

사이드 프로젝트로 레시피 서비스를 만들면서, 이런 요구사항이 생겼다.

“회원가입이 완료되면, 이 유저에게 냉장고를 하나 자동으로 만들어 붙여주자.”

 

도메인적으로는 꽤 자연스러운 흐름인데, 트랜잭션을 생각하기 시작하니까 조금 복잡해진다.

  • 냉장고 생성은 부가 기능이다.
  • 냉장고 생성이 실패했다고 해서 회원가입까지 롤백되면 안 된다.
  • 하지만 회원가입과 냉장고 생성은 연쇄적으로 일어나야 한다.

그래서 선택한 게 Spring 이벤트(@TransactionalEventListener) + AFTER_COMMIT + 별도 트랜잭션 조합이었다.
구현까진 잘 됐는데, 문제는 그다음이었다.

“이걸 테스트하려고 하니까, JPA 슬라이스 테스트, H2, AFTER_COMMIT, 트랜잭션이 서로 싸우기 시작했다…”

 

이 글은 내가 실제로 겪었던:

  • Table "USERS" not found
  • LazyInitializationException
  • 이벤트가 안 도는 문제
  • 예외 테스트가 안 잡히는 문제

까지 전부 포함된, 이벤트 기반 설계를 테스트하기 위해 삽질했던 기록이다.

 


 

1. 요구사항과 이벤트 기반 설계 초안

1.1 요구사항 정리

먼저 도메인을 아주 단순하게 요약하면:

  • 유저는 회원가입을 한다.
  • 회원가입이 정상적으로 완료되면,
    • 유저에게 냉장고를 하나 생성해서 할당한다.
  • 유저당 냉장고는 최대 1개다.
  • 냉장고 생성에 실패해도, 회원가입은 롤백되면 안 된다.

즉, 냉장고 생성은 “후처리(post processing)”에 가까운 역할이다.

 


 

1.2 이벤트 기반 설계

나는 이 요구사항을 이렇게 모델링했다.

  1. 회원가입이 끝나면 UserJoinedEvent 를 발행한다.
data class UserJoinedEvent(
    val userId: Long,
    val email: String
)

 

 

   2. 이 이벤트를 처리하는 곳은 RefrigeratorService 안으로 넣었다.

@Service
class RefrigeratorService(
    private val refrigeratorRepository: RefrigeratorRepository,
    private val ingredientRepository: IngredientRepository,
    private val userRepository: UserRepository,
) {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    fun onUserJoined(event: UserJoinedEvent) {
        val user = userRepository.findById(event.userId)
            .orElseThrow { BusinessException(UserCode.LOGIN_ERROR_002, HttpStatus.UNAUTHORIZED) }

        if (user.hasRefrigerator()) {
            return
        }

        createRefrigeratorFor(user)
    }

    private fun createRefrigeratorFor(user: User): Refrigerator {
        val defaultIngredients = findOrCreateDefaultIngredients()
        val fridge = Refrigerator.create(defaultIngredients)
        val savedFridge = refrigeratorRepository.save(fridge)
        user.assignRefrigerator(savedFridge)
        return savedFridge
    }

    private fun findOrCreateDefaultIngredients(): List<Ingredient> {
        return BasicIngredients.DEFAULTS.map { basic ->
            ingredientRepository.findByCategoryAndName(basic.category, basic.name)
                ?: ingredientRepository.save(basic.toEntity())
        }
    }
}

 

 

   3. 회원가입 쪽에서는 단순히 이벤트만 쏜다.

publisher.publishEvent(UserJoinedEvent(userId, email))

 

 

여기서 중요한 부분은 이 두 가지다.

  • @TransactionalEventListener(phase = AFTER_COMMIT)
    • 회원가입 트랜잭션이 커밋된 이후에만 냉장고 생성 로직이 실행된다.
  • @Transactional(propagation = REQUIRES_NEW)
    • 냉장고 생성은 항상 새로운 트랜잭션에서 실행된다.
    • 냉장고 생성에 실패해도 이미 커밋된 회원가입은 롤백되지 않는다.

 

설계만 보면 꽤 그럴듯하다.
하지만 이걸 테스트하려고 하는 순간, 온갖 오류들이 쏟아지기 시작했다.

 

 


 

2. 이벤트 기반 테스트에서 맞은 진짜 핵심 오류들

이제 진짜 “이벤트 + AFTER_COMMIT + 트랜잭션” 조합이 본색을 드러내기 시작했다.

요약하면, 이런 흐름을 테스트하고 싶었다.

  1. 유저를 DB에 저장한다.
  2. UserJoinedEvent 를 발행한다.
  3. 회원가입 트랜잭션이 커밋된 뒤, AFTER_COMMIT 단계에서 onUserJoined 가 호출된다.
  4. onUserJoined 안에서 냉장고를 생성하고, 유저에게 할당한다.

그런데 실제로 테스트를 돌려보면, 냉장고는 커녕 쿼리조차 안 찍히거나,
이벤트가 돌긴 도는데 유저를 못 찾는다.
하나씩 정리해보자.

 

2.1 AFTER_COMMIT이 아예 안 도는 상황

처음에 썼던 테스트 코드는 대략 이렇게 생겼다.

 

@Test
@DisplayName("UserJoinedEvent 처리로 냉장고 생성 및 할당")
fun userJoinedEvent_생성() {
    // given
    val user = userRepository.save(User(email = "e@e.com", password = "pw", name = "name"))

    // when
    invoker.publishUserJoinedAndCommit(user.id!!, user.email)

    // then
    val reloaded = userRepository.findById(user.id!!).get()
    assertThat(reloaded.hasRefrigerator()).isTrue()
    val fridgeId = reloaded.refrigeratorExternal.id!!
    assertThat(refrigeratorRepository.findById(fridgeId)).isPresent
}

 

 

테스트를 실행해보면, 로그는 이렇게 흘렀다.

Hibernate: 
    insert 
    into
        users
        (email, name, password, refrigerator_id, role) 
    values
        (?, ?, ?, ?, ?)
Hibernate: 
    select
        u1_0.id,
        u1_0.email,
        u1_0.name,
        u1_0.password,
        u1_0.refrigerator_id,
        u1_0.role 
    from
        users u1_0 
    where
        u1_0.id=?

 

 

분명 이벤트를 발행했는데…

  • insert into refrigerator ...
  • update users set refrigerator_id = ...

같은 냉장고 관련 쿼리가 단 한 줄도 안 찍힌다.

당연히 마지막 assertion도 이렇게 터진다.

 

Expecting value to be true but was false
필요:true
실제:false

 

분명히 설계상 AFTER_COMMIT에서 냉장고를 만들어야 하는데,
애가 아예 안 돌고 있다는 느낌이 딱 왔다.

 

왜 안 도는 걸까? ( @DataJpaTest + AFTER_COMMIT )

문제의 조합은 이거였다.

  • 테스트 클래스에 @DataJpaTest
  • 이벤트 리스너에 @TransactionalEventListener(phase = AFTER_COMMIT)

@DataJpaTest 는 각 테스트 메서드를 하나의 큰 트랜잭션(T₀) 으로 감싸고,
테스트가 끝날 때 롤백해 버린다.

AFTER_COMMIT 은 글자 그대로,

커밋이 일어난 다음에 이벤트를 실행해라”

 

라는 의미고, 커밋이 없으면 당연히 실행되지 않는다.

실제로는 이런 일이 벌어지고 있었다.

  1. 테스트 시작 → T₀ 시작 (@DataJpaTest 덕분에)
  2. userRepository.save(...) → T₀ 안에서 유저 INSERT
  3. publisher.publishEvent(UserJoinedEvent(...)) 호출
    • 이 이벤트는 “T₀가 커밋된 직후에 onUserJoined를 호출해라”로 예약된다.
  4. 테스트 메서드 종료 → T₀는 커밋이 아니라 롤백
  5. 롤백이므로, AFTER_COMMIT 리스너는 호출되지 않는다.

그래서 아무리 로그를 뒤져봐도 냉장고 INSERT/UPDATE가 안 찍혔던 것.

즉, 코드/설계는 맞는데, 테스트 환경이 “커밋이 없는 세상”이라 AFTER_COMMIT이 돌 틈이 없었던 것이다.

 


 

2.2 AFTER_COMMIT은 도는데, 유저를 못 찾는 상황

그래서 다음으로 시도한 건, 테스트 안에서만 “진짜 커밋이 있는 트랜잭션”을 하나 만들어주는 것이었다.

그 역할을 하는 헬퍼 클래스를 따로 만들었다.

 

@Service
class TestEventInvoker(
    private val publisher: ApplicationEventPublisher,
    txManager: PlatformTransactionManager
) {
    private val txTemplate = TransactionTemplate(txManager).apply {
        propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
    }

    fun publishUserJoinedAndCommit(userId: Long, email: String) {
        val event = UserJoinedEvent(userId = userId, email = email)

        txTemplate.executeWithoutResult {
            publisher.publishEvent(event)
            // 여기서 트랜잭션이 커밋되는 순간
            // -> AFTER_COMMIT 리스너(onUserJoined)가 호출된다.
        }
    }
}

 

이제 최소한:

  • publishEvent 가 트랜잭션(T₁) 안에서 실행되고,
  • T₁ 이 커밋될 때 AFTER_COMMIT 리스너가 도는 구조가 됐다.

실제로 로그도 이렇게 나오기 시작했다.

2025-12-14T20:08:46.504+09:00 ERROR ... TransactionSynchronization.afterCompletion threw exception

com.example.home_recipe.global.exception.BusinessException: 존재하지 않는 이메일
    at com.example.home_recipe.service.refrigerator.RefrigeratorService.onUserJoined(RefrigeratorService.kt:44)
    ...

 

afterCompletion 시점에 리스너가 실제로 호출되고 있다는 증거다.
하지만 이번에는 또 다른 문제가 생겼다.

BusinessException: 존재하지 않는 이메일

onUserJoined 코드를 다시 보면:

 

@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun onUserJoined(event: UserJoinedEvent) {
    val user = userRepository.findById(event.userId)
        .orElseThrow { BusinessException(UserCode.LOGIN_ERROR_002, HttpStatus.UNAUTHORIZED) }

    if (user.hasRefrigerator()) {
        return
    }

    createRefrigeratorFor(user)
}

 

로그를 보면 여기서 BusinessException("존재하지 않는 이메일") 이 터진다.

“아니, 분명 테스트에서 유저를 저장하고 나서 이벤트를 쐈는데
왜 ‘존재하지 않는 이메일’이냐” 는 의문이 바로 들었다.

원인은 이것도 트랜잭션 경계 문제였다.

 

  1. @DataJpaTest 때문에, 여전히 각 테스트는 큰 트랜잭션(T₀) 안에서 돌아가고 있다.
  2. userRepository.save(...) 는 T₀ 안에서만 INSERT 하고, 커밋되지 않는다.
    (테스트가 끝날 때 T₀ 전체가 롤백될 예정)
  3. TestEventInvoker.publishUserJoinedAndCommit 안에서는 REQUIRES_NEW 로 T₁을 새로 열고,
    그 안에서 publishEvent 호출 → T₁ 커밋 시점에 AFTER_COMMIT → onUserJoined (T₂) 실행.
  4. T₂ 입장에서는:
    • “이미 커밋된 데이터” 만 볼 수 있다.
    • 하지만 유저 INSERT 는 T₀ 에만 있고 T₀ 은 아직 커밋이 안 된 상태.
    • 결과적으로 findById(event.userId) 가 비게 되고, BusinessException 이 터진다.

 

정리하면,

유저는 T₀(테스트 트랜잭션)에만 있고 커밋이 안 된 상태인데,
AFTER_COMMIT 리스너는 T₂(완전히 다른 트랜잭션)에서 돌면서
“커밋된 유저”를 찾으려고 하니 못 찾는
상황이었다.

 


 

결국 이 테스트 메서드에 한 줄을 추가했다.

@Test
@DisplayName("UserJoinedEvent 처리로 냉장고 생성 및 할당")
@Transactional(propagation = Propagation.NOT_SUPPORTED)
fun userJoinedEvent_생성() {
    // given
    val user = userRepository.save(User(email = "e@e.com", password = "pw", name = "name"))

    // when
    invoker.publishUserJoinedAndCommit(user.id!!, user.email)

    // then
    val reloaded = userRepository.findById(user.id!!).get()
    assertThat(reloaded.hasRefrigerator()).isTrue()
    val fridgeId = reloaded.refrigeratorExternal.id!!
    assertThat(refrigeratorRepository.findById(fridgeId)).isPresent
}

 

@Transactional(propagation = NOT_SUPPORTED) 의 의미는:

“이 메서드에서는 스프링 테스트가 만들어주는 트랜잭션을 사용하지 않는다

 

라는 것.

이렇게 되면:

  • userRepository.save(...) 호출 시
    • JPA가 “작은 트랜잭션”을 열고 INSERT → 바로 커밋
  • publishUserJoinedAndCommit 호출 시
    • TransactionTemplate(REQUIRES_NEW) 가 T₁을 열고 이벤트 발행 → 커밋
    • T₁ 커밋 시점에 AFTER_COMMIT 리스너(T₂)가 실행되고, 냉장고 생성
  • 마지막 findById 는 이미 커밋된 유저 + 냉장고를 조회할 수 있다.

이제야 비로소 내가 의도한 플로우:

회원가입 (커밋) → AFTER_COMMIT에서 냉장고 생성 (별도 트랜잭션)

 

을 테스트 코드에서도 재현할 수 있게 됐다.

 


6. 예외 테스트 – 왜 assertThatThrownBy 가 안 먹는지

위 문제들을 해결하고 나서, 존재하지 않는 유저에 대한 예외 테스트도 추가하고 싶었다.

처음에 썼던 테스트는 이런 형태였다.

 

@Test
@DisplayName("UserJoinedEvent - 존재하지 않는 유저면 예외 발생")
fun userJoinedEvent_유저없음() {
    // given
    val notExists = 999_999L

    // when & then
    assertThatThrownBy {
        invoker.publishUserJoinedAndCommit(notExists, "none@example.com")
    }.isInstanceOf(IllegalArgumentException::class.java)
}

 

 

하지만 결과는:

Expecting code to raise a throwable.
java.lang.AssertionError: 
Expecting code to raise a throwable.

 

 

즉, 예외가 전혀 안 올라온다고 인식한다.
근데 로그는 이렇게 나온다.

2025-12-14T20:08:46.504+09:00 ERROR ... TransactionSynchronization.afterCompletion threw exception

com.example.home_recipe.global.exception.BusinessException: 존재하지 않는 이메일
    at com.example.home_recipe.service.refrigerator.RefrigeratorService.onUserJoined(RefrigeratorService.kt:44)
    ...

 

분명 예외는 난 것 같은데, 왜 assertThatThrownBy 는 “예외 안 났음”이라고 할까?

AFTER_COMMIT + 트랜잭션 콜백의 함정

핵심은 예외가 나는 위치다.

  • BusinessException("존재하지 않는 이메일") 은
    onUserJoined 리스너 안에서 던져진다.
  • 이 리스너는 @TransactionalEventListener(phase = AFTER_COMMIT) 이라서,
    트랜잭션이 커밋된 후에 실행되는 콜백이다.
  • 즉, 이 예외는
    • TransactionSynchronization.afterCompletion(...) 안에서 처리되며,
    • publishUserJoinedAndCommit 호출 스택까지 “정상적인 예외 전파”로 올라오지 않는다.

그래서 AssertJ 입장에서는 이렇게 보인다.

 

assertThatThrownBy {
    invoker.publishUserJoinedAndCommit(...)
}.isInstanceOf(...)

 

→ publishUserJoinedAndCommit 이 아무 예외도 던지지 않았네? → AssertionError

어떻게 테스트할 거냐? 책임 위치 정하기

여기서 선택지는 두 가지다.

 

전략 1 – 리스너 메서드를 직접 호출해서 예외 검증

이 방식은 간단하다.

“없는 유저에 대해서는 냉장고 생성 이벤트를 처리할 때 예외를 던진다”
라는 비즈니스 규칙만 확인하고 싶은 거라면,
굳이 AFTER_COMMIT/트랜잭션을 모두 엮을 필요가 없다.

 

전략 2 – 아예 invoker에서 직접 예외를 던지게 설계 변경

다른 선택지는 설계를 이렇게 바꾸는 것이다.

“존재하지 않는 유저 id로 publishUserJoinedAndCommit 이 호출되면
그 자리에서 바로 예외를 던지겠다

 

이건 완전 설계 선택의 영역이라,
나는 도메인 규칙을 리스너 쪽에 두고,
예외 검증은 전략 1처럼 리스너 직접 호출 테스트로 처리하는 쪽을 택했다.

 

테스트를 이렇게 변경했다.

@Test
@DisplayName("UserJoinedEvent - 존재하지 않는 유저면 예외 발생")
fun userJoinedEvent_유저없음() {
    // given
    val notExists = 999_999L
    val event = UserJoinedEvent(notExists, "none@example.com")

    // when & then
    assertThatThrownBy {
        refrigeratorService.onUserJoined(event)
    }.isInstanceOf(BusinessException::class.java)
}
  • 이 경우 @TransactionalEventListener 는 무시되고,
  • @Transactional(propagation = REQUIRES_NEW)만 적용된
    일반 서비스 메서드 호출로 동작한다.
  • 예외는 함수 안에서 바로 던져지므로,
    assertThatThrownBy 가 정상적으로 잡을 수 있다.

장점은 명확하다.

  • “없는 유저면 예외 던진다”라는 핵심 규칙만 깔끔하게 검증 가능.
  • 트랜잭션/이벤트 타이밍이 섞여 있지 않아서 테스트가 단순하다.

 


 

이벤트 + 트랜잭션 + 테스트에서 얻은 개인 체크리스트

이번에 “회원가입 → AFTER_COMMIT → 냉장고 생성” 요구사항을 구현하고,
그걸 테스트하는 과정을 겪으면서 정리한 나만의 체크리스트는 대략 이렇다.

 

  1. 비즈니스 규칙 테스트는 최대한 단순하게
    • 이벤트/트랜잭션 타이밍을 끌어들이지 말고,
    • 리스너/서비스 메서드를 직접 호출해서 검증한다.
    • 예: refrigeratorService.onUserJoined(event) 를 직접 부르면서 예외/상태 확인.
  2. 이벤트 플로우 전체를 테스트할 때는 트랜잭션 경계를 의식적으로 설계
    • @DataJpaTest 처럼 “모든 걸 롤백하는 큰 트랜잭션” 안에서 AFTER_COMMIT 이 안 도는 건 당연하다.
    • 필요한 경우:
      • @Transactional(propagation = NOT_SUPPORTED) 로 테스트 메서드에서 테스트 트랜잭션을 끄고,
      • TransactionTemplate(REQUIRES_NEW) 같은 도구로 “진짜 커밋되는 트랜잭션”을 직접 만들어야 한다.
  3. AFTER_COMMIT에서 던지는 예외는 호출자에게 안 올라올 수도 있다
    • TransactionSynchronization.afterCompletion 안에서 터지는 예외라,
    • 로그만 찍히고 호출자 입장에서는 “정상 종료”처럼 보이기도 한다.
    • 그래서 assertThatThrownBy { invoker.publish... } 가 실패하는 건 자연스러운 현상이다.
    • “어디에서 예외를 던질지(리스너 vs invoker)”를 먼저 정한 뒤, 해당 지점에 맞게 테스트를 짜야 한다.
  4. Mockito + Kotlin + Spring Data 제네릭 조합에서는
    • any<타입>() + thenAnswer { it.getArgument<타입>(0) } 패턴을 적극 활용해서
    • save(...) must not be null 같은 애매한 NPE를 예방한다.
  5. 로그는 최고의 디버깅 도구
    • insert into users ... 까지 찍히는지,
    • insert into refrigerator ..., update users set refrigerator_id=... 가 언제/어디서 찍히는지,
    • TransactionSynchronization.afterCompletion threw exception 이 나오는지
    • 이런 로그들을 보고 “지금 이 코드가 어느 트랜잭션, 어느 타이밍에서 돌고 있는지” 를 추적하면
      머리가 조금 덜 아파진다.

 


 

 

정리해놓고 보니 절반은 삽질 기록이고, 절반은 트랜잭션 공부 노트인데,
나중에 “이벤트 핸들러 + AFTER_COMMIT 테스트”를 또 구현하게 될 때,
이 글이 내 미래의 나한테 체크리스트처럼 도움이 됐으면 좋겠다.

 

그리고 혹시 지금 이 글을 보고 있는 사람이
나처럼 이벤트 + @DataJpaTest + AFTER_COMMIT 지옥에 빠져 있다면,
위에서 하나라도 딱 맞아떨어지는 해결책이 있었기를