[Spring] @Transactional 과 UnexpectedRollbackException
org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
아래 코드와 같이 예외가 발생했더라도 트랜잭션이 시작된 메서드에서 예외를 잡았으니 롤백 없이 커밋되고 정상적으로 값을 리턴할 것이라고 생각하였습니다.
@Service
public class AccountService {
@Resource
private HistoryService historyService;
@Transactional
public UserDetails signUp(UserVO param) {
// ...
try {
historyService.addHistories(param);
} catch (RuntimeException e) {
log.error("Error when saving user history", e);
}
// ...
return userDetails;
}
}
@Service
public class HistoryService {
@Resource
private HistoryRepository historyRepository;
@Transactional
public void addHistories(UserVO param) {
historyRepository.save(param);
// ...
throw new RuntimeException(); // 예외 발생
}
}
하지만 예상과는 다르게 다른 로직도 정상적으로 수행됐음에도 불구하고 마지막에 UnexpectedRollbackException이 발생하면서 모두 롤백되었습니다.
org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:752)
at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:711)
at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:633)
at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:386)
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:118)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:749)
at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:691)
스프링 문서를 찾아보니 @Transactional의 propagation 기본 속성은 REQUIRED인데, REQUIRED이면 설정이 적용된 각 메서드에 대해 논리적 트랜잭션 범위가 생성됩니다. 이러한 각 논리적 트랜잭션 범위는 롤백 전용 상태를 개별적으로 결정할 수 있으며 외부 트랜잭션 범위는 내부 트랜잭션 범위와 논리적으로 독립적입니다. 이러한 모든 범위는 동일한 물리적 트랜잭션에 맵핑됩니다. 따라서 내부 트랜잭션 범위에 설정된 rollback-only 마커는 외부 트랜잭션이 실제로 커밋할 가능성에 영향을 미칩니다.
그러나 내부 트랜잭션 범위가 rollback-only 마커를 설정하는 경우 외부 트랜잭션은 롤백 자체를 결정하지 않았으므로 롤백(내부 트랜잭션 범위에 의해 자동으로 트리거됨)은 예상치 못한 것입니다. 해당 지점에서 UnexpectedRollbackException이 발생합니다. 이는 트랜잭션 호출자가 커밋이 실제로 수행되지 않았는데 커밋이 수행되었자고 가정하지 않도록 하기 위한 예상 동작입니다. 따라서 내부 트랜잭션(외부 호출 호출자가 인식하지 못하는)이 자동으로 트랜잭션을 rollback-only로 표시하는 경우 외부 호출자는 여전히 커밋을 호출합니다. 외부 호출자는 대신 예기치 않은 롤백이 수행되었음을 명확하게 나타내기 위해 UnexpectedRollbackException을 발생한 것입니다.
위에 내용은 문서 내용을 번역해서 적은 것이고, 처음 코드 기반으로 정리하자면 다음과 같습니다.
1. @Transactional 어노테이션이 붙은 AccountService.signUp 메서드 이름으로 새로운 트랜잭션이 실행됩니다.
2. HistoryService.addHistories 메서드로 진입하면서 이미 만들어진 트랜잭션에 참여합니다.
- @Transactional 의 propagation 기본 속성이 REQUIRED 이기 때문
2-1. historyrepository.save 메서드 또한 2와 마찬가지로 이미 호출한 메서드의 트랜잭션에 참여합니다.
- 이 메서드가 트랜잭션 메서드인 이유는 JpaRepository를 상속하는 인터페이스의 기본 구현체는 SimpleJpaRepository이고 save 메서드에는 @Transactional이 걸려있기 때문
2-2. save의 트랜잭션이 종료됩니다.
- 전파 속성(propagation) 때문에 실제 트랜잭션이 재사용되더라도 트랜잭션 메서드의 반환시점마다 트랜잭션의 완료처리를 함. 다만 커밋이나 롤백같은 최종완료처리는 최초 트랜잭션이 반환될때 일어남.
2-3. 예외발생으로 설정된 propagation(기본 규칙 REQUIRED) 규칙으로 트랜잭션을 롤백하기로 결정됩니다. 해당 트랜잭션 실패를 선언하고 rollback-only 마킹을 합니다.
3. 내부 메서드에서 발생한 예외를 최초 트랜잭션 메서드에서 잡습니다.
4. 최초 트랜잭션 메서드가 완료처리를 시작합니다. 앞에서 예외를 잡았고 별다른 이슈가 없는듯하여 최종 커밋을 진행하려는 순간 rollback-only가 마킹되어 있는 것을 확인하고 롤백합니다.
- 참여 중인 트랜잭션이 실패하면 기본정책이 전역롤백이다.
번외)
참고로 @Transactional의 rollbackFor 기본 속성은 RuntimeException으로 Exception이 발생하면 트랜잭션 rollback이 일어나지 않습니다.
[Reference]