본문 바로가기
  • 개발하는 곰돌이
Development/JPA

[JPA] 트랜잭션의 전파와 UnexpectedRollbackException 이슈

by 개발하는 곰돌이 2024. 1. 29.

목차

    들어가기 전에

    지금까지 스프링부트 프로젝트에 JPA를 사용하면서 트랜잭션이 필요한 메소드에 기계적으로 @Transactional 어노테이션을 붙여서 사용하다가 처음 보는 예외를 맞닥뜨렸다.

    트랜잭션에 rollback-only가 마킹되어 자동으로 롤백되었다고 한다. 이게 도대체 무슨 말인가 싶어서 관련 내용을 찾아보고 정리해본다.

    문제 상황

    DB에 API 호출 이력을 남기기 위해 컨트롤러에 @Transactional(dontRollbackOn = [Exception::class])을 달아주고 트랜잭션이 필요한 서비스 계층의 메소드들에 @Transactional을 달아준 상황이었다.

     

    대충 아래와 같은 느낌이라고 보면 될 것 같다.(실제 코드가 아닌 상황 재현을 위한 코드)

    @Transactional(dontRollbackOn = [Exception::class])
    @RestController
    class TransactionController(
        private val transactionService: TransactionService,
        private val apiLogRepository: ApiLogRepository
    ) {
        @GetMapping("/transaction")
        fun transaction() {
            apiLogRepository.save(ApiRequestLog())
            transactionService.throwException(TransactionDemo(name = "demo"))
        }
    }
    @Service
    class TransactionService(private val transactionRepository: TransactionRepository) {
        @Transactional
        fun throwException(transactionDemo: TransactionDemo) {
            transactionRepository.save(transactionDemo)
            throw RuntimeException("Exception")
        }
    }

    이런 상황에서 서비스 계층의 메소드에서 예외가 터져버리면 위의 UnexpectedRollbackException이 발생하는 문제가 있었다.

     

    지금까진 서비스 계층에만 @Transactional을 달아서 사용하고 있었기 때문에 굉장히 생소한 예외였다.

    트랜잭션의 전파

    관련 내용을 찾다보니 트랜잭션의 전파라는 개념이 있었다.

     

    위의 상황에서 TransactionController의 메소드들은 클래스단에 선언된 @Transactional로 인해 트랜잭션 메소드가 된다. 그리고 이 트랜잭션 메소드에서 TransactionService에 있는 또다른 트랜잭션 메소드를 호출하고 그 메소드에서 예외가 터져버리는 상황으로 볼 수 있다. 

     

    여기서 @Transactional의 속성 중 TxType이라는 타입이 있다. 어노테이션이 붙은 메소드나 클래스가 어떤식으로 동작할 것인지를 결정하는 Enum 클래스인데 기본값은 REQUIRED로 되어있다.

    기본값인 REQUIRED의 설명을 보면 트랜잭션 컨텍스트 외부에서 호출되면 새로운 트랜잭션을 시작하고 트랜잭션 컨텍스트 내부에서 호출되면 기존에 존재하는 해당 트랜잭션 컨텍스트 내에서 실행된다는 것을 볼 수 있다.

    문제의 상황으로 돌아가보자

    문제의 상황으로 돌아가서 로직이 어떤식으로 진행되는지 대강 정리하면 아래와 같다.

    1. TransactionController의 transaction()이 실행되면서 새로운 트랜잭션 컨텍스트가 시작된다.
    2. transaction()은 ApiLogRepository에 데이터를 저장하고 TransactionService의 throwException()을 실행한다.
    3. throwException() 또한 트랜잭션 메소드이지만 TransactionController의 transaction()을 통해 실행되었기 때문에 기존의 트랜잭션 컨텍스트에 참여한다.
    4. throwException()에서 RuntimeException이 발생하여 트랜잭션에 rollback-only를 마킹하고 내부 트랜잭션을 완료처리 한다.
    5. TransactionController의 transaction()으로 서비스의 throwException()에서 발생한 RuntimeException이 전파되어 외부 트랜잭션을 처리하려고 한다.
    6. TransactionController의 트랜잭션 컨텍스트는 모든 종류의 Exception에 대해 롤백을 하지 않는다고 선언했기 때문에 문제 없이 커밋하려고 하지만 트랜잭션에 rollback-only가 마킹되어 있어서 UnexpectedRollbackException과 함께 롤백이 되어버린다!

    즉, 트랜잭션 컨텍스트에 참여한 메소드에서 예외를 별도로 처리하지 않으면 해당 메소드의 외부에서 아무리 롤백을 하지 않으려고 해도 그냥 롤백되어 버린다!

     

    프로젝트에서는 서비스에서 전파된 예외를 포함한 모든 컨트롤러의 예외를 @RestControllerAdvice@ExceptionHandler로 처리하고 있기 때문에 서비스에서 예외가 발생할 수 있는 로직마다 try-catch를 해주기에는 너무 번거로워진다.

    어떻게 해결할까?

    우선 문제의 상황을 되짚어보면 롤백이 진행되도록 설정된 내부 트랜잭션에서 예외가 발생하면 외부 트랜잭션의 설정과는 전혀 상관없이 롤백이 진행된다.

     

    그런데 여기서 @Transactional의 기본값인 REQUIRED는 트랜잭션 외부에서 호출되면 새로운 트랜잭션을 생성하고, 트랜잭션 내부에서 호출되면 외부의 트랜잭션에 참여한다고 했다.

     

    다시 생각해보면 두 트랜잭션이 별개의 트랜잭션으로 구성할 수 있다면 이 문제를 해결할 수 있지 않을까?

    외부 트랜잭션과 내부 트랜잭션을 분리하자

    여기서 TxType 중 REQUIRES_NEW를 사용할 수 있다. @Transactional에 해당 속성을 설정하면 어떠한 트랜잭션 내부에서 호출되었을 때 외부 트랜잭션을 일시 중지하고 새로운 트랜잭션을 생성하여 메소드를 실행한다. 그리고 새로운 트랜잭션이 완료되었을 때 일시 중지된 외부 트랜잭션을 재개한다. 

     

    다시 말해 외부 트랜잭션의 진행이 내부 트랜잭션에 영향을 받지 않게 된다!

     

    그렇다면 위의 TransactionService를 아래처럼 고칠 수 있을 것이다.

    @Service
    class TransactionService(private val transactionRepository: TransactionRepository) {
        @Transactional(Transactional.TxType.REQUIRES_NEW)
        fun throwException(transactionDemo: TransactionDemo) {
            transactionRepository.save(transactionDemo)
            throw RuntimeException("Exception")
        }
    }

    그런데 이런식으로 매번 @Transactional에 값을 넣기엔 귀찮으니까 커스텀 어노테이션을 작성해주자.

     

    Kotlin

    @Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
    @Retention(AnnotationRetention.RUNTIME)
    @Transactional(Transactional.TxType.REQUIRES_NEW)
    annotation class NewTransactional(
        val rollbackOn: Array<out KClass<*>> = [],
        val dontRollbackOn: Array<out KClass<*>> = []
    )

    Java

    @Target({ElementType.METHOD, ElementType.TYPE})
    @Retention(RetentionPolicy.RUNTIME)
    @Transactional(Transactional.TxType.REQUIRES_NEW)
    public @interface NewTransactional {
        Class[] rollbackOn() default {};
        Class[] dontRollbackOn() default {};
    }

     

    이제 위의 TransactionService 코드는 아래처럼 바꿀 수 있다!

    @Service
    class TransactionService(private val transactionRepository: TransactionRepository) {
        @NewTransactional
        fun throwException(transactionDemo: TransactionDemo) {
            transactionRepository.save(transactionDemo)
            throw RuntimeException("Exception")
        }
    }

     

    여기까지 진행했다면 서비스 계층에서 예외가 발생해도 컨트롤러 계층은 롤백되지 않는 것을 볼 수 있다!

    마치며

    사실 이렇게 컨트롤러에서 서비스 계층의 메소드를 실행할 때마다 새로운 트랜잭션을 만들면 성능상에 저하가 있진 않을지, 다른 이슈가 발생할 가능성은 없는지 우려되긴 한다. 이건 추후에 더 알아봐야 할 것 같다.

    참조링크

     

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

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

    techblog.woowahan.com

     

    댓글