이 글은 얼마 전 에러로그 하나에 대한 호기심과 의문으로 시작해서 스프링의 트랜잭션 내에서 예외가 어떻게 처리되는지를 이해하기 위해 삽질을 해본 경험을 토대로 쓰여졌습니다. 스프링의 트랜잭션의 세부적인 동작방식을 살펴보기 때문에 코드가 많고 설명이 조금 긴 편입니다. 상황재현과 테스트를 위해 작성한 코드는 github에 있습니다. 테스트에 사용한 버전은 SpringBoot 2.1.2, MySQL 5.7입니다.

때는 지난 12월의 어느날

beta서버에서 에러로그가 계속 쌓이고 있었습니다.

이야기의 시작

팀동료 한 분과 함께 살펴보니, 데이터가 잘 못 들어오면 던지는 예외의 문자열이었고 원인도 쉽게 찾을 수 있었습니다. 그리고 이 예외는 메서드를 호출한 메서드에서 잡아서(try/catch) 에러로그를 남긴 후에 필요한 데이터는 기본값으로 대신 전달되도록 작성되어 있었습니다. 스프링의 선언적 트랜잭션(@Transactional) 안에서 잡은 예외이기 때문 당연히 롤백 없이 커밋되어야 하죠.

그런데 웬걸! 그 바로 아래에 롤백예외가 보란듯이 로그에 연달아 나오고 있었습니다!

... ERROR ... [askScheduler-10] c.w.b.b.l.CreateDeliveryMessageListener : 
    org.springframework.transaction.TransactionSystemException: 
    Could not commit JPA transaction; nested exception is 
    javax.persistence.RollbackException: Transaction marked as rollbackOnly
at o.s.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:526)
...
...
Caused by: javax.persistence.RollbackException: Transaction marked as rollbackOnly

몇가지 추측과 가정을 해보았지만 정확한 결론을 내리기는 뭔가 아쉬워서 상황을 재현해보았습니다. 이왕에 재현해 보는 김에 이미 알고 있는 것과 그 외의 여러가지 케이스의 테스트를 만들어 봤습니다.

추측보단 테스트를


아래 코드는 의문의 상황을 간략하게 재현한 부분입니다.

@Service
@Transactional
public class OuterService {
    @Autowired 
    private TransactionalInnerService transactionalInnerService;

    public void callingTransactionalMethodThrowingRuntimeEx() {
        try {
            transactionalInnerService.innerMethodThrowingRuntimeEx();
        } catch (RuntimeException ex) {
            log.warn("OuterService caught exception at outer. ex:{}", ex.getMessage());
    }
}
@Service
@Transactional
public class TransactionalInnerService {
    @Autowired
    private final PostRepository postRepository;

    public void innerMethodThrowingRuntimeEx() {
        postRepository.save(new Post("[Transactional class] innerMethodThrowingRuntimeEx"));
        throw new RuntimeException("RuntimeException inside");
    }
}

상황은 이렇습니다. OuterService에 있는 메서드는 클래스의 트랜잭션 선언에 의해 트랜잭션메서드가 됩니다. 그리고 그 메서드는 또 다른 트랜잭션 클래스의 메서드를 호출합니다. 호출된 내부 메서드는 결과적으로 마지막에 RuntimeException을 던집니다.

의도한 건 이런거겠죠. “안에서 트랜잭션을 롤백시키게 하는 RuntimeException을 날렸지만 트랜잭션이 시작된 메서드에서 예외를 잡았으니 롤백 없지 커밋되어야지~”

이쯤에서 이미 이유를 아시는 분들은 편안한 마음으로 나머지 내용을 봐주세요. 이런 분들 말이죠.

이런 분들은 패스! <- 롤백관련 키워드로 슬랙채널 검색하니 영한님의 말씀

(제목에서부터 이미 감이 왔다 하시는 분들도 패스!) (저 빼고 다 패쓰? ㅠㅠ)


재현되는 롤백현상

실행환경과 유사하게 재현하기 위해 H2 대신 MySQL로, 실행은 Application Runner로 작성했습니다. 실행은 ./gradlew bootRun (github)

네, 아래와 같이 롤백이 일어나고 있습니다.

java.lang.IllegalStateException: Failed to execute ApplicationRunner
  ...
Caused by: org.springframework.transaction.UnexpectedRollbackException: 
    Transaction silently rolled back because it has been marked as rollback-only
at o.s.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:755)
at o.s.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:714)
at o.s.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:533)
at o.s.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:304)
at o.s.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:98)
at o.s.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
at o.s.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:688)
at xxx.OuterService$$EnhancerBySpringCGLIB$$670cc5f8.callingTransactionalMethodThrowingRuntimeEx(<generated>)

콜스택을 거꾸로 올라가면서 살펴보면,

  1. 프락시 메서드로 감싸진 callingTransactionalMethodThrowingRuntimeEx 메서드가 호출됨
  2. 트랜잭션을 가지고 invokeWithinTransaction 호출함
  3. commitTransactionAfterReturning 리턴된 후 커밋을 하려다가
  4. processCommit 에서 UnexpectedRollbackException 던져짐
  5. 예외 메시지를 보니 marked as rollback-only 라고 함


어떤 일이 일어났는지 로그를 좀 더 자세히 살펴보겠습니다. 관련이 있는 로그 위주로 간추렸습니다. (로그레벨 조정) (로그파일전문)

...TransactionalApplication         : Started TransactionalApplication in 4.858 seconds (JVM running for 5.323)
...JpaTransactionManager         <1>: Creating new transaction with name [OuterService.callingTransactionalMethodThrowingRuntimeEx]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
...JpaTransactionManager          | : Opened new EntityManager [SessionImpl(1202874820<open>)] for JPA transaction
...TransactionImpl                  : begin

...JpaTransactionManager         <2>: Participating in existing transaction
...TransactionInterceptor         | : Getting transaction for [TransactionalInnerService.innerMethodThrowingRuntimeEx]

...JpaTransactionManager         <3>: Participating in existing transaction
...TransactionInterceptor         | : Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]

...TransactionInterceptor        <4>: Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]

...TransactionInterceptor        <5>: Completing transaction for [TransactionalInnerService.innerMethodThrowingRuntimeEx] after exception: java.lang.RuntimeException: RuntimeException inside
...RuleBasedTransactionAttribute <6>: Applying rules to determine whether transaction should rollback on java.lang.RuntimeException: RuntimeException inside
...RuleBasedTransactionAttribute  | : Winning rollback rule is: null
...RuleBasedTransactionAttribute  | : No relevant rollback rule found: applying default rules
...JpaTransactionManager         <7>: Participating transaction failed - marking existing transaction as rollback-only
...JpaTransactionManager          | : Setting JPA transaction on EntityManager [SessionImpl(1202874820<open>)] rollback-only

...OuterService                  <8>: OuterService caught exception at outer. ex:RuntimeException inside
...TransactionInterceptor        <9>: Completing transaction for [OuterService.callingTransactionalMethodThrowingRuntimeEx]
...JpaTransactionManager        <10>: Initiating transaction commit
...JpaTransactionManager            : Committing JPA transaction on EntityManager [SessionImpl(1202874820<open>)]
...TransactionImpl                  : committing
...LocalTransactionCoordinatorImpl  : On commit, transaction was marked for roll-back only, rolling back
...JpaTransactionManager            : Closing JPA EntityManager [SessionImpl(1202874820<open>)] after transaction


  • <1> 최초로 OuterService의 메서드 이름으로 새로운 트랜잭션이 시작됩니다.
  • <2> TransactionalInnerService의 메서드로 진입하면서 이미 만들어진 트랜잭션에 참여합니다. @Trasactional의 기본 propagation 속성이 PROPAGATION_REQUIRED 이기 때문입니다.
  • <3> postRepository.save 메서드 또한 <2>와 마찬지로 이미 호출한 메서드의 트랜잭션에 참여합니다. 이 메서드가 트랜잭션 메서드인 이유는 JpaRepository를 상속하는 인터페이스의 기본 구현체는 SimpleJpaRepository이고 save 메서드에는 @Transactional이 걸려 있기 때문입니다. 위의 로그를 보면 실제로 구현체의 메서드로 트랜잭션에 참여하고 있는게 보이죠.
  • <4> save의 트랜잭션이 끝났습니다. 여기서 주목할 만한 사실은, 전파속성(propagation) 때문에 실제 트랜잭션이 재사용되더라도 트랜잭션 메서드의 반환시점마다 트랜잭션의 완료처리(completion)를 한다는 것입니다. 물론 커밋이나 롤백같은 최종완료처리는 최초 트랜잭션이 반환될 때 일어나겠지만요.
  • <5> 문제의 코드부분입니다. try/catch 없이 RuntimeExceptio이 던져지면서 트랜잭션 완료처리가 시작됩니다.
  • <6> RuntimeException 때문에 트랜잭션을 롤백할지 결정하는 규칙을 적용한다네요. 그런데, 딱히 지정된 규칙이 없어서 디폴트 규칙으로 가는군요.
  • <7> 디폴트라더니 급기야 롤백하기로 결정했나봅니다… 참여한 트랜잭션 실패를 선언하고 rollback-only 마킹을 합니다. 여기까지가 예외가 발생한 내부 트랜잭션의 완료처리였습니다.
  • <8> 안에서 발생한 예외를 최초 트랜잭션 메서드에서 잡았습니다.
  • <9> 최초 트랜잭션 메서드가 완료처리를 시작합니다
  • <10> 앞에서 예외는 잡았고 별 문제 없어 보이는 듯하여 최종커밋을 하려고합니다. 그런데 정작 커밋하려는 순간 roll-back only가 마킹되어 있다고 롤백을 해버립니다!

네 여기까지가 로그를 보면서 해석해본 롤백의 전말이었습니다. 트랜잭션에 참여중인 메서드에서 예외를 잡지 않고 위로 던져버리면 롤백이 되버리더라는 이야기입니다. 그 위에서 예외를 잡아봐야 소용없다는 얘기네요…

여기서 조금만 더 들어가보겠습니다. <5>부터 <7>까지의 내용입니다. 요약하면 이렇습니다.


“참여 중인 트랜잭션이 실패하면 기본정책이 전역롤백이다.”



사건의 현장 속으로

참여중이던 트랜잭션이 완료처리되는 <5> 지점의 정확한 좌표가 TransactionAspectSupport.java:543 입니다. 예외가 던져진 후에 완료처리되는 로그가 여기서 나오고 있었습니다.

protected void completeTransactionAfterThrowing(@Nullable TransactionInfo txInfo, Throwable ex) {
	if (logger.isTraceEnabled()) {
		logger.trace("Completing transaction for [" + txInfo.getJoinpointIdentification() + "] after exception: " + ex);
	}
	if (txInfo.transactionAttribute != null && txInfo.transactionAttribute.rollbackOn(ex)) {
		try {
			txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus());
		}

549 라인에 던져진 예외가 롤백하게 지정된 예외인지 확인을 합니다. txInfo.transactionAttribute.rollbackOn(ex) 여길 따라가보면 다들 들어보셨던 그 이야기가 나옵니다.

@Override
public boolean rollbackOn(Throwable ex) {
	return (ex instanceof RuntimeException || ex instanceof Error);
}

예제에서 던졌던 예외는 RuntimeException이기 때문에 여기선 롤백을 하게 됩니다.


txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus()); 따라가보면 AbstractPlatformTransactionManager.java:821까지 와서 아래의 코드와 조우합니다. <7> 지점의 로그가 보입니다. 이 메서드의 전체를 보시면 왜 처음에는 바로 롤백되지 않고 최상위 트랜잭션에서 롤백이 되는지도 알 수있습니다. 아래는 오늘의 주제와 직접 관련된 코드입니다.

// Participating in larger transaction
if (status.hasTransaction()) {
    if (status.isLocalRollbackOnly() || isGlobalRollbackOnParticipationFailure()) {
		if (status.isDebug()) {
			logger.debug("Participating transaction failed - marking existing transaction as rollback-only");
		}
		doSetRollbackOnly(status);
	}
}

여기서 isLocalRollbackOnly는 디폴트값이 false입니다. 이걸 true로 쓰는 경우는 아직까지 못 봤는데, 언급한 곳은 있었습니다.


정작 이글을 쓰게 만든 주범은 isGlobalRollbackOnParticipationFailure입니다. 디폴트값이 true입니다. 그래서 doSetRollbackOnly(status) 안에서 롤백이 마킹되었습니다. 그리고 결국에는 최초의 트랜잭션이 완료처리될 때 커밋하는 마지막 순간UnexpectedRollbackException을 던지게 합니다. 앞에서 봤던 그 로그네요.

else if (status.isNewTransaction()) {
	if (status.isDebug()) {
		logger.debug("Initiating transaction commit");
	}
	unexpectedRollback = status.isGlobalRollbackOnly();
	doCommit(status);
}

// Throw UnexpectedRollbackException if we have a global rollback-only
// marker but still didn't get a corresponding exception from commit.
if (unexpectedRollback) {
	throw new UnexpectedRollbackException(
	    	"Transaction silently rolled back because it has been marked as rollback-only");
}

사요나라..


범인의 최후 변론

그러면 왜 그런 결정이 내려진 것일까요. 누구에게나 사연은 있는거겠죠. 아래는 globalRollbackOnParticipationFailure 속성에 대해 주석으로 설명해 놓은 것을 번역해본 것입니다. 오역이 있을 수 있습니다. 원문을 그대로 보시길 추천합니다.

Set whether to globally mark an existing transaction as rollback-only after a participating transaction failed.

참여 중인 트랜잭션이 실패한 후에 기존 트랜잭션을 전역적으로 rollback-only로 마킹할 것인지 설정

Default is “true”: If a participating transaction (e.g. with PROPAGATION_REQUIRED or PROPAGATION_SUPPORTS encountering an existing transaction) fails, the transaction will be globally marked as rollback-only. The only possible outcome of such a transaction is a rollback: The transaction originator cannot make the transaction commit anymore.

디폴트는 true임. PROPAGATION_REQUIRED 또는 PROPAGATION_SUPPORTS 인 참여 중인 트랜잭션이 실패하면, 그 트랜잭션은 전역적으로 rollback-only로 마킹된다. 이런 트랜잭션은 결과적으로 롤백되고만다. 최초의 트랜잭션관리자도 그 트랜잭션을 커밋시킬 수 없게된다.

Switch this to “false” to let the transaction originator make the rollback decision. If a participating transaction fails with an exception, the caller can still decide to continue with a different path within the transaction. However, note that this will only work as long as all participating resources are capable of continuing towards a transaction commit even after a data access failure: This is generally not the case for a Hibernate Session, for example; neither is it for a sequence of JDBC insert_update_delete operations.

이 값을 false로 바꾸면 최초의 트랜잭션관리자가 롤백을 결정하게 한다. 참여 중인 트랜잭션이 예외로 실패하면 호출자는 여전히 트랜잭션 내의 다른 경로로 계속 진행할지 결정 할 수 있게된다. 그런데 주의할 점은, 이게 가능하려면 참여중인 모든 자원이 데이터접근이 안되더라도 커밋에 지장이 없다는 게 보장되어야한다는 것이다. 일반적으로 하이버네이트 세션의 경우는 그렇지 않다. JDBC insert_update_delete의 경우도 마찬가지다.

Note:This flag only applies to an explicit rollback attempt for a subtransaction, typically caused by an exception thrown by a data access operation (where TransactionInterceptor will trigger a PlatformTransactionManager.rollback() call according to a rollback rule). If the flag is off, the caller can handle the exception and decide on a rollback, independent of the rollback rules of the subtransaction. This flag does, however, not apply to explicit setRollbackOnly calls on a TransactionStatus, which will always cause an eventual global rollback (as it might not throw an exception after the rollback-only call).

주의: 이 설정은 서브트랜잭션에 대한 명시적 롤백의 경우에만 적용된다. 보통 데이터접근에 문제가 있을 때 던지는 예외 때문인데, 이 경우 TransactionInterceptor가 롤백규칙에 따라 (역주: RuntimeException 또는 Error) PlatformTransactionManager.rollback()을 호출한다. 이 설정이 꺼져있으면 호출한 쪽에서 서브트랜잭션의 롤백규칙에 상관 없이 예외를 처리하고 롤백여부를 결정할 수 있다. 그렇긴해도 트랜잭션상태객체에 명시적으로 setRollbackOnly 호출을 해버리면 소용이 없다. 그 호출이 결과적으로 트랜잭션이 통으로 롤백되게 하기 때문이다. rollback-only 호출하고 나서 예외를 안던질수도 있다(?).

The recommended solution for handling failure of a subtransaction is a “nested transaction”, where the global transaction can be rolled back to a savepoint taken at the beginning of the subtransaction. PROPAGATION_NESTED provides exactly those semantics; however, it will only work when nested transaction support is available. This is the case with DataSourceTransactionManager, but not with JtaTransactionManager.

이런 서브트랜잭션에서 실패를 처리할 때 권장하기로는, 전역트랜잭션이 서브트랜잭션이 시작할 때 잡아둔 세이브포인트까지 롤백하는 “중첩된 트랜잭션”을 사용하는 것이다. PROPAGATION_NESTED가 이 기능을 제공하는데, 중첩 트랜잭션이 지원되는 경우에만 동작하고, DataSourceTransactionManager는 되지만, JtaTransactionManager는 안된다.(역주: DataSourceTransactionManager를 직접 쓸 때만 된다. tx-propagation-nested)

See Also: setNestedTransactionAllowed(boolean), JtaTransactionManager

참고: setNestedTransactionAllowed(boolean), JtaTransactionManager

트랜잭션 동작방식 안에 이런 세세한 설정값들이 있다니, 열어보기 전엔 몰랐었네요.

예전에 이런 생각을 많이 했었습니다. “내부 트랜잭션이 실패해도 외부 트랜잭션에 영향을 주지 않도록 해볼 순 없을까?” 그 때 시도했던 것이 PROPAGATION_NESTED 이었는데, 시도할 때마다 번번히 의도대로 동작하지 않았었는데, 그 이유가 여기에 나와 있었습니다. DataSourceTransactionManager를 직접 쓸 때만 된다는 것. 실제로 시도해보면 중첩(nested) 트랜잭션 만들가다 아래와 같은 예외를 던져버립니다. 재현한 테스트도 만들어보았습니다. 일단 접는 걸로…

JpaDialect does not support savepoints - check your JPA provider's capabilities


오늘 이야기의 관점에서 정리해보자면, 참여 중인 일부트랜잭션이 실패할 때 전체 다 롤백하는 것은 의도한 것이다. 직접 제어하고 싶다면 해볼 순 있겠지만 잘 알고 써야하고 항상 가능한 것도 아니다. 이렇게 요약할 수 있겠네요.

이제야 너를 이해하겠어, 앞으론 잘 쓸께…


이렇게… 의문 하나로 시작된 일이 테스트를 만들고 디버깅하며 한 줄 한 줄 실행해보았네요. 이전보다 조금 더 제가 매일매일 사용하고 있는 스프링의 핵심기능에 좀 더 다가간 것 같아 보람이 있었습니다 :)



그런데 말입니다

이 글을 위해 테스트를 열심히 만들던 늦은 저녁에 한 분이 제가 하고 있는 걸 보시더니 이런 질문을 하십니다.

“그런데, 트랜잭션 안에서 RuntimeException은 왜 잡으려고 했나요?”

한참 코드 깊숙히 들어가서 코드로 머리가 꽉찬 상태인 저의 머리를 갑자기 텅비게 만드는 질문…
그 질문을 곱씹어보았습니다.
예외처리가 비지니스로직에 영향을 주는 게 맞나?
스프링의 트랜잭션에서는 왜 RuntimeException과 Error을 롤백대상으로 보았을까?
어떤 의도가 있는 것일까…

재일님 훈화말씀 <- 재일님의 훈화말씀


오늘도 BROS의 에러로그 박멸작업은 계속됩니다…FOREVER…

그런데 정작 CheckedException은 테스트를 안 만들었네…