Kotest에서 @DataJpaTest의 트랜잭션은 왜 롤백되지 않았을까?
Kotest에서 @DataJpaTest의 트랜잭션은 왜 롤백되지 않았을까?
moseoh
spring-boot kotlin kotest

Kotest에서 @DataJpaTest의 트랜잭션은 왜 롤백되지 않았을까?

moseoh · 2024년 09월 14일

JPA Repository를 테스트할 때 @DataJpaTest는 거의 국룰처럼 사용됩니다. 테스트가 끝나면 자동으로 데이터를 롤백해주어 테스트 격리성을 보장해주는 아주 편리한 어노테이션이죠. 그런데 이 @DataJpaTest를 Kotest의 BehaviorSpec과 함께 사용하다가, 당연히 롤백될 거라 믿었던 데이터가 롤백되지 않아 테스트가 깨지는 문제를 겪었습니다.

문제 상황: 실패하는 테스트

문제는 BehaviorSpec을 사용한 아래 테스트 코드에서 발생했습니다.

@DataJpaTest
class UserRepositoryTest @Autowired constructor(
private val userRepository: UserRepository
) : BehaviorSpec({
Given("existsByEmail") {
val user = UserFixture.createEntity() // 같은 이메일로 유저 생성
userRepository.save(user)
// ...
}
Given("findByEmail") {
val user = UserFixture.createEntity() // 같은 이메일로 유저 생성
userRepository.save(user)
// ...
}
})

각각의 Given 블록은 독립적인 시나리오로, 테스트 실행 시 서로에게 영향을 주지 않아야 합니다. 그래서 첫 번째 Given 블록이 끝나면 user 데이터가 롤백될 거라고 기대했죠. 하지만 전체 테스트를 실행하니, 두 번째 Given 블록에서 아래와 같은 Unique key violation 오류가 발생했습니다.

Image

could not execute statement [Unique index or primary key violation: "PUBLIC.CONSTRAINT_INDEX_2 ON PUBLIC.""USER""(EMAIL NULLS FIRST) ...

첫 번째 Given에서 저장한 데이터가 롤백되지 않고 그대로 남아, 두 번째 Given에서 동일한 이메일로 데이터를 저장하려다 중복 키 오류가 난 것이죠.

원인 분석: 왜 롤백이 안 됐을까?

가장 먼저 든 생각은 ‘Kotest 환경에서는 @DataJpaTest@Transactional이 제대로 동작하지 않는 건가?’ 였습니다. 이 가설을 검증하기 위해, 트랜잭션 동작 과정을 좀 더 깊이 파고들었습니다.

1. @DataJpaTest의 롤백 원리

먼저 @DataJpaTest 어노테이션의 내부를 살펴보면, @Transactional을 포함하고 있는 것을 알 수 있습니다.

@Target(ElementType.TYPE)
// ...
@Transactional // <-- 여기!
public @interface DataJpaTest { ... }

Spring 테스트 프레임워크에서는 TransactionalTestExecutionListener라는 클래스가 @Transactional 어노테이션이 붙은 테스트의 트랜잭션을 관리합니다. 그리고 이 리스너는 기본적으로 테스트가 끝나면 트랜잭션을 롤백하도록 설정되어 있습니다. (@Rollback(true)가 기본값)

2. JUnit과의 비교 테스트

가설을 확인하기 위해, 먼저 익숙한 JUnit 환경에서 동일한 테스트가 어떻게 동작하는지 확인해 봤습니다. 트랜잭션 로그를 보기 위해 application.yml에 아래 설정을 추가했습니다.

logging:
level:
org.springframework.orm.jpa: DEBUG
org.springframework.transaction: DEBUG

JUnit 테스트 코드:

@DataJpaTest
class UserRepositoryJUnitJpaTest {
@Test
fun test() {
userRepository.save(UserFixture.createEntity("email"))
}
}

JUnit 실행 로그:

// 테스트 메소드 이름으로 트랜잭션 생성
Creating new transaction with name [....UserRepositoryJUnitJpaTest.test]: ...
...
Hibernate: insert into user ...
...
// 테스트 종료 후 롤백 실행
Initiating transaction rollback
Rolling back JPA transaction on EntityManager ...

예상대로 JUnit에서는 @Test 메소드 전체를 하나의 트랜잭션으로 묶고, 테스트가 끝나자마자 롤백하는 것을 로그로 명확히 확인할 수 있었습니다. 데이터베이스에도 데이터는 남지 않았죠.

3. Kotest에서의 동작 확인

이제 문제의 Kotest 코드를 실행하고 로그를 살펴봤습니다.

Kotest 실행 로그:

// 어? 트랜잭션이 repository.save() 메소드 이름으로 생성된다.
Creating new transaction with name [...SimpleJpaRepository.save]: ...
...
Hibernate: insert into user ...
...
// 롤백이 아니라 커밋이 실행된다!
Initiating transaction commit
Committing JPA transaction on EntityManager ...

로그에서 결정적인 차이를 발견했습니다. Kotest에서는 Given 블록 전체가 트랜잭션으로 묶이는 것이 아니라, userRepository.save() 메소드가 호출되는 시점에 새로운 트랜잭션이 시작되고, 메소드 실행이 끝나자마자 바로 커밋되어 버렸습니다.

이것이 원인이었습니다. Kotest의 테스트 실행 생명주기와 Spring의 TransactionalTestExecutionListener가 관리하는 트랜잭션의 생명주기가 서로 맞지 않았던 것입니다.

해결 방법: Kotest에 Spring 테스트 생명주기 알려주기

다행히 Kotest는 이런 문제를 해결할 수 있도록 kotest-extensions-spring 라이브러리를 통해 SpringTestExtension을 제공합니다. 이 익스텐션을 사용하면 Spring 테스트 컨텍스트의 생명주기를 Kotest의 테스트 구조에 맞게 조절할 수 있습니다.

SpringTestLifecycleMode에는 두 가지 옵션이 있습니다.

  • Root: BehaviorSpecGiven처럼 최상위 테스트 블록을 기준으로 트랜잭션을 시작하고 종료합니다.
  • Test: BehaviorSpecWhen이나 Then처럼 최하위 테스트 블록을 기준으로 트랜잭션을 관리합니다. 제가 원했던 것은 각 Given 블록마다 데이터가 격리되는 것이었으므로, SpringTestLifecycleMode.Root 옵션이 필요했습니다.
@DataJpaTest
class UserRepositoryTest @Autowired constructor(
private val userRepository: UserRepository,
) : BehaviorSpec({
// 이 한 줄을 추가하여 트랜잭션 생명주기를 설정
extensions(SpringTestExtension(SpringTestLifecycleMode.Root))
Given("given") {
userRepository.save(UserFixture.createEntity("email1"))
}
})

이 코드를 추가하고 다시 테스트를 실행하니, 실행 로그가 JUnit과 동일하게 Given 블록 단위로 트랜잭션이 시작되고 끝나면 롤백되는 것을 확인할 수 있었습니다.

프로젝트 전체에 설정 적용하기

모든 테스트 클래스마다 extensions(...) 코드를 추가하는 것은 번거로운 일입니다. Kotest는 프로젝트 레벨의 공통 설정을 지원하므로, 이 설정을 모든 테스트에 한 번에 적용할 수 있습니다.

src/test/kotlin 경로에 아래와 같이 KotestConfig 파일을 생성하면 됩니다.

src/test/kotlin/KotestConfig.kt
import io.kotest.core.config.AbstractProjectConfig
import io.kotest.extensions.spring.SpringTestExtension
import io.kotest.extensions.spring.SpringTestLifecycleMode
class KotestConfig : AbstractProjectConfig() {
override fun extensions() = listOf(SpringTestExtension(SpringTestLifecycleMode.Root))
}

이렇게 하면 개별 테스트 파일에 별도 설정을 추가하지 않아도, 모든 @DataJpaTest가 예상대로 동작하게 됩니다.

요약

Kotest 환경에서 @DataJpaTest의 자동 롤백 기능이 동작하지 않는다면, Kotest의 테스트 생명주기와 Spring의 트랜잭션 관리 생명주기가 일치하지 않기 때문일 가능성이 높습니다.

이 문제는 KotestConfigSpringTestExtension(SpringTestLifecycleMode.Root)를 전역으로 설정함으로써 간단하게 해결할 수 있습니다.