[Spring Boot] try catch로 잡아도 롤백 되는 경우에 대해 알아보자
@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/
https://techblog.woowahan.com/2606/
# 해결 방법
근본적인 원인은 서비스 -> 서비스를 호출하는 내부 서비스 부분의 @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을 던지면 정상 동작한다.