ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Spring 트랜잭션 롤백 관리: rollback-only 이슈 분석과 해결
    📖 개발 공부 2024. 11. 10. 14:39
    "Transaction silently rolled back because it has been marked as rollback-only"
    요 에러는 어떤 상황에 발생할까?

    문제 상황

    회사에서 주기적으로 돌고 있는 배치잡에서 

    Transaction silently rolled back because it has been marked as rollback-only
    

    이런 로그가 발생하면서, 데이터 처리가 누락된 현상들을 종종 발견했다.

     

    확인해보니 데이터 변경 로직이 포함된 유스케이스 메서드에 @Transactional 어노테이션이 적용되어 있으며, 이 메서드에서 @Transactional 이 적용된 FindHelper 클래스의 메서드를 호출하고 있다.

     

    이 FindHelper 메서드에서 특정 조건에 부합하지 않으면, IllegalStateException 예외를 던지도록 되어있다.

    다음 코드와 같이 유스케이스에서는 이 경우 runCatching을 통해서 null을 반환하도록 하였다. 그리고 data가 null이면 skip 메서드를 호출한다.

      val data = runCatching { findHelper.find(id).data }.getOrNull()
          ?.takeIf { it.deletedAt == null }
    
      if (data == null) {
          skip()
      }
    

    skip() 메서드 내부에서는 데이터를 변경하는 로직이 포함되어있다.

     

    설명만 보면 코드는 트랜잭션 관리에 문제가 없어 보이지만, 실제로는 rollback-only 트랜잭션 상태를 고려하지 않은 흐름 제어가 문제의 핵심이다.

     

    트랜잭션이 이미 rollback-only로 설정된 상태에서, 유스케이스에서 runCatching을 통해 IllegalStateException을 잡고 null을 반환하도록 설계된 것이다.

     

    그렇기 때문에 skip() 메서드가 호출되어 데이터 변경 로직이 실행되더라도, 트랜잭션이 rollback-only 상태이기 때문에 변경된 내용이 커밋되지 않고 Transaction silently rolled back because it has been marked as rollback-only와 같은 예상치 못한 롤백이 발생했다. 

     

    → 결국, skip() 메서드 내부의 데이터 변경이 반영되지 않는 문제로 이어진 것이다. 

    문제 해결

    • 사실 FindHelper는 단순 조회 로직에 그치기 때문에, 트랜잭션이 필요없다. 그렇기 때문에 FindHelper에 달려있는 @Transactional 어노테이션을 제거했다.
      기존에 하위 메서드에 어노테이션이 달려있을 때에는 해당 메서드 내에서 트랜잭션 상태를 변경할 수 있었는데,
      어노테이션을 제거함으로써 유스케이스에서 해당 예외를 catch해 핸들링하면, rollback 마킹 없이 정상적으로 처리할 수 있게 된다.
    • 만약 내부에도 트랜잭션이 필요한 상태라면, Transaction propagation 속성을 REQUIRED_NEW 를 설정할 수 있다. REQUIRES_NEW 설정 시 현재 트랜잭션을 별도의 트랜잭션으로 실행하여 외부 트랜잭션에 독립적인 동작을 수행할 수 있다. 이는 내부 트랜잭션에서 발생한 예외가 외부 트랜잭션에 영향을 미치지 않게 할 수 있다.

    트랜잭션 롤백 메커니즘 분석

    Spring 트랜잭션 관리의 기본 동작에서, @Transactional 메서드 내에서 발생한 체크되지 않은 예외(Unchecked Exception, 여기서는 IllegalStateException)는 트랜잭션을 자동으로 rollback-only 상태로 설정한다.

     

    위의 상황을 재현하는 코드를 작성해보았다.

    @Service
    class TransactionPerformer(
        private val rollbackTestService: RollbackTestService,
        private val testRepository: TestRepository,
    ) {
        @Transactional
        fun execute() {
            try {
                rollbackTestService.performTransaction()
            } catch (e: Exception) {
                println("TransactionPerformer -- \\n" +  e.message)
            }
            testRepository.save(TestEntity(name = "parent transaction"))
        }
    }
    
    @Service
    class **RollbackTestService**(
        private val testRepository: TestRepository
    ) {
        @Transactional
        fun performTransaction() {
            // 데이터 저장
            testRepository.save(TestEntity(name = "First Entity"))
    
            // Checked Exception은 롤백을 마킹하지 않는다.
    //        throw Exception("Simulated Exception")
            // RuntimeException은 롤백을 마킹한다.
            throw IllegalStateException("Simulated IllegalStateException")
        }
    }

     

    throw IllegalStateException("Simulated IllegalStateException") 로직을 넣었을 때, 로그 level을 조정하여서 자세한 로그를 살펴보았다.

    # 트랜잭션(com.may.transactionrollback.TransactionPerformer.execute) 생성
    o.s.orm.jpa.JpaTransactionManager        : Creating new transaction with name [com.may.transactionrollback.TransactionPerformer.execute]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
    
    ---
    
    # IllegalStateException 예외 발생 직후
    o.s.t.i.TransactionInterceptor           : Completing transaction for [com.may.transactionrollback.RollbackTestService.performTransaction] after exception: java.lang.IllegalStateException: Simulated IllegalStateException
    o.s.orm.jpa.JpaTransactionManager        : Participating transaction failed - **marking existing transaction as rollback-only**
    
    ---
    
    # 커밋 시점
    o.s.orm.jpa.JpaTransactionManager        : Initiating transaction commit
    o.s.orm.jpa.JpaTransactionManager        : Committing JPA transaction on EntityManager [SessionImpl(801808302<open>)]
    o.h.e.t.internal.TransactionImpl         : committing
    cResourceLocalTransactionCoordinatorImpl : On commit, transaction was marked for roll-back only, rolling back

     

    이렇게 IllegalStateException 예외가 발생한 이후 rollback-only=true가 설정되었다.
     marking existing transaction as rollback-only

    이후, 커밋하는 시점에 rollback-only 마킹을 확인하고 롤백을 진행하는 것을 확인할 수 있다.

     

    위의 설명을 다이어그램으로 표현해보았다.

     

    IllegalStateException이 아닌, Exception 예외를 던지는 경우엔 Checked Exception이기  때문에 롤백을 마킹하지 않고, 커밋이 성공한다. 만약 Exception 또한 롤백으로 간주하고 싶다면 rollbackFor 속성을 추가하면 된다. (@Transactional(rollbackFor = [Exception::class]))

     


    이번 이슈와 분석을 통해, rollback-only 트랜잭션 상태에 대한 공부를 할 수 있었다. 앞으로는 트랜잭션 상태와 예외 처리 방식을 더 세심하게 관리하여 유사한 문제에 대해 유연하고 빠른 대처를 할 수 있을 듯! 💪

     

     

    🔗 참고

     

    응? 이게 왜 롤백되는거지? | 우아한형제들 기술블로그

    이 글은 얼마 전 에러로그 하나에 대한 호기심과 의문으로 시작해서 스프링의 트랜잭션 내에서 예외가 어떻게 처리되는지를 이해하기 위해 삽질을 해본 경험을 토대로 쓰여졌습니다. 스프링의

    techblog.woowahan.com

     

    반응형

    댓글

Designed by Tistory.