Kotest 6.x + Spring Boot 3.x에서 @Transactional 롤백이 안될 때
문제 상황
Spring Boot 3.5.15와 Kotest 6.2.1 환경에서 Repository 통합 테스트를 작성하던 중, 테스트 데이터가 롤백되지 않고 실제 DB에 계속 쌓이는 문제가 발생했습니다.
@Tags("test")
@SpringBootTest
@ActiveProfiles("local", "test")
@Transactional
@ApplyExtension(SpringExtension::class)
class ProductRepositoryTest(
private val productRepository: ProductRepository,
) : BehaviorSpec() {
init {
given("상품 Repository 테스트") {
`when`("상품 저장 테스트") {
val product = Product(
name = "test-product",
price = 10000
)
then("상품이 저장된다") {
val saved = productRepository.save(product)
saved.id shouldNotBe null
}
}
}
}
}
테스트를 실행할 때마다 DB에 test-product라는 이름의 상품이 계속 쌓였습니다. 95개가 넘는 테스트 데이터가 누적되어 있었습니다.
환경 정보
// build.gradle.kts
dependencies {
// Spring Boot 3.5.15
implementation("org.springframework.boot:spring-boot-starter-data-jpa:3.5.15")
// Kotest
testImplementation("io.kotest:kotest-runner-junit5:6.2.1")
testImplementation("io.kotest.extensions:kotest-extensions-spring:6.2.1")
}
- Kotlin: 2.4.0
- Spring Boot: 3.5.15
- Kotest: 6.2.1
- Kotest Spring Extension: 6.2.1
- MySQL 8.0
근본 원인: ProjectConfig가 있었는데 왜 안됐을까?
사실 프로젝트에는 이미 ProjectConfig가 설정되어 있었습니다:
// src/test/kotlin/com/myapp/configuration/ProjectConfig.kt
object ProjectConfig : AbstractProjectConfig() {
// rollback 관련 동작 안해서 아래 설정 추가
override val extensions = listOf(SpringExtension(SpringTestLifecycleMode.Root))
}
Kotest 공식 문서에서는 "ProjectConfig만 추가하면 된다"고 했는데, 롤백이 작동하지 않았습니다.
문제 1: ProjectConfig가 잘못된 패키지에 있었음
Kotest는 "모든 테스트의 공통 상위 패키지"에서 ProjectConfig를 찾습니다.
Kotest 문서에서:
"if you have tests located in com.sksamuel.myproject.services, com.sksamuel.myproject.common, and com.sksamuel.myproject.data, then you can place your config class in any of com.sksamuel.myproject, com.sksamuel, and com."
우리의 패키지 구조:
테스트 패키지들:
com.myapp.api.controller.ProductControllerTest
com.myapp.api.repository.ProductRepositoryTest
com.myapp.api.service.ProductServiceTest
공통 상위 패키지: com.myapp.api
ProjectConfig 위치:
❌ com.myapp.configuration.ProjectConfig (형제 패키지)
✅ com.myapp.api.ProjectConfig (공통 상위 패키지)configuration은 api와 형제(sibling) 패키지라서 Kotest가 찾지 못했습니다!
문제 2: 잘못된 @Transactional 사용
import jakarta.transaction.Transactional // ❌ JTA용, Spring Test와 통합 안됨
Jakarta의 @Transactional은 JTA(Java Transaction API)를 위한 것으로, Spring Test의 트랜잭션 롤백 메커니즘과 통합되지 않습니다.
해결 과정
시도 1: H2 In-Memory DB로 변경
// application-test.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
결과: 실패
- Profile 검증 문제 (국가 + 환경 프로필 필요)
- Feign 클라이언트 설정 누락
- 복잡한 설정 요구사항으로 포기
시도 2: ProjectConfig에 SpringTestLifecycleMode.Root 추가
object ProjectConfig : AbstractProjectConfig() {
override val extensions = listOf(SpringExtension(SpringTestLifecycleMode.Root))
}
결과: 여전히 실패
- ProjectConfig가
configuration패키지에 있어서 Kotest가 찾지 못함 - jakarta.transaction.Transactional 사용으로 롤백 안됨
시도 3: 각 테스트에서 명시적으로 SpringExtension 등록
@SpringBootTest
@Transactional // jakarta.transaction.Transactional
class ProductRepositoryTest : BehaviorSpec() {
override val extensions = listOf(SpringExtension(SpringTestLifecycleMode.Root))
@Autowired
private lateinit var productRepository: ProductRepository
}
결과: 테스트는 통과하지만 롤백 안됨
- jakarta.transaction.Transactional 때문
최종 해결책
두 가지를 동시에 수정해야 합니다:
1. ProjectConfig를 공통 상위 패키지로 이동
// ❌ 이전: src/test/kotlin/com/myapp/configuration/ProjectConfig.kt
// ✅ 이후: src/test/kotlin/com/myapp/api/ProjectConfig.kt
package com.myapp.api // ← 공통 상위 패키지
import io.kotest.core.config.AbstractProjectConfig
import io.kotest.extensions.spring.SpringExtension
import io.kotest.extensions.spring.SpringTestLifecycleMode
object ProjectConfig : AbstractProjectConfig() {
override val extensions = listOf(SpringExtension(SpringTestLifecycleMode.Root))
}
2. Spring의 @Transactional 사용
방법 A: Field Injection (간단한 방법)
import org.springframework.transaction.annotation.Transactional // ✅ Spring 것 사용
@Tags("test")
@SpringBootTest
@ActiveProfiles("local", "test")
@Transactional // ← Spring 것
class ProductRepositoryTest : BehaviorSpec() {
// ✅ ProjectConfig가 공통 패키지에 있으면 이 줄 불필요!
// override val extensions = listOf(SpringExtension(SpringTestLifecycleMode.Root))
@Autowired
private lateinit var productRepository: ProductRepository
init {
given("상품 Repository 테스트") {
`when`("상품 저장 테스트") {
val product = Product(name = "test-product", price = 10000)
then("상품이 저장된다") {
val saved = productRepository.save(product)
saved.id shouldNotBe null
}
}
}
}
}
방법 B: Constructor Injection (불변성, 명시성 선호 시)
import org.springframework.test.context.TestConstructor
import org.springframework.transaction.annotation.Transactional
@Tags("test")
@SpringBootTest
@ActiveProfiles("local", "test")
@Transactional
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
class ProductRepositoryTest(
private val productRepository: ProductRepository, // ← val로 불변
) : BehaviorSpec() {
init {
given("상품 Repository 테스트") {
`when`("상품 저장 테스트") {
val product = Product(name = "test-product", price = 10000)
then("상품이 저장된다") {
val saved = productRepository.save(product)
saved.id shouldNotBe null
}
}
}
}
}
@TestConstructor의 역할:
- Spring Test에게 "생성자의 모든 파라미터를 자동으로 주입해라"고 알려줌
- 없으면 "zero-arg constructor not found" 에러 발생
- Field injection보다 불변성과 명시성 측면에서 선호됨
비교표
| 구분 | 잘못된 방법 | 올바른 방법 |
|---|---|---|
| ProjectConfig 위치 | com.myapp.configuration.ProjectConfig (형제 패키지) |
com.myapp.api.ProjectConfig (공통 상위 패키지) |
| @Transactional | jakarta.transaction.Transactional (JTA용) |
org.springframework.transaction.annotation.Transactional (Spring용) |
| 개별 테스트 설정 | override val extensions = ... 필요 |
불필요 (ProjectConfig가 자동 적용) |
| 롤백 작동 | ❌ 안됨 | ✅ 됨 |
핵심 포인트
1. ProjectConfig 위치가 매우 중요
Kotest는 다음 세 가지 방법으로 ProjectConfig를 찾습니다:
io.kotest.provided.ProjectConfig클래스kotest.framework.config.fqn시스템 프로퍼티로 지정- 모든 테스트의 공통 상위 패키지에 있는 ProjectConfig 클래스 ← 가장 흔한 방법
공통 상위 패키지란?
- 테스트가
com.myapp.api.controller,com.myapp.api.repository,com.myapp.api.service에 있다면 - 공통 상위 패키지는
com.myapp.api,com.myapp,com중 하나 com.myapp.configuration은 형제 패키지라서 공통 상위 패키지가 아님!
2. 올바른 @Transactional 사용
| 어노테이션 | 패키지 | 용도 | 테스트 롤백 |
|---|---|---|---|
@Transactional |
jakarta.transaction |
JTA 트랜잭션 | ❌ 지원 안함 |
@Transactional |
org.springframework.transaction.annotation |
Spring 트랜잭션 | ✅ 자동 롤백 |
IntelliJ IDEA의 자동 import는 jakarta 것을 먼저 선택하는 경우가 많으므로 주의!
3. SpringTestLifecycleMode
| 모드 | 트랜잭션 범위 | 사용 시나리오 |
|---|---|---|
Root |
Spec 전체에 하나의 트랜잭션 | BehaviorSpec, DescribeSpec 등 |
Test |
각 테스트마다 별도 트랜잭션 | @Test 메서드마다 격리 필요 시 |
Kotest의 BehaviorSpec 구조(given/when/then)에서는 Root 모드가 적합합니다.
Gradle 설정으로 ProjectConfig 명시하기 (선택사항)
패키지 구조 변경이 어려운 경우, Gradle에서 명시적으로 지정할 수 있습니다:
// build.gradle.kts
tasks.test {
useJUnitPlatform()
systemProperty(
"kotest.framework.config.fqn",
"com.myapp.configuration.ProjectConfig"
)
}
하지만 공통 상위 패키지에 두는 것이 가장 권장되는 방법입니다.
검증 방법
1. ProjectConfig가 로드되는지 확인
// ProjectConfig.kt
object ProjectConfig : AbstractProjectConfig() {
init {
println("🔧 ProjectConfig loaded!") // 테스트 실행 시 출력됨
}
override val extensions = listOf(SpringExtension(SpringTestLifecycleMode.Root))
}
2. 롤백 확인
테스트를 여러 번 실행하고 DB를 확인:
-- 테스트 실행 후 데이터가 남아있지 않아야 함
SELECT COUNT(*) FROM product WHERE name = 'test-product';
-- 결과: 0
결론
Kotest 6.x + Spring Boot 3.x 환경에서 Repository 통합 테스트 롤백이 안될 때는:
ProjectConfig를 모든 테스트의 공통 상위 패키지에 위치
- ❌
com.myapp.configuration(형제 패키지) - ✅
com.myapp.api(공통 상위 패키지)
- ❌
Spring의
@Transactional사용- ❌
jakarta.transaction.Transactional - ✅
org.springframework.transaction.annotation.Transactional
- ❌
ProjectConfig에 SpringExtension 설정
override val extensions = listOf(SpringExtension(SpringTestLifecycleMode.Root))
이 조합으로 각 테스트에서 override val extensions 없이도 자동으로 트랜잭션이 롤백되어 DB에 테스트 데이터가 남지 않습니다.
완성된 구조
src/test/kotlin/
└── com/
└── myapp/# Kotest 6.x + Spring Boot 3.x에서 @Transactional 롤백이 안될 때
## 문제 상황
Spring Boot 3.5.15와 Kotest 6.2.1 환경에서 Repository 통합 테스트를 작성하던 중, 테스트 데이터가 롤백되지 않고 실제 DB에 계속 쌓이는 문제가 발생했습니다.
```kotlin
@Tags("test")
@SpringBootTest
@ActiveProfiles("local", "test")
@Transactional
@ApplyExtension(SpringExtension::class)
class ProductRepositoryTest(
private val productRepository: ProductRepository,
) : BehaviorSpec() {
init {
given("상품 Repository 테스트") {
`when`("상품 저장 테스트") {
val product = Product(
name = "test-product",
price = 10000
)
then("상품이 저장된다") {
val saved = productRepository.save(product)
saved.id shouldNotBe null
}
}
}
}
}테스트를 실행할 때마다 DB에 test-product라는 이름의 상품이 계속 쌓였습니다. 95개가 넘는 테스트 데이터가 누적되어 있었습니다.
환경 정보
// build.gradle.kts
dependencies {
// Spring Boot 3.5.15
implementation("org.springframework.boot:spring-boot-starter-data-jpa:3.5.15")
// Kotest
testImplementation("io.kotest:kotest-runner-junit5:6.2.1")
testImplementation("io.kotest.extensions:kotest-extensions-spring:6.2.1")
}
- Kotlin: 2.4.0
- Spring Boot: 3.5.15
- Kotest: 6.2.1
- Kotest Spring Extension: 6.2.1
- MySQL 8.0
근본 원인: ProjectConfig가 있었는데 왜 안됐을까?
사실 프로젝트에는 이미 ProjectConfig가 설정되어 있었습니다:
// src/test/kotlin/com/myapp/configuration/ProjectConfig.kt
object ProjectConfig : AbstractProjectConfig() {
// rollback 관련 동작 안해서 아래 설정 추가
override val extensions = listOf(SpringExtension(SpringTestLifecycleMode.Root))
}
Kotest 공식 문서에서는 "ProjectConfig만 추가하면 된다"고 했는데, 롤백이 작동하지 않았습니다.
문제 1: ProjectConfig가 잘못된 패키지에 있었음
Kotest는 "모든 테스트의 공통 상위 패키지"에서 ProjectConfig를 찾습니다.
Kotest 문서에서:
"if you have tests located in com.sksamuel.myproject.services, com.sksamuel.myproject.common, and com.sksamuel.myproject.data, then you can place your config class in any of com.sksamuel.myproject, com.sksamuel, and com."
우리의 패키지 구조:
테스트 패키지들:
com.myapp.api.controller.ProductControllerTest
com.myapp.api.repository.ProductRepositoryTest
com.myapp.api.service.ProductServiceTest
공통 상위 패키지: com.myapp.api
ProjectConfig 위치:
❌ com.myapp.configuration.ProjectConfig (형제 패키지)
✅ com.myapp.api.ProjectConfig (공통 상위 패키지)configuration은 api와 형제(sibling) 패키지라서 Kotest가 찾지 못했습니다!
문제 2: 잘못된 @Transactional 사용
import jakarta.transaction.Transactional // ❌ JTA용, Spring Test와 통합 안됨
Jakarta의 @Transactional은 JTA(Java Transaction API)를 위한 것으로, Spring Test의 트랜잭션 롤백 메커니즘과 통합되지 않습니다.
해결 과정
시도 1: H2 In-Memory DB로 변경
// application-test.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
결과: 실패
- Profile 검증 문제 (국가 + 환경 프로필 필요)
- Feign 클라이언트 설정 누락
- 복잡한 설정 요구사항으로 포기
시도 2: ProjectConfig에 SpringTestLifecycleMode.Root 추가
object ProjectConfig : AbstractProjectConfig() {
override val extensions = listOf(SpringExtension(SpringTestLifecycleMode.Root))
}
결과: 여전히 실패
- ProjectConfig가
configuration패키지에 있어서 Kotest가 찾지 못함 - jakarta.transaction.Transactional 사용으로 롤백 안됨
시도 3: 각 테스트에서 명시적으로 SpringExtension 등록
@SpringBootTest
@Transactional // jakarta.transaction.Transactional
class ProductRepositoryTest : BehaviorSpec() {
override val extensions = listOf(SpringExtension(SpringTestLifecycleMode.Root))
@Autowired
private lateinit var productRepository: ProductRepository
}
결과: 테스트는 통과하지만 롤백 안됨
- jakarta.transaction.Transactional 때문
최종 해결책
두 가지를 동시에 수정해야 합니다:
1. ProjectConfig를 공통 상위 패키지로 이동
// ❌ 이전: src/test/kotlin/com/myapp/configuration/ProjectConfig.kt
// ✅ 이후: src/test/kotlin/com/myapp/api/ProjectConfig.kt
package com.myapp.api // ← 공통 상위 패키지
import io.kotest.core.config.AbstractProjectConfig
import io.kotest.extensions.spring.SpringExtension
import io.kotest.extensions.spring.SpringTestLifecycleMode
object ProjectConfig : AbstractProjectConfig() {
override val extensions = listOf(SpringExtension(SpringTestLifecycleMode.Root))
}
2. Spring의 @Transactional 사용
방법 A: Field Injection (간단한 방법)
import org.springframework.transaction.annotation.Transactional // ✅ Spring 것 사용
@Tags("test")
@SpringBootTest
@ActiveProfiles("local", "test")
@Transactional // ← Spring 것
class ProductRepositoryTest : BehaviorSpec() {
// ✅ ProjectConfig가 공통 패키지에 있으면 이 줄 불필요!
// override val extensions = listOf(SpringExtension(SpringTestLifecycleMode.Root))
@Autowired
private lateinit var productRepository: ProductRepository
init {
given("상품 Repository 테스트") {
`when`("상품 저장 테스트") {
val product = Product(name = "test-product", price = 10000)
then("상품이 저장된다") {
val saved = productRepository.save(product)
saved.id shouldNotBe null
}
}
}
}
}
방법 B: Constructor Injection (불변성, 명시성 선호 시)
import org.springframework.test.context.TestConstructor
import org.springframework.transaction.annotation.Transactional
@Tags("test")
@SpringBootTest
@ActiveProfiles("local", "test")
@Transactional
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
class ProductRepositoryTest(
private val productRepository: ProductRepository, // ← val로 불변
) : BehaviorSpec() {
init {
given("상품 Repository 테스트") {
`when`("상품 저장 테스트") {
val product = Product(name = "test-product", price = 10000)
then("상품이 저장된다") {
val saved = productRepository.save(product)
saved.id shouldNotBe null
}
}
}
}
}
@TestConstructor의 역할:
- Spring Test에게 "생성자의 모든 파라미터를 자동으로 주입해라"고 알려줌
- 없으면 "zero-arg constructor not found" 에러 발생
- Field injection보다 불변성과 명시성 측면에서 선호됨
비교표
| 구분 | 잘못된 방법 | 올바른 방법 |
|---|---|---|
| ProjectConfig 위치 | com.myapp.configuration.ProjectConfig (형제 패키지) |
com.myapp.api.ProjectConfig (공통 상위 패키지) |
| @Transactional | jakarta.transaction.Transactional (JTA용) |
org.springframework.transaction.annotation.Transactional (Spring용) |
| 개별 테스트 설정 | override val extensions = ... 필요 |
불필요 (ProjectConfig가 자동 적용) |
| 롤백 작동 | ❌ 안됨 | ✅ 됨 |
핵심 포인트
1. ProjectConfig 위치가 매우 중요
Kotest는 다음 세 가지 방법으로 ProjectConfig를 찾습니다:
io.kotest.provided.ProjectConfig클래스kotest.framework.config.fqn시스템 프로퍼티로 지정- 모든 테스트의 공통 상위 패키지에 있는 ProjectConfig 클래스 ← 가장 흔한 방법
공통 상위 패키지란?
- 테스트가
com.myapp.api.controller,com.myapp.api.repository,com.myapp.api.service에 있다면 - 공통 상위 패키지는
com.myapp.api,com.myapp,com중 하나 com.myapp.configuration은 형제 패키지라서 공통 상위 패키지가 아님!
2. 올바른 @Transactional 사용
| 어노테이션 | 패키지 | 용도 | 테스트 롤백 |
|---|---|---|---|
@Transactional |
jakarta.transaction |
JTA 트랜잭션 | ❌ 지원 안함 |
@Transactional |
org.springframework.transaction.annotation |
Spring 트랜잭션 | ✅ 자동 롤백 |
IntelliJ IDEA의 자동 import는 jakarta 것을 먼저 선택하는 경우가 많으므로 주의!
3. SpringTestLifecycleMode
| 모드 | 트랜잭션 범위 | 사용 시나리오 |
|---|---|---|
Root |
Spec 전체에 하나의 트랜잭션 | BehaviorSpec, DescribeSpec 등 |
Test |
각 테스트마다 별도 트랜잭션 | @Test 메서드마다 격리 필요 시 |
Kotest의 BehaviorSpec 구조(given/when/then)에서는 Root 모드가 적합합니다.
Gradle 설정으로 ProjectConfig 명시하기 (선택사항)
패키지 구조 변경이 어려운 경우, Gradle에서 명시적으로 지정할 수 있습니다:
// build.gradle.kts
tasks.test {
useJUnitPlatform()
systemProperty(
"kotest.framework.config.fqn",
"com.myapp.configuration.ProjectConfig"
)
}
하지만 공통 상위 패키지에 두는 것이 가장 권장되는 방법입니다.
검증 방법
1. ProjectConfig가 로드되는지 확인
// ProjectConfig.kt
object ProjectConfig : AbstractProjectConfig() {
init {
println("🔧 ProjectConfig loaded!") // 테스트 실행 시 출력됨
}
override val extensions = listOf(SpringExtension(SpringTestLifecycleMode.Root))
}
2. 롤백 확인
테스트를 여러 번 실행하고 DB를 확인:
-- 테스트 실행 후 데이터가 남아있지 않아야 함
SELECT COUNT(*) FROM product WHERE name = 'test-product';
-- 결과: 0
결론
Kotest 6.x + Spring Boot 3.x 환경에서 Repository 통합 테스트 롤백이 안될 때는:
ProjectConfig를 모든 테스트의 공통 상위 패키지에 위치
- ❌
com.myapp.configuration(형제 패키지) - ✅
com.myapp.api(공통 상위 패키지)
- ❌
Spring의
@Transactional사용- ❌
jakarta.transaction.Transactional - ✅
org.springframework.transaction.annotation.Transactional
- ❌
ProjectConfig에 SpringExtension 설정
override val extensions = listOf(SpringExtension(SpringTestLifecycleMode.Root))
이 조합으로 각 테스트에서 override val extensions 없이도 자동으로 트랜잭션이 롤백되어 DB에 테스트 데이터가 남지 않습니다.
완성된 구조
src/test/kotlin/
└── com/
└── myapp/
└── api/
├── ProjectConfig.kt ← 이 위치!
├── controller/
│ └── ProductControllerTest.kt
├── repository/
│ └── ProductRepositoryTest.kt ← override val extensions 불필요
└── service/
└── ProductServiceTest.kt참고 자료
'프로그래밍 > Kotlin' 카테고리의 다른 글
| data class와 BeanPropertyRowMapper 오류 (feat. Java Records) (4) | 2025.05.20 |
|---|---|
| Kotlin + JPA 의 val id 이슈 (0) | 2025.01.21 |
| Rest API와 Grpc API 서버를 하나로 서비스 할 수 있다고?! (5) | 2024.08.28 |
| kotlin - jackson 관련 이슈 (토비의 스프링 6 강의) (0) | 2024.07.05 |
| ktlint lint 적용 (0) | 2024.02.08 |