[Spring] Transaction 전파

Transaction 전파

트랜잭션은 시작 지점 & 종료 지점이 명확하게 존재한다
시작 지점은 하나이지만 종료 지점은 둘로 분류할 수 있다

  1. Commit
  2. Rollback

특정 로직을 진행하다가 추가적인 트랜잭션이 진입하는 경우를 생각해보자
이 때 기존에 존재하는 트랜잭션에 대해서 추가적으로 진행되는 트랜잭션의 행동은 트랜잭션 전파 속성에 의해 결정된다

 

물리 트랜잭션 vs 논리 트랜잭션

단일 트랜잭션인 상황에서는 사실 트랜잭션의 개념 자체를 물리/논리로 나눌 필요가 없다

  • 논리 트랜잭션 그 자체가 물리 트랜잭션이기 때문에

하지만 여러 트랜잭션이 중첩된 상황에서는 물리/논리라는 개념으로 분리될 필요가 있다

이러한 상황에서 물리 트랜잭션 / 논리 트랜잭션간에는 다음과 같은 규칙이 존재한다

1. 모든 논리 트랜잭션이 commit되어야 물리 트랜잭션이 commit된다
2. 하나의 논리 트랜잭션이라도 rollback된다면 물리 트랜잭션은 rollback된다

 

하지만 다음과 같은 상황에서는 약간 다르다

이 경우 논리 트랜잭션 2는 전파 속성이 REQUIRES_NEW이므로 물리 트랜잭션1과는 아예 관련이 없는 새로운 트랜잭션에서 동작한다
따라서 위의 규칙은 예외없이 따라야 하지만 논리 트랜잭션 2의 경우 물리 트랜잭션 1에 어떠한 영향을 미치지 않는다

  • 별개의 트랜잭션이므로

 

물리 트랜잭션과 논리 트랜잭션에 대해서 간단하게 정리 해보면 다음과 같다

물리 트랜잭션
- 실제 DB에 적용이 되는 트랜잭션 단위
- Connection을 통해서 commit-rollback을 하는 단위

논리 트랜잭션
- TransactionManager를 통해서 관리되는 트랜잭션 단위

 

Spring에서의 기본 트랜잭션 처리 전략

Spring에서 기본적으로 적용되는 예외에 대한 트랜잭션 처리 전략은 다음과 같다

  • Checked Exception = 커밋
  • Unchecked Exception = 롤백

 

Checked냐 Unchecked냐에 따라 커밋/롤백되는 이 기본 전략은 어디까지나 Spring에서 기본적으로 제공해주는 전략이다

이를 오해하고 자바에서 예외 처리에 대한 기본 전략을 아래와 같이 이해하면 안된다

자바에서도 마찬가지로 Checked Exception = 커밋 & Unchecked Exception = 롤백?
  • 자바에서의 전략은 전적으로 개발자가 알아서 구현해야 한다
  • 언어 레벨에서 위와 같은 메커니즘을 제공해주지 않는다

 

Spring에서도 원한다면 @Transactional의 rollbackFor, noRollbackFor,...등의 옵션으로 예외에 대한 처리 전략을 커스텀하게 적용할 수 있다

 

 

Propagation에 따른 트랜잭션 처리 흐름

테스트 Commit/Rollback 구조

  • Commit = 정상 흐름
  • Rollback = Unchecked Exception 발생
logging:
  level:
    org.springframework.orm.jpa.JpaTransactionManager: DEBUG
    org.springframework.jdbc.support.JdbcTransactionManager: DEBUG
    org.hibernate.resource.transaction: DEBUG

 

REQUIRED (기본값)

기존 트랜잭션 X = 새로운 트랜잭션 생성
기존 트랜잭션 O = 기존 트랜잭션에 참여

 

1) Main(Commit) & Sub(Commit)

// RequiredMainComponent
@Transactional
fun case1() {
    log.info("===== Main(REQUIRED) BEGIN =====")
    log.info(">> Main 로직 진행...")
    sub.commit()
    log.info("===== Main(REQUIRED) COMMIT =====")
}

// RequiredSubComponent
@Transactional
fun commit() {
    log.info("===== Sub(REQUIRED) BEGIN =====")
    log.info(">> Sub 로직 진행...")
    log.info("===== Sub(REQUIRED) COMMIT =====")
}

// Test
@Test
fun `Main(Commit) & Sub(Commit)`() {
    shouldNotThrowAny { main.case1() }
}

  • 두 트랜잭션(물리, 논리) 모두 Commit이므로 최종 처리 역시 Commit으로 진행된다

 

2) Main(Commit) & Sub(Rollback)

// RequiredMainComponent
@Transactional
fun case2() {
    log.info("===== Main(REQUIRED) BEGIN =====")
    log.info(">> Main 로직 진행...")
    try {
        sub.rollback()
    } catch (_: Exception) {
        log.info(">> 논리 트랜잭션 Exception 발생...")
    } finally {
        log.info("===== Main(REQUIRED) COMMIT =====")
    }
}

// RequiredSubComponent
@Transactional
fun rollback() {
    log.info("===== Sub(REQUIRED) BEGIN =====")
    log.info(">> Sub 로직 진행...")
    log.info("===== Sub(REQUIRED) ROLLBACK =====")

    throw TxException()
}

// Test
@Test
fun `Main(Commit) & Sub(Rollback)`() {
    shouldThrow<UnexpectedRollbackException> { main.case2() }
}

  • RequiredSubComponent 논리 트랜잭션 내부에서 Unchecked Exception이 발생했고 그에 따라서 참여하고 있는 트랜잭션에 rollbackOnly 마킹을 하게 된다
  • 그 후 RequiredMainComponent 물리 트랜잭션에서 Commit을 시도하려고 해도 이미 rollbackOnly 마킹이 되어있으므로 UnexpectedRollbackException과 함께 Rollback된다

 

3) Main(Rollback) & Sub(Commit)

// RequiredMainComponent
@Transactional
fun case3() {
    log.info("===== Main(REQUIRED) BEGIN =====")
    log.info(">> Main 로직 진행...")
    sub.commit()
    log.info("===== Main(REQUIRED) ROLLBACK =====")

    throw TxException()
}

// RequiredSubComponent
@Transactional
fun commit() {
    log.info("===== Sub(REQUIRED) BEGIN =====")
    log.info(">> Sub 로직 진행...")
    log.info("===== Sub(REQUIRED) COMMIT =====")
}

// Test
@Test
fun `Main(Rollback) & Sub(Commit)`() {
    shouldThrow<TxException> { main.case3() }
}

  • RequiredSubComponent 논리 트랜잭션은 정상적으로 진행되었다
  • 하지만 RequiredMainComponent 물리 트랜잭션에서 Unchecked Exception이 발생했기 때문에 Rollback을 진행한다

 

REQUIRES_NEW

기존 트랜잭션 X = 새로운 트랜잭션 생성
기존 트랜잭션 O = 새로운 트랜잭션 생성
  • 기존 트랜잭션이 있든 없든 무조건 새로운 트랜잭션을 생성해서 진행한다
  • 만약 기존 트랜잭션이 존재하는 경우 기존 트랜잭션을 잠시 중단하고 새로운 트랜잭션을 진행한다

 

1) SubA(Commit) & SubB(Rollback)

// RequiresNewMainComponent
@Transactional
fun case1() {
    log.info("===== Main(REQUIRED) BEGIN =====")
    log.info(">> Main 로직 진행...")
    subA.commit()
    try {
        subB.rollback()
    } catch (_: Exception) {
    }
    log.info("===== Main(REQUIRED) COMMIT =====")
}

// RequiresNewSubComponentA
@Transactional
fun commit() {
    log.info("===== SubA(REQUIRED) BEGIN =====")
    log.info(">> SubA 로직 진행...")
    log.info("===== SubA(REQUIRED) COMMIT =====")
}

// RequiresNewSubComponentB
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun rollback() {
    log.info("===== SubB(REQUIRES_NEW) BEGIN =====")
    log.info(">> SubB 로직 진행...")
    log.info("===== SubB(REQUIRES_NEW) ROLLBACK =====")

    throw TxException()
}

// Test
@Test
fun `SubA(Commit) & SubB(Rollback)`() {
    shouldNotThrowAny { main.case1() }
}

  • Main & SubA는 정상적으로 Commit, SubB는 Unchecked Exception이 발생하였다
  • 여기서 SubB는 REQUIRES_NEW이므로 Main & SubA와는 별개의 트랜잭션에서 로직이 진행된다
    • Main & SubA의 기존 트랜잭션은 잠시 중단 (suspending)된다
  • 따라서 결과는 다음과 같이 진행된다
    • Main & SubA의 TX1 = Commit
    • SubB의 TX2 = Unchecked Exception으로 인한 Rollback

 

2) SubA(Rollback) & SubB(Rollback)

// RequiresNewMainComponent
@Transactional
fun case2() {
    log.info("===== Main(REQUIRED) BEGIN =====")
    log.info(">> Main 로직 진행...")
    try {
        subA.rollback()
    } catch (_: Exception) {
    }
    subB.commit()
    log.info("===== Main(REQUIRED) COMMIT =====")
}

// RequiresNewSubComponentA
@Transactional
fun rollback() {
    log.info("===== SubA(REQUIRED) BEGIN =====")
    log.info(">> SubA 로직 진행...")
    log.info("===== SubA(REQUIRED) ROLLBACK =====")

    throw TxException()
}

// RequiresNewSubComponentB
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun commit() {
    log.info("===== SubB(REQUIRES_NEW) BEGIN =====")
    log.info(">> SubB 로직 진행...")
    log.info("===== SubB(REQUIRES_NEW) COMMIT =====")
}

// Test
@Test
fun `SubA(Rollback) & SubB(Rollback)`() {
    shouldThrow<UnexpectedRollbackException> { main.case2() }
}

  • SubA는 Unchecked Exception이 발생하였고 SubB는 정상적으로 Commit되었다
  • 따라서 결과는 다음과 같이 진행된다
    • Main & SubA의 TX1 = rollbackOnly 마킹으로 인한 UnexpectedRollbackException + Rollback
    • SubB의 TX2 = Commit

 

SUPPORTS

기존 트랜잭션 X = 트랜잭션 없이 진행
기존 트랜잭션 O = 기존 트랜잭션에 참여

 

1) 기존 트랜잭션 X

// SupportsMainComponent
fun case1() {
    log.info("===== Main(No TX) BEGIN =====")
    log.info(">> Main 로직 진행...")
    sub.execute()
    log.info("===== Main(No TX) COMMIT =====")
}

// SupportsSubComponent
@Transactional(propagation = Propagation.SUPPORTS)
fun execute() {
    log.info("===== Sub(SUPPORTS) BEGIN =====")
    log.info(">> Sub 로직 진행...")
    log.info("===== Sub(SUPPORTS) COMMIT =====")
}

// Test
@Test
fun `기존 트랜잭션 X`() {
    shouldNotThrowAny { main.case1() }
}

  • 기존 활성화 트랜잭션이 없으므로 SUPPORTS에서도 트랜잭션 없이 진행한다

 

2) 기존 트랜잭션 O

// SupportsMainComponent
@Transactional
fun case2() {
    log.info("===== Main(No TX) BEGIN =====")
    log.info(">> Main 로직 진행...")
    sub.execute()
    log.info("===== Main(No TX) COMMIT =====")
}

// SupportsSubComponent
@Transactional(propagation = Propagation.SUPPORTS)
fun execute() {
    log.info("===== Sub(SUPPORTS) BEGIN =====")
    log.info(">> Sub 로직 진행...")
    log.info("===== Sub(SUPPORTS) COMMIT =====")
}

// Test
@Test
fun `기존 트랜잭션 O`() {
    shouldNotThrowAny { main.case2() }
}

  • 기존 활성화 트랜잭션이 존재하므로 SUPPORTS에서도 그대로 참여한다

 

NOT_SUPPORTED

기존 트랜잭션 X = 트랜잭션 없이 진행
기존 트랜잭션 O = 트랜잭션 없이 진행

 

1) 기존 트랜잭션 X

// NotSupportedMainComponent
fun case1() {
    log.info("===== Main(No TX) BEGIN =====")
    log.info(">> Main 로직 진행...")
    sub.execute()
    log.info("===== Main(No TX) COMMIT =====")
}

// NotSupportedSubComponent
@Transactional(propagation = Propagation.NOT_SUPPORTED)
fun execute() {
    log.info("===== Sub(NOT_SUPPORTED) BEGIN =====")
    log.info(">> Sub 로직 진행...")
    log.info("===== Sub(NOT_SUPPORTED) COMMIT =====")
}

// Test
@Test
fun `기존 트랜잭션 X`() {
    shouldNotThrowAny { main.case1() }
}

  • 기존 활성화 트랜잭션이 없으므로 NOT_SUPPORTED에서도 트랜잭션 없이 진행한다

 

2) 기존 트랜잭션 O

// NotSupportedMainComponent
@Transactional
fun case2() {
    log.info("===== Main(No TX) BEGIN =====")
    log.info(">> Main 로직 진행...")
    sub.execute()
    log.info("===== Main(No TX) COMMIT =====")
}

// NotSupportedSubComponent
@Transactional(propagation = Propagation.NOT_SUPPORTED)
fun execute() {
    log.info("===== Sub(NOT_SUPPORTED) BEGIN =====")
    log.info(">> Sub 로직 진행...")
    log.info("===== Sub(NOT_SUPPORTED) COMMIT =====")
}

// Test
@Test
fun `기존 트랜잭션 O`() {
    shouldNotThrowAny { main.case2() }
}

  • 기존 활성화 트랜잭션이 존재하더라도 NOT_SUPPORTED에서는 트랜잭션 없이 진행한다
  • 기존 트랜잭션은 잠시 중단 (suspending)된다

 

MANDATORY

기존 트랜잭션 X = IllegalTransactionStateException 예외 발생
기존 트랜잭션 O = 기존 트랜잭션에 참여
  • 반드시 기존 트랜잭션이 존재해야만 하고 없으면 예외가 발생한다

 

1) 기존 트랜잭션 X

// MandatoryMainComponent
fun case1() {
    log.info("===== Main(No TX) BEGIN =====")
    log.info(">> Main 로직 진행...")
    sub.execute()
    log.info("===== Main(No TX) COMMIT =====")
}

// MandatorySubComponent
@Transactional(propagation = Propagation.MANDATORY)
fun execute() {
    log.info("===== Sub(MANDATORY) BEGIN =====")
    log.info(">> Sub 로직 진행...")
    log.info("===== Sub(MANDATORY) COMMIT =====")
}

// Test
@Test
fun `기존 트랜잭션 X`() {
    shouldThrow<IllegalTransactionStateException> { main.case1() }
}

  • MANDATORY는 기존 활성화 트랜잭션이 반드시 존재해야만 하고 위와 같이 존재하지 않으면 IllegalTransactionStateException이 발생한다

 

2) 기존 트랜잭션 O

// MandatoryMainComponent
@Transactional
fun case2() {
    log.info("===== Main(No TX) BEGIN =====")
    log.info(">> Main 로직 진행...")
    sub.execute()
    log.info("===== Main(No TX) COMMIT =====")
}

// MandatorySubComponent
@Transactional(propagation = Propagation.MANDATORY)
fun execute() {
    log.info("===== Sub(MANDATORY) BEGIN =====")
    log.info(">> Sub 로직 진행...")
    log.info("===== Sub(MANDATORY) COMMIT =====")
}

// Test
@Test
fun `기존 트랜잭션 O`() {
    shouldNotThrowAny { main.case2() }
}

  • MANDATORY는 기존 활성화 트랜잭션이 존재하면 위와 같이 참여한다

 

NEVER

기존 트랜잭션 X = 트랜잭션 없이 진행
기존 트랜잭션 O = IllegalTransactionStateException 예외 발생
  • MANDATORY와는 반대로 반드시 기존 트랜잭션이 없어야하고 있으면 예외가 발생한다

 

1) 기존 트랜잭션 X

// NeverMainComponent
fun case1() {
    log.info("===== Main(No TX) BEGIN =====")
    log.info(">> Main 로직 진행...")
    sub.execute()
    log.info("===== Main(No TX) COMMIT =====")
}

// NeverSubComponent
@Transactional(propagation = Propagation.NEVER)
fun execute() {
    log.info("===== Sub(NEVER) BEGIN =====")
    log.info(">> Sub 로직 진행...")
    log.info("===== Sub(NEVER) COMMIT =====")
}

// Test
@Test
fun `기존 트랜잭션 X`() {
    shouldNotThrowAny { main.case1() }
}

  • NEVER는 기존 활성화 트랜잭션이 반드시 존재하지 않아야 하고 위와 같이 존재하면 IllegalTransactionStateException이 발생한다

 

2) 기존 트랜잭션 O

// NeverMainComponent
@Transactional
fun case2() {
    log.info("===== Main(No TX) BEGIN =====")
    log.info(">> Main 로직 진행...")
    sub.execute()
    log.info("===== Main(No TX) COMMIT =====")
}

// NeverSubComponent
@Transactional(propagation = Propagation.NEVER)
fun execute() {
    log.info("===== Sub(NEVER) BEGIN =====")
    log.info(">> Sub 로직 진행...")
    log.info("===== Sub(NEVER) COMMIT =====")
}

// Test
@Test
fun `기존 트랜잭션 O`() {
    shouldThrow<IllegalTransactionStateException> { main.case2() }
}

  • NEVER는 기존 활성화 트랜잭션이 존재하지 않으면 위와 같이 트랜잭션이 없는 상태로 진행된다

 

NESTED

기존 트랜잭션 X = 새로운 트랜잭션 생성
기존 트랜잭션 O = 중첩 트랜잭션 생성
  • 중첩된 본인 트랜잭션은 외부 트랜잭션으로부터 영향을 받지만 영향을 주지는 않는다
    • 본인이 Rollback → 외부는 영향 X
    • 외부에서 Rollback → 본인도 Rollback

 

1) 기존 트랜잭션 X

// NestedMainComponent
fun case1() {
    log.info("===== Main(No Tx) BEGIN =====")
    log.info(">> Main 로직 진행...")
    sub.commit()
    log.info("===== Main(No Tx) COMMIT =====")
}

// NestedSubComponent
@Transactional(propagation = Propagation.NESTED)
fun commit() {
    log.info("===== Sub(NESTED) BEGIN =====")
    log.info(">> Sub 로직 진행...")
    log.info("===== Sub(NESTED) COMMIT =====")
}

// Test
@Test
fun `기존 트랜잭션 X`() {
    shouldNotThrowAny { main.case1() }
}

  • NESTED는 기존 활성화 트랜잭션이 존재하지 않으면 새로운 트랜잭션을 생성해서 진행한다

 

2) 기존 트랜잭션 O → Main(Commit) & Sub(Rollback)

// NestedMainComponent
@Transactional
fun case2(jpaTx: Boolean) {
    log.info("===== Main(REQUIRED) BEGIN =====")
    log.info(">> Main 로직 진행...")

    if (jpaTx) { // JPA
        sub.rollback()
    } else { // JDBC
        try {
            sub.rollback()
        } catch (_: Exception) {
        } finally {
            log.info("===== Main(REQUIRED) COMMIT =====")
        }
    }
}

// NestedSubComponent
@Transactional(propagation = Propagation.NESTED)
fun rollback() {
    log.info("===== Sub(NESTED) BEGIN =====")
    log.info(">> Sub 로직 진행...")
    log.info("===== Sub(NESTED) ROLLBACK =====")

    throw TxException()
}

 

JPA

@Test
fun `기존 트랜잭션 O - Main(Commit) & Sub(Rollback)`() {
    shouldThrow<NestedTransactionNotSupportedException> { main.case2(jpaTx = true) }
}

  • JpaTransactionManager는 중첩 트랜잭션 NESTED를 지원하지 않는다

 

JDBC

@Test
fun `기존 트랜잭션 O - Main(Commit) & Sub(Rollback)`() {
    shouldNotThrowAny { main.case2(jpaTx = false) }
}

  • JdbcTransactionManager는 JpaTransactionManager와는 달리 중첩 트랜잭션 NESTED를 지원한다
  • 결과로 보면 알 수 있듯이 중첩 트랜잭션은 외부 트랜잭션에 영향을 주지 않는다

 

3) 기존 트랜잭션 O → Main(Rollback) & Sub(Commit)

// NestedMainComponent
@Transactional
fun case3() {
    log.info("===== Main(REQUIRED) BEGIN =====")
    log.info(">> Main 로직 진행...")
    sub.commit()
    log.info("===== Main(REQUIRED) ROLLBACK =====")

    throw TxException()
}

// NestedSubComponent
@Transactional(propagation = Propagation.NESTED)
fun commit() {
    log.info("===== Sub(NESTED) BEGIN =====")
    log.info(">> Sub 로직 진행...")
    log.info("===== Sub(NESTED) COMMIT =====")
}

 

JPA

@Test
fun `기존 트랜잭션 O - Main(Rollback) & Sub(Commit)`() {
    shouldThrow<NestedTransactionNotSupportedException> { main.case3() }
}

  • JpaTransactionManager는 중첩 트랜잭션 NESTED를 지원하지 않는다

 

JDBC

@Test
fun `기존 트랜잭션 O - Main(Rollback) & Sub(Commit)`() {
    shouldThrow<TxException> { main.case3() }
}

  • JdbcTransactionManager는 JpaTransactionManager와는 달리 중첩 트랜잭션 NESTED를 지원한다
  • 최종 결과는 외부 트랜잭션의 Unchecked Exception으로 인해 Rollback된다

 

 

관련된 코드는 깃허브에서 확인할 수 있습니다