JPA Repository를 테스트할 때 @DataJpaTest
는 거의 국룰처럼 사용됩니다. 테스트가 끝나면 자동으로 데이터를 롤백해주어 테스트 격리성을 보장해주는 아주 편리한 어노테이션이죠. 그런데 이 @DataJpaTest
를 Kotest의 BehaviorSpec
과 함께 사용하다가, 당연히 롤백될 거라 믿었던 데이터가 롤백되지 않아 테스트가 깨지는 문제를 겪었습니다.
문제 상황: 실패하는 테스트
문제는 BehaviorSpec
을 사용한 아래 테스트 코드에서 발생했습니다.
@DataJpaTestclass 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
오류가 발생했습니다.
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 테스트 코드:
@DataJpaTestclass UserRepositoryJUnitJpaTest { @Test fun test() { userRepository.save(UserFixture.createEntity("email")) }}
JUnit 실행 로그:
// 테스트 메소드 이름으로 트랜잭션 생성Creating new transaction with name [....UserRepositoryJUnitJpaTest.test]: ......Hibernate: insert into user ......// 테스트 종료 후 롤백 실행Initiating transaction rollbackRolling 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 commitCommitting JPA transaction on EntityManager ...
로그에서 결정적인 차이를 발견했습니다. Kotest에서는 Given
블록 전체가 트랜잭션으로 묶이는 것이 아니라, userRepository.save()
메소드가 호출되는 시점에 새로운 트랜잭션이 시작되고, 메소드 실행이 끝나자마자 바로 커밋되어 버렸습니다.
이것이 원인이었습니다. Kotest의 테스트 실행 생명주기와 Spring의 TransactionalTestExecutionListener
가 관리하는 트랜잭션의 생명주기가 서로 맞지 않았던 것입니다.
해결 방법: Kotest에 Spring 테스트 생명주기 알려주기
다행히 Kotest는 이런 문제를 해결할 수 있도록 kotest-extensions-spring
라이브러리를 통해 SpringTestExtension
을 제공합니다. 이 익스텐션을 사용하면 Spring 테스트 컨텍스트의 생명주기를 Kotest의 테스트 구조에 맞게 조절할 수 있습니다.
SpringTestLifecycleMode
에는 두 가지 옵션이 있습니다.
Root
:BehaviorSpec
의Given
처럼 최상위 테스트 블록을 기준으로 트랜잭션을 시작하고 종료합니다.Test
:BehaviorSpec
의When
이나Then
처럼 최하위 테스트 블록을 기준으로 트랜잭션을 관리합니다. 제가 원했던 것은 각Given
블록마다 데이터가 격리되는 것이었으므로,SpringTestLifecycleMode.Root
옵션이 필요했습니다.
@DataJpaTestclass 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
파일을 생성하면 됩니다.
import io.kotest.core.config.AbstractProjectConfigimport io.kotest.extensions.spring.SpringTestExtensionimport io.kotest.extensions.spring.SpringTestLifecycleMode
class KotestConfig : AbstractProjectConfig() { override fun extensions() = listOf(SpringTestExtension(SpringTestLifecycleMode.Root))}
이렇게 하면 개별 테스트 파일에 별도 설정을 추가하지 않아도, 모든 @DataJpaTest
가 예상대로 동작하게 됩니다.
요약
Kotest 환경에서 @DataJpaTest
의 자동 롤백 기능이 동작하지 않는다면, Kotest의 테스트 생명주기와 Spring의 트랜잭션 관리 생명주기가 일치하지 않기 때문일 가능성이 높습니다.
이 문제는 KotestConfig
에 SpringTestExtension(SpringTestLifecycleMode.Root)
를 전역으로 설정함으로써 간단하게 해결할 수 있습니다.