[Spring] Transactional에서 Runtime Exception 조심하세요~

kshired·2023년 2월 10일
3

Spring

목록 보기
11/11
post-thumbnail

시작

Spring에선 AOP( Aspect Oriented Programming )를 이용하여, Transaction의 일관성 있는 처리를 위한 @Transactional 이라는 어노테이션을 제공한다.

@Transactional 은 여러가지 강력한 기능을 제공하지만, 그에 따라 자세히 알고 쓰지 않으면 겪을 수 있는 문제들이 있다.

회사에서 이번주에 따끈 따끈하게 문제를 겪었던 @Transactional의 scope에서 Runtime Exception이 터졌을 때 발생할 수 있는 문제를 알아보자.

문제 발생!

일하는 도중 갑자기 새 기능을 배포한 뒤, 슬랙 멘션을 받았다.

느낌이 싸해서 어 뭐지..? 하고 봤더니

xx님 갑자기 이 기능이 안되는데.. 혹시 이유를 아시나요?

역시나 문제가 터졌다!

그래도 우리 팀의 비개발자분들은 traceId의 존재를 아셔서, 문제가 발생하면 traceId도 함께 주신다. 최고!!

바로 키바나를 켜서 traceId를 검색해보니, 처음보는 Exception이 터져있었다.

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

처음 봤지만.. 검색은 우리에게 항상 답을 주기에 바로 검색을했다.

세상에, 배민에서 까지 써놨다. 이제 그럼 문제 해결은 식은 죽 먹기다. ( 아니였다 )

문제 분석

한 줄 요약

한 줄 요약하고 들어가면..

@Transactional 은 Runtime Exception이 발생하면, 롤백 마크를 하여 트랜잭션을 재사용하지 못하도록 한다.

여기서 이해하셨나요? 그럼 글을 끄고 나가셔도 됩니다!

저처럼 이해 못한 분은, 아래 실제 사례로 이해해보기를 살펴보세요!

실제 사례로 이해해보기

솔직히 처음에 무슨 말인지 이해가 안됐다. 이럴 땐, 예제 코드를 보면서 이해하는게 최고니까 한 번 같이 살펴보자.

@Component
class ClassA(
	private val classB: ClassB,
    private val repositoryA: RepositoryA
) {
    @Transactional
    fun someSaveFunction() : Long {
        val b = runCatching {
            classB.do()
        }.getOrNull()
        
        
       	val a = repositoryA.save(A(name = b?.name))
		return a.id    
    }
}

@Component
class ClassB(
    private val repositoryB: RepositoryB
) {
    fun do() : B {
        return repositoryB.find() ?: throw ApplicationException("Cannot find b")
    }
}

위 코드에서 someSaveFunction을 동작시키면 a는 정상적으로 save될까?

정답은? "아니다"

classB.do() 를 실행했을 때, ApplicationException 즉 Unchecked Exception 이 throw 되었기 때문에 runCatching을 통해 catch를 했지만 rollback이 되버린다.

이것이, Transaction silently rolled back because it has been marked as rollback-only 라는 에러를 발생시킨 원인이다.

이것을 자세히 알고 싶다면, 위에서 언급한 배민 블로그 을 자세히 보자.

그럼 어떻게 해결해야 할까?

해결 방법 1

Class B 의 Exception을 ApplicationException이 아닌 일반 Exception으로 대체한다.

fun do() : B {
    return repositoryB.find() ?: throw Exception("Cannot find b")
}

@Transactional의 rollback은 Unchecked Exception을 rollback 하기 때문에 문제가 쉽게 해결된다.

하지만 원래 Exception이 터지고, rollback이 되어야하는 메소드라면 이 방법도 다른 방법을 찾아야한다.

해결 방법 2

@Transactional 의 옵션으로 noRollbackFor 옵션 추가. 이 옵션에 Throwable의 서브 클래스를 지정해놓으면, 해당 클래스의 Exception이 발생하여도 Rollback하지 않는다.

@Component
class ClassA(
	private val classB: ClassB,
    private val repositoryA: RepositoryA
) {
    @Transactional(noRollbackFor = RuntimeException::class.java)
    fun someSaveFunction() : Long {
        val b = runCatching {
            classB.do()
        }.getOrNull()
        
        
       	val a = repositoryA.save(A(name = b?.name))
		return a.id    
    }
}

하지만, 매번 옵션을 지정해주어야하고 이러한 예외 케이스를 두어야하기 때문에 별로 좋지 않은 것 같다.

해결 방법 3

이 방법은 위 예시 케이스와 유사한 상황에만 해당된다.

classB.do() 가 실패했을 때 어차피 null 값을 쓸거라면, classB.doNullable() 같은 함수를 만들어서 사용하자.

fun do() : B? {
    return repositoryB.findOrNull()
}

즉, 굳이 Exception을 터뜨려야하는 상황이 아니라면 터뜨릴 필요가 없는 방식으로 대체하자.

추가로 하고 싶은 이야기

Exception 지점에서 try-catch를 했다면, 꼭 log를 남기자.

log를 남기지 않으면 이런 문제가 발생했을 때, 대처하는 시간이 생각보다 오래걸린다.
실제로 이번 에러를 마주했을 때, 로그를 남기지 않아 꽤나 대처가 힘들었다.

Bad

fun doSomething() {
    runCatching {
        classB.do()
    }.getOrNull()
}

Good

fun doSomething() {
    runCatching {
        classB.do()
    }.onFailure {
        log.error("에러가 발생했습니다.", it)
    }.getOrNull()
}

정리

  • @Transactional 의 scope 안에서 Runtime Exception 이 throw 되었을 때, 추가적인 어노테이션 설정을 하지않으면, try-catch는 의미가 없다.
  • 굳이 Exception을 터뜨려야하는 상황이 아니라면 터뜨릴 필요가 없는 방식으로 대체하자.
  • try-catch로 에러를 잡아서 진행시키고자한다면, 로그는 반드시 남기자.
    • 진짜 안남겨놓으면 고생한다.

Reference

profile
글 쓰는 개발자

0개의 댓글