도대체 왜 롤백이 되는거야? (2편 - 롤백이 되는 과정)

enoch·2022년 12월 11일
3
post-thumbnail

서론

이전에 트랜잭션 실험을 통해 아래 두가지 에러 메세지를 얻게 되었다.
1. Transaction silently rolled back because it has been marked as rollback-only
2. JpaDialect does not support savepoints - check your JPA provider's capabilities
하나씩 살펴보기로 했고, 첫번째 에러 메세지 전문으로 검색하니 바로 검색되는 우아한형제들 기술 블로그의 글(응? 이게 왜 롤백되는거지? by.구인본)을 보게 됐다.

본론

1. Rollback이 된 이유

    @Override
    @Transactional(noRollbackFor = ClassRoomException.class)
    public void rollBackTest(Long checkoutId) {
        CheckoutEntity checkoutEntity = checkoutEntityService.getCheckoutEntity(checkoutId);
        checkoutEntity.setMixpanelDistinctId("UPDATED");
        try {
            classRoomEnrolmentService.rollbackTest();
        } catch (ClassRoomException e){
            log.error("CATCH EXCEPTION");
            throw e;
        }
        log.info("END");
    }
@Override
    @Transactional
    public void rollbackTest() throws ClassRoomException{
        throw new ClassRoomException(ErrorMessage.CLASS_ALREADY_ENROLMENT, CriticalLevel.NON_CRITICAL);
    }

각각을 순서대로 호출 메소드, 피호출 메소드이라고 하고, 구인본님의 글을 여기에 적용시켜 본다.
1. 최초로 호출 메소드 에서 트랜잭션이 시작된다.
2. 피호출 메소드가 시작되어 있는 트랜잭션에 참여한다.
3. 피호출 메소드가 ClassRoomException을 던지며 참여한 트랜잭션 실패를 선언하고 rollback-only 마킹을 한다.
4. 피호출 메소드에서 트랜잭션이 완료처리된다.
5. 호출 메소드에서 try/catch로 예외를 잡고 트랜잭션을 마무리 한다.
6. 트랜잭션이 마무리 되며 rollback-only 마크를 보고 롤백을 시켜버린다.

스택 트레이스를 따라가보자

protected void completeTransactionAfterThrowing(@Nullable TransactionInfo txInfo, Throwable ex) {
    if (txInfo != null && txInfo.getTransactionStatus() != null) {
        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());
            }
            catch (TransactionSystemException ex2) {
                logger.error("Application exception overridden by rollback exception", ex);
                ex2.initApplicationException(ex);
                throw ex2;
            }
            catch (RuntimeException | Error ex2) {
                logger.error("Application exception overridden by rollback exception", ex);
                throw ex2;
            }
        }

txInfo.transactionAttribute.rollbackOn(ex)를 따라가보면

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

RuntimeException들은 rollbackOntrue로 반환되면서, txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus()); 가 동작하게 되는 것이었다.

해당 메소드를 따라 들어가보면

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

위 로직에서 RollbackOnly가 마킹된다.

if (unexpectedRollback) {
    throw new UnexpectedRollbackException(
            "Transaction rolled back because it has been marked as rollback-only");
}

트랜잭션이 완료 처리되는 과정을 따라가보면

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

if (unexpectedRollback) {
    throw new UnexpectedRollbackException(
            "Transaction silently rolled back because it has been marked as rollback-only");
}

isGlobalRollbackOnly()true가 되고, 해당 트랜잭션은 UnexpectedRollbackException을 던지게 되는것이다.

그러면 가장 처음 보았던 Transaction silently rolled back because it has been marked as rollback-only 에러 메세지를 만날 수 있다.

응? 이게 왜 롤백되는거지? by.구인본 다시한번 이 글을 참고하여 isGlobalRollbackOnParticipationFailure() 가 왜 true를 반환하는지 알 수 있었다.

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

2. Propagation.NESTED는 왜?

그렇다면 중첩 트랜잭션을 사용하기 위해 Propagation.NESTED 를 사용했을 때 발생했던
JpaDialect does not support savepoints - check your JPA provider's capabilities 에러는 무엇이었을까?

중첩 트랜잭션은 JDBC 3.0 이후 버전의 savepoint기능을 사용하는데, JPA를 사용하는 경우, 변경감지를 통해서 업데이트문을 최대한 지연해서 발행하는 방식을 사용하기 때문에 중첩된 트랜잭션 경계를 설정할 수 없어 지원하지 않는다고 한다.

JPA를 사용하면 NESTED는 사용하지 않는 것으로 해야겠다.

참고 자료

https://techblog.woowahan.com/2606/
https://keencho.github.io/posts/transaction-rollback/
https://reiphiel.tistory.com/entry/understanding-of-spring-transaction-management-practice

profile
🍣 초밥을 사랑하는 백엔드 개발자 입니다 :)

0개의 댓글