프로그래밍/Kotlin

Kotest 6.x + Spring Boot 3.x에서 @Transactional 롤백이 안될 때

seungdols 2026. 6. 24. 17:51

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 (공통 상위 패키지)

configurationapi와 형제(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를 찾습니다:

  1. io.kotest.provided.ProjectConfig 클래스
  2. kotest.framework.config.fqn 시스템 프로퍼티로 지정
  3. 모든 테스트의 공통 상위 패키지에 있는 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 통합 테스트 롤백이 안될 때는:

  1. ProjectConfig를 모든 테스트의 공통 상위 패키지에 위치

    • com.myapp.configuration (형제 패키지)
    • com.myapp.api (공통 상위 패키지)
  2. Spring의 @Transactional 사용

    • jakarta.transaction.Transactional
    • org.springframework.transaction.annotation.Transactional
  3. 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 (공통 상위 패키지)

configurationapi와 형제(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를 찾습니다:

  1. io.kotest.provided.ProjectConfig 클래스
  2. kotest.framework.config.fqn 시스템 프로퍼티로 지정
  3. 모든 테스트의 공통 상위 패키지에 있는 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 통합 테스트 롤백이 안될 때는:

  1. ProjectConfig를 모든 테스트의 공통 상위 패키지에 위치

    • com.myapp.configuration (형제 패키지)
    • com.myapp.api (공통 상위 패키지)
  2. Spring의 @Transactional 사용

    • jakarta.transaction.Transactional
    • org.springframework.transaction.annotation.Transactional
  3. 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

참고 자료

반응형