우아한 기술블로그에서 설명하지 않은 부분이 헷갈려서 이 글을 쓰게 됐다. 위의 블로그를 읽고 생각하게 된 것은, “RuntimeException 이 발생하면 무조건 rollback 이 발생한다.” 였다. 하지만 이는 사실이 아니었다.

결론을 먼저 말하자면 아래와 같다.

  • @Transactional 이 붙은 함수가 완료되기 전에 catch Exception 이 있으면 롤백은 일어나지 않는다.
  • @Transactional 이 붙은 함수가 완료된 후에 catch Exception 이 있으면 롤백이 일어난다.

결론 도출을 위한 코드

테스트를 위해 아래와 같이 kotlin 코드를 작성했다.

@Service
@Transactional
class OuterService(
    private val memberRepository: MemberRepository,
    private val innerService: InnerService,
) {
    fun tryCatchAndThrow() {
        try {
            memberRepository.save(Member())
            throw RuntimeException("Outer: intentionally thrown")
        } catch (e: RuntimeException) {
            println(e)
        }
    }

    fun outerTryCatchAndInnerThrow() {
        try {
            innerService.`throw`()
        } catch (e: RuntimeException) {
            println(e)
        }
    }

    fun innerTryCatchAndInnerThrow() {
        innerService.tryCatchAndThrow()
    }
}
@Service
@Transactional
class InnerService(
    private val memberRepository: MemberRepository,
) {
    fun `throw`() {
        memberRepository.save(Member())
        throw RuntimeException("Inner: intentionally thrown")
    }

    fun tryCatchAndThrow() {
        try {
            memberRepository.save(Member())
            throw RuntimeException("Inner: intentionally thrown")
        } catch (e: Exception) {
            println(e)
        }
    }
}

InnerService 를 참조하는 클래스는 OuterSerivce 뿐이라고 가정하고 OuterService 에 대해 아래와 같이 테스트 코드를 작성했다.

@SpringBootTest
@ActiveProfiles("test")
@TestConstructor(autowireMode = ALL)
@Import(TestcontainersConfiguration::class)
class OuterServiceTest(
    private val outerService: OuterService,
    private val memberRepository: MemberRepository,
) {
    @BeforeEach
    fun tearDown() {
        memberRepository.deleteAll()
    }

    @Test
    fun `innerService 존재 X, outerService 예외 발생 - catch 하면 rollback X`() {
        assertDoesNotThrow { outerService.tryCatchAndThrow() }
        assertTrue(memberRepository.findAll().isNotEmpty())
    }

    @Test
    fun `innerService 존재 O, innerService 예외 발생 - innerService 가 catch 하면 rollback X`() {
        assertDoesNotThrow { outerService.innerTryCatchAndInnerThrow() }
        assertTrue(memberRepository.findAll().isNotEmpty())
    }

    @Test
    fun `innerService 존재 O, innerService 예외 발생 - outerServide 가 catch 하더라도 rollback O`() {
        assertThrows<UnexpectedRollbackException> { outerService.outerTryCatchAndInnerThrow() }
        assertTrue(memberRepository.findAll().isEmpty())
    }
}

위 테스트 코드를 보면 트랜잭션이 끝나기 전에 catch Exception 한다면 예외가 발생하지 않고, 결과적으로 롤백이 발생하지 않음을 확인할 수 있다.

로그 확인

아래는 rollback 이 발생하는 유일한 테스트 함수 innerService 존재 O, innerService 예외 발생 - outerServide 가 catch 하더라도 rollback O 의 로그다.

o.h.e.t.internal.TransactionImpl         : On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
o.h.e.t.internal.TransactionImpl         : begin
o.h.e.t.internal.TransactionImpl         : committing
o.h.e.t.internal.TransactionImpl         : On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
o.h.e.t.internal.TransactionImpl         : begin
cResourceLocalTransactionCoordinatorImpl : JDBC transaction marked for rollback-only (exception provided for stack trace)

java.lang.Exception: exception just for purpose of providing stack trace

java.lang.RuntimeException: Inner: intentionally thrown
o.h.e.t.internal.TransactionImpl         : committing
cResourceLocalTransactionCoordinatorImpl : On commit, transaction was marked for roll-back only, rolling back
o.h.e.t.internal.TransactionImpl         : On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
o.h.e.t.internal.TransactionImpl         : begin
o.h.e.t.internal.TransactionImpl         : committing

아래는 rollback 이 발생하지 않는 테스트 함수의 로그로, 테스트 메서드 innerService 존재 X, outerService 예외 발생 - catch 하면 rollback X 의 로그다.

2024-12-10T22:37:34.342+09:00 DEBUG 10375 --- [    Test worker] o.h.e.t.internal.TransactionImpl         : On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
2024-12-10T22:37:34.342+09:00 DEBUG 10375 --- [    Test worker] o.h.e.t.internal.TransactionImpl         : begin
2024-12-10T22:37:34.345+09:00 DEBUG 10375 --- [    Test worker] o.h.e.t.internal.TransactionImpl         : committing
2024-12-10T22:37:34.348+09:00 DEBUG 10375 --- [    Test worker] o.h.e.t.internal.TransactionImpl         : On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
2024-12-10T22:37:34.348+09:00 DEBUG 10375 --- [    Test worker] o.h.e.t.internal.TransactionImpl         : begin
java.lang.RuntimeException: Outer: intentionally thrown
2024-12-10T22:37:34.350+09:00 DEBUG 10375 --- [    Test worker] o.h.e.t.internal.TransactionImpl         : committing
2024-12-10T22:37:34.357+09:00 DEBUG 10375 --- [    Test worker] o.h.e.t.internal.TransactionImpl         : On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
2024-12-10T22:37:34.357+09:00 DEBUG 10375 --- [    Test worker] o.h.e.t.internal.TransactionImpl         : begin
2024-12-10T22:37:34.360+09:00 DEBUG 10375 --- [    Test worker] o.h.e.t.internal.TransactionImpl         : committing

아래는 rollback 이 발생하지 않는 테스트 함수의 로그로, 테스트 메서드 innerService 존재 O, innerService 예외 발생 - innerService 가 catch 하면 rollback X 의 로그다.

2024-12-10T22:37:34.364+09:00 DEBUG 10375 --- [    Test worker] o.h.e.t.internal.TransactionImpl         : On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
2024-12-10T22:37:34.365+09:00 DEBUG 10375 --- [    Test worker] o.h.e.t.internal.TransactionImpl         : begin
2024-12-10T22:37:34.369+09:00 DEBUG 10375 --- [    Test worker] o.h.e.t.internal.TransactionImpl         : committing
2024-12-10T22:37:34.376+09:00 DEBUG 10375 --- [    Test worker] o.h.e.t.internal.TransactionImpl         : On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
2024-12-10T22:37:34.376+09:00 DEBUG 10375 --- [    Test worker] o.h.e.t.internal.TransactionImpl         : begin
java.lang.RuntimeException: Inner: intentionally thrown
2024-12-10T22:37:34.378+09:00 DEBUG 10375 --- [    Test worker] o.h.e.t.internal.TransactionImpl         : committing
2024-12-10T22:37:34.382+09:00 DEBUG 10375 --- [    Test worker] o.h.e.t.internal.TransactionImpl         : On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
2024-12-10T22:37:34.382+09:00 DEBUG 10375 --- [    Test worker] o.h.e.t.internal.TransactionImpl         : begin
2024-12-10T22:37:34.384+09:00 DEBUG 10375 --- [    Test worker] o.h.e.t.internal.TransactionImpl         : committing

테스트 환경 설정

build.gradle.kts

plugins {
    val kotlinVersion = "1.9.25"
    kotlin("jvm") version kotlinVersion
    kotlin("plugin.spring") version kotlinVersion
    id("org.springframework.boot") version "3.3.5"
    id("io.spring.dependency-management") version "1.1.6"
    kotlin("plugin.jpa") version kotlinVersion
}

group = "org.example"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    runtimeOnly("com.mysql:mysql-connector-j")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
    testImplementation("org.springframework.boot:spring-boot-testcontainers")
    testImplementation("org.testcontainers:junit-jupiter")
    testImplementation("org.testcontainers:mysql")
}

tasks.test {
    useJUnitPlatform()
}
kotlin {
    jvmToolchain(17)
}

테스트 컨테이너 설정

@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {
    @Bean
    @ServiceConnection
    fun mysqlContainer(): MySQLContainer<*> {
        return MySQLContainer(DockerImageName.parse(MYSQL_8_0))
    }

    private object Constant {
        const val MYSQL_8_0 = "mysql:8.0"
    }
}

application-test.yml

spring:
  jpa:
    hibernate:
      ddl-auto: create
logging:
  level:
    ROOT: warn
    org.hibernate.engine.transaction.internal: DEBUG
    org.hibernate.resource.transaction.backend.jdbc: DEBUG

부록: Propagation 에 따른 테스트

IneerService Transactional Propagation 유형에 따라 테스트 코드의 성공 실패 여부를 체크해보았다. 실패해는 경우에는 취소선 (strikethrough) 을 그어서 표시했다.

테스트 메서드: innerService 존재 X, outerService 예외 발생 - catch 하면 rollback X

REQUIRED
SUPPORTS
MANDATORY
REQUIRES_NEW
NOT_SUPPORTED
NEVER
NESTED

테스트 메서드: innerService 존재 O, innerService 예외 발생 - innerService 가 catch 하면 rollback X

REQUIRED
SUPPORTS
MANDATORY
REQUIRES_NEW
NOT_SUPPORTED
NEVER
NESTED

테스트 메서드: innerService 존재 O, innerService 예외 발생 - outerServide 가 catch 하더라도 rollback O

REQUIRED
SUPPORTS
MANDATORY
REQUIRES_NEW
NOT_SUPPORTED
NEVER
NESTED

References