응? 이게 왜 롤백되는거지?
이 글은 얼마 전 에러로그 하나에 대한 호기심과 의문으로 시작해서 스프링의 트랜잭션 내에서 예외가 어떻게 처리되는지를 이해하기 위해 삽질을 해본 경험을 토대로 쓰여졌습니다. 스프링의 트랜잭션의 세부적인 동작방식을 살펴보기 때문에 코드가 많고 설명이 조금 긴 편입니다. 상황재현과 테스트를 위해 작성한 코드는 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>)
콜스택을 거꾸로 올라가면서 살펴보면,
- 프락시 메서드로 감싸진 callingTransactionalMethodThrowingRuntimeEx 메서드가 호출됨
- 트랜잭션을 가지고 invokeWithinTransaction 호출함
- commitTransactionAfterReturning 리턴된 후 커밋을 하려다가
- processCommit 에서 UnexpectedRollbackException 던져짐
- 예외 메시지를 보니 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은 테스트를 안 만들었네…