Backend/Spring Framework

[Spring Boot] try catch로 잡아도 롤백 되는 경우에 대해 알아보자

mopil 2024. 5. 10. 23:57
반응형

@Transactional로 감싸진 블록은 RuntimeException을 상속받는 예외를 맞이하면 자동적으로 롤백을 해준다.

 

그런데 간혹, 여러 데이터를 저장할 때 일부는 실패하더라도 트랜잭션이 성공했으면 바라는 경우가 있다.

 

그럴 때 

 

Transaction silently rolled back because it has been marked as rollback-only라고 에러가 발생하면서 전체 롤백이 되는 경우가 있다.

 

왜 이런지 구체적으로 알아보도록 하자.

 

# 예시 상황

  • 총 5명의 User의 포인트를 1씩 증가시켜가며 저장할 것이다.
  • 이때 포인트가 2이면 예외를 발생시킨다.
  • 예외가 발생하더라도 다른 User는 저장에 성공해야 한다.
@Service
class UserTestService(
    private val userRepository: UserRepository,
) {
    @Transactional
    fun createUser(point: Long) {
        if (point == 2L) {
            throw RuntimeException("2번 유저는 안돼!!")
        }
        val user = User(point = point.toInt())
        userRepository.save(user)
    }
}

먼저 User를 저장할 UserTestService 예제 코드이다.

 

2 포인트만 예외를 발생시키고 나머지의 경우는 User를 성공적으로 저장한다.

 

 

@Service
class TestService3(
    private val userTestService: UserTestService
) {
    @Transactional
    fun bulkSaveUsers() {
        repeat(5) {
            try {
                userTestService.createUser(it.toLong())
            } catch (e: Exception) {
                println(e.message)
            }
        }
    }
}

 

그리고 총 5명의 User를 저장할 예제 Service이다. 이 Service의 bulkSaveUsers는 가상의 Controller에서 호출된다고 가정해 보자.

 

우리는 처리 중 예외가 발생하더라도 무시하고 계속 진행되길 원하므로 try catch로 userTestService를 감싸 예외처리 로직을 넣어주었다.

 

 

그렇지만 이 코드는 실행 시 

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

요 에러를 발생시키고 전체 롤백이 된다.

 

 

# 원인

원인은 TestService도 트랜잭션이 걸려있고, 내부로 호출하는 UserTestService도 트랜잭션이 걸려있어서

 

하위 클래스인 UserTestService에서 예외가 발생하면 여기 @Transactional 어노테이션이 강제 롤백 마킹을 해버리고

 

상위 호출 클래스인 TestService의 try catch로 예외를 잡더라도 강제 롤백 마킹이 되어있기 때문이다.

 

대충 그림으로 표현하면 요런 상황

 

 

# 왜 이런 걸까?

스프링 프레임워크가 이런 식으로 설계를 해놨기 때문이다 (...)

 

구체적으로 궁금한 사람들은 아래 포스팅이 더 잘 정리되어 있으므로 참조하길 바란다.

 

https://keencho.github.io/posts/transaction-rollback/

 

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

이게 왜 저장이 안될까? 서비스를 운영하다가 이 포스팅의 제목과 같이 Transaction silently rolled back because it has been marked as rollback-only 라는 에러 메시지를 받게 되었습니다. 해당 에러의 원인은 금방

keencho.github.io

 

https://techblog.woowahan.com/2606/

 

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

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

techblog.woowahan.com

 

 

# 해결 방법

근본적인 원인은 서비스 -> 서비스를 호출하는 내부 서비스 부분의 @Transactional 이 먼저 예외를 감지하여 강제롤백 하도록 설정하기 때문이다.

 

새로운 트랜잭션을 열도록 설정

@Service
class UserTestService(
    private val userRepository: UserRepository,
) {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun createUser(point: Long) {
        if (point == 2L) {
            throw RuntimeException("2번 유저는 안돼!!")
        }
        val user = User(point = point.toInt())
        userRepository.save(user)
    }
}

 

@Transactional(propagation = Propagation.REQUIRES_NEW) 옵션을 주게 되면 외부 @Transactional이 있는 부분에서 호출하더라도 트랜잭션에 참여하지 않고 별도의 트랜잭션을 열어서 처리한다.

 

이렇게 되면 로직은 성공하지만 아예 별도의 트랜잭션을 열어버리기 때문에 전체 롤백이 되어야 하는 부분에서는 사용할 수 없다. (롤백이 먹지 않게 된다.)

 

 

CheckedException을 던지도록 변경

@Service
class UserTestService(
    private val userRepository: UserRepository,
) {
    @Transactional
    fun createUser(point: Long) {
        if (point == 2L) {
            throw Exception("2번 유저는 안돼!!")
        }
        val user = User(point = point.toInt())
        userRepository.save(user)
    }
}

 

공유한 포스팅을 읽어보면 알겠지만, 트랜잭션 어노테이션은 UncheckedException (RuntimeException을 상속받는 것들)만 롤백마크를 true로 해서 UncheckedException을 던지면 정상 동작한다.

 

 

반응형