프로그래밍/스프링

@Transactional 그리고 Transaction marked as rollback-only 에러

말랑공룡 2023. 9. 21. 13:44
Transaction rolled back because it has been marked as rollback-only

 

최근에 실무에서 에러 로그에 지속적으로 이 에러가 발생하는 것을 발견했다.

처리 상 문제는 없었으나 어쨌든 에러가 발생하고 있는 상황이니까 더 이상 발생하지 않게 처치를 해야했다.

 

그 당시 프로젝트는 서비스 계층 클래스에 전부 @Transactional 설정을 해버리는 aop가 되어 있었다.

그렇다보니 서비스에 있는 메소드들이 다 트랜잭션에 걸려있는 상태였다.

 

이런 구조 위에서 개별적으로 붙이는 @Transactional이 아닌 전체에 적용되어 버린 어노테이션 상황과 그 안에서의 try catch, 그리고 또 다시 throw 같은 복잡한 로직들 사이에서 저 에러가 발생하였다.

그 이슈를 계기로 어떤 상황에서 저 에러가 발생하는지 알아보고자 한다.

 

예시 코드


@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class SampleService {

    private final SampleServiceV2 sampleServiceV2;

    public void outerMethod() {
        try {
            sampleServiceV2.innerMethod();
        } catch (Exception e) {
            log.error("outerMethod Error", e);
        }

    }

}
@Service
@Slf4j
@Transactional
@RequiredArgsConstructor
public class SampleServiceV2 {
    private final SampleMapper sampleMapper;

    public void innerMethod(){
        sampleMapper.insert(Item.builder().itemName("itemName").build());
        // 에러발생
        throw new RuntimeException();
    }
}

 

  1. 각기 다른 서비스가 있고 한 서비스에서 다른 한 서비스의 메소드를 호출한다.
  2. 두 메소드는 다 @Transactional 적용대상인 상태이다.
  3. 내부 메소드에서 에러가 발생한다.
  4. 외부 메소드에서 그 에러를 catch로 잡는다.

 

에러분석


 

에러 로그

 

at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) 

 

@Transactional를 단 메소드가 호출될 때, TransactionInterceptor가 동작하게 된다.

 

 

TransactionInterceptor.invoke

 

return문에 있는 것처럼 TransactionAspectSupport.invokeWithinTransaction가 호출된다.

 

 

TransactionAspectSupport.invokeWithinTransaction

 

  1. invocation.proceedWithInvocation()에서 대상 메소드 실행
  2. innerMethod의 exception발생하여 catch, 다시 throw
  3. 해당 트랜잭션에 rollback-only 마크
  4. outerMethod @Transactional aop로 인해 invocation.proceedWithInvocation() 호출
  5. 에러 발생 없이 끝까지 흘러서 commitTransactionAfterReturning 호출

 

TransactionAspectSupport.commitTransactionAfterReturning

 

그럼 이 트랜잭션은 commit을 호출하게 된다.

 

AbstractPlatformTransactionManager.commit

 

shouldCommitOnGlobalRollbackOnly는 전역 롤백 상황에서 트랜잭션을 커밋할 지 여부를 결정하는데 사용된다.
만약 true를 반환한다면 트랜잭션은 커밋이 되고 그 밖에는 롤백이 된다.
즉, 전역 롤백 상황에서 트랜잭션을 커밋하려면 shouldCommitOnGlobalRollbackOnly를 true로 설정하면 되고default는 false이다. 이 값은 @Transactional의 rollbackFor 속성이나 noRollbackFor 속성을 사용하여 제어한다.

 

 

아무튼 어떤 제어도 없기에 여기에서 이 값은 default인 false에 부정연산자가 적용되어 true, 그리고 isGlobalRollbackOnly는 아까 exception으로 인해 connection에 rollback-only가 되어있으니 true가 되어 true && true니까 안의 로직을 타게 된다.

결국 파라미터 unexpected를 true로 하여 processRollback을 호출하게 된다.

 

 

AbstractPlatformTransactionManager.processRollback

 

그리고 콘솔에서 보았던 그 에러를 던지는 부분을 확인할 수 있다.

 

그래서 왜 났다고?


 

outerMethod와 innerMethod는 각각 @Transactional aop를 시작한다.

하지만 같은 Transaction이다.

왜냐하면 propagation default가 REQUIRED인데, 이 옵션은 부모 트랜잭션이 존재한다면 부모 트랜잭션에 합류, 그렇지 않다면 새로운 트랜잭션을 만든다. outerMethod에서 시작한 부모 트랜잭션이 존재하기 때문에 그것에 합류한 것이 된다.

 

이제 innerMethod에서 exception이 던져져서 트랜잭션에 rollback 마크가 찍혀있는 채로

outerMethod에서 무사 commit하려고 하니, 응 롤백해야만 해~ 하면서 에러가 찍힌 것이다.

 

보장되어야 하는 트랜잭션의 원칙들 중, 한 트랜잭션 내의 처리들은 오류로 인해 전체가 롤백되거나 정상적으로 전체가 커밋되거나 둘 중 하나여야만 한다는 원자성을 위배하지 않기 위해 이런 에러가 난 것이다.

 

트랜잭션 ACID 참고글

2023.09.13 - [프로그래밍/정리] - 스프링 DB 1편 정리

 

해결


 

일단 지금 샘플코드 상으로는 각 서비스들이 어떤 역할을 하는지 정해져 있지 않기 때문에 해결방법을 딱 하나라고 정할 수가 없을 것 같다. 단순하게 생각해봤을 때는 innerMethod의 propagation을 REQUIRES_NEW로 해서 innerMethod를 새로운 처리, 즉 새로운 트랜잭션을 시작하고 끝내게 하는 것이 가장 간단하게 해결할 수 있는 방법이라고 생각한다.

 

이렇게 저 에러로그가 찍히는 과정도 알아보고 @Transactional이 어떻게 동작하는지를 대략적으로 살펴봤다.

그래도 아직 완벽하게 분석이 끝났다고는 할 수 없다.

일단 이 포스팅은 이렇게 마무리하고 더 연구를 해보면서 기록으로 남겨보려고 한다.