Programming/Spring Boot

@Transactional 상태에서 Exception이 발생했을 때 Rollback 동작 과정

Jan92 2022. 12. 27. 00:45

@Transactional 어노테이션을 통해 트랜잭션을 선언하고 메서드 내부 로직을 짜던 중 '트랜잭션 안에서 발생하는 예외와 그 예외를 처리하는 방법에 따라 어떻게 롤백이 되는지'에 대한 개념이 명확하지 않아 정리해본 내용입니다.

 

결론을 먼저 말하자면 Unchecked Exception 발생 시에는 트랜잭션이 롤백되지만, Checked Exception 발생 시에는 트랜잭션이 롤백되지 않는데요. 

이어지는 내용을 통해 Unchecked Exception, Checked Exception에 대한 간단한 정리와 더불어, 각 상황에 따른 코드 예시와 동작 과정, 그리고 결과를 살펴보겠습니다.

 

 

Checked Exception, Unchecked Exception

CheckedException, Unchecked Exception

- Checked Exception

CompileException 이라고도 하며, Exception을 바로 상속받는데요.

컴파일 시점에서 예외에 대한 처리(try-catch 또는 throw)를 하지 않는다면 컴파일 에러가 발생합니다.

'CheckedException은 예외 발생 시 transaction rollback이 안된다는 특징'이 있습니다. 

(DataFormatException, FileNotFoundException 등)

 

- Unchecked Exception

RuntimeException을 상속받는 Exception입니다.

컴파일 시점이 아닌 실행 중에 발생할 수 있는 예외를 의미하며, CheckedException과 반대로 컴파일 시점에서 예외를 catch 하는지 여부를 확인하지 않습니다.

'UncheckedException은 예외 발생 시 transaction rollback이 된다는 특징'이 있습니다.

(NullPointerException, IndexOutOfBoundsException 등)

 

/*

Exception을 처리하는 방법에는 try-catch를 통해 메서드 내에서 직접 Exception을 처리하는 방법과 throws를 통해 코드가 있는 메서드를 호출하는 곳으로 예외 처리의 책임을 넘기는 방법이 있습니다.

*/

 

 

 

예외와 예외 처리에 따른 코드 예시(동작 과정 및 결과)

 

1. throw Unchecked Exception

@Transactional
public void insertMember(MemberDto.InsertMember insertMember) {
    Member member = Member.builder()
            .name(insertMember.getName())
            .age(insertMember.getAge())
            .build();
    //save
    memberRepository.save(member);

    //throw Unchecked Exception
    throw new RuntimeException("RuntimeException (Unchecked Exception)");
}

UnCheckedException인 RuntimeException을 throw 하는 예시입니다.

해당 메서드 작동 후 결과를 보면 insert 쿼리문은 동작했지만 member 데이터가 저장되지 않고 롤백된 것을 확인할 수 있는데요.

rollback이 되는 과정을 좀 더 자세하게 살펴보겠습니다.

 

 

if (txAttr == null || !(ptm instanceof CallbackPreferringPlatformTransactionManager)) {
	TransactionInfo txInfo = createTransactionIfNecessary(ptm, txAttr, joinpointIdentification);

	Object retVal;
	try {
		retVal = invocation.proceedWithInvocation();
	}
	catch (Throwable ex) {
		completeTransactionAfterThrowing(txInfo, ex);
		throw ex;
	}
	finally {
		cleanupTransactionInfo(txInfo);
	}

	...

	commitTransactionAfterReturning(txInfo);
	return retVal;
}

해당 코드는 TransactionAspectSupportinvokeWhitinTransaction() 메서드의 일부인데요.

try문 안의 invocation.proceedWithInvocation() 메서드가 동작하며 MemberService에서 throw 한 RuntimeException이 catch 부분에서 잡히게 되고 completeTransactionAfterThrowing() 메서드가 실행되게 됩니다.

 

 

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;
	}
}

(completeTransactionAfterThrowing() 메서드의 일부분)

 

여기에서는 맨 위에 있는 rollbackOn() 메서드에 주목해볼 수 있는데요. 해당 rollbackOn()RuleBasedTransactionArrtibute class에 구현된 메서드이며, 메서드 내부에서 super.rollbackOn()으로 한번 더 작동하는데 이때 상위 클래스가 DefaultTransactionAttribute class입니다.

 

 

DefaultTransactionAttribute rollbackOn()

rollbackOn() 메서드에서는 다음과 같이 UncheckedException만 rollback 시킨다는 설명을 볼 수 있는데요.

 

/*

The default behavior is as with EJB: rollback on unchecked exception (RuntimeException), assuming an unexpected outcome outside any business rules.

*/

 

따라서 completeTransactionAfterThrowing() 메서드에서 if 조건문에 해당되기 때문에 txInfo.getTransactionManager().rollback() 부분에 의해 롤백이 실행되는 것입니다.

 

 

 

2. try-catch Unchecked Exception

@Transactional
public void insertMember(MemberDto.InsertMember insertMember) {
    Member member = Member.builder()
            .name(insertMember.getName())
            .age(insertMember.getAge())
            .build();
    //save
    memberRepository.save(member);

    //try-catch Unchecked Exception
    try {
        throw new RuntimeException("RuntimeException (Unchecked Exception)");
    } catch (RuntimeException e) {
        e.printStackTrace();
    }
}

두 번째는 UncheckdException가 try-catch를 통해 처리되는 경우인데요.

실행 결과를 먼저 말씀드리면 롤백이 실행되지 않습니다. 이유는 아래에서 살펴보겠습니다.

 

 

	try {
		retVal = invocation.proceedWithInvocation();
	}
	catch (Throwable ex) {
		completeTransactionAfterThrowing(txInfo, ex);
		throw ex;
	}
	finally {
		cleanupTransactionInfo(txInfo);
	}
    
        ...

	commitTransactionAfterReturning(txInfo);
	return retVal;

(위에서 본 TransactionAspectSupport의 invokeWithinTransaction() 메서드의 일부)

 

이 경우에는 insertMember() 메서드 내부에서 발생한 RuntimeException을 try-catch를 통해 메서드 내부에서 처리하였기 때문에 invocation.proceedWithInvocation() 실행이 되어도 catch에서 잡을 Exception이 없게 됩니다.

따라서 catch 부분은 통과하고 finally 부분 실행 이후 commitTransactionAfterReturning() 메서드를 통해 커밋이 실행됩니다.

 

 

 

3. throw Checked Exception

@Transactional
public void insertMember(MemberDto.InsertMember insertMember) throws Exception {
    ...
    
    //save
    memberRepository.save(member);

    //throw Checked Exception
    throw new Exception("Exception (Checked Exception)");
}

세 번째는 Checked Exception을 throw 하는 경우입니다.

해당 경우 역시 예외가 발생해도 롤백이 실행되지 않는데요. 하지만 이때 rollback이 실행되지 않는 이유는 두 번째 경우와는 다릅니다.

 

 

	try {
		retVal = invocation.proceedWithInvocation();
	}
	catch (Throwable ex) {
		completeTransactionAfterThrowing(txInfo, ex);
		throw ex;
	}

(TransactionAspectSupport의 invokeWithinTransaction() 메서드의 일부)

 

이 경우 해당 코드에서 catch(Throwable ex)에 MemberService에서 throw 한 Exception이 잡히게 되고, completeTransactionAfterThrowing() 메서드가 실행되게 되는데요.

 

 

    if (tx.Info.transactionAttribute != null && txInfo.transactionAttribute.rollbackOn(ex)) {
        try {
            txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus());
        }
        ...
    }
    else {
        try {
            txInfo.getTransactionManager().commit(txInfo.getTransactionStatus());
        }
    }

(completeTransactionAfterThrowing() 메서드의 일부)

 

다시 맨 위에 줄에 rollbackOn() 메서드에 주목해 보면, 해당 메서드는 아까 첫 번째 경우에서 설명했던 것처럼 UncheckedException만 롤백이 가능하다는 결과를 반환합니다.

따라서 if 부분이 아닌 else 부분에서 동작하게 되며, txInfo.getTransactionManager().commit() 메서드에 의해 커밋이 실행되는 것입니다.

 

/*

CheckedException을 try-catch로 잡는 경우는 두 번째 경우와 똑같이 동작하기 때문에 설명을 생략하겠습니다.

*/

 

 

 

4. throw Checked Exception (rollbackFor = Exception.class)

@Transactional(rollbackFor = Exception.class)
public void insertMember(MemberDto.InsertMember insertMember) throws Exception {
    ...
    
    //save
    memberRepository.save(member);

    //throw Checked Exception
    throw new Exception("Exception (Checked Exception)");
}

그러면 Checked Exception 발생 시 트랜잭션을 rollback 하는 방법은 없을까요?

방법은 @Transactional 어노테이션의 rollbackFor 옵션으로 해당 예외 클래스를 지정하면 가능합니다.

 

 

    @Override
    public boolean rollbackOn(Throwable ex) {
        ...

        if (this.rollbackRules != null) {
            for (RollbackRuleAttribute rule : this.rollbackRules) {
                int depth = rule.getDepth(ex);
                if (depth >= 0 && depth < deepest) {
                    deepest = depth;
                    winner = rule;
                }
            }
        }
        
        if (winner == null) {
            return super.rollbackOn(ex);
        }
    }

(RuleBasedTransactionAttribute class의 rollbackOn() 메서드 일부분)

 

completeTransactionAfterThrowing() 메서드에서 실행되는 rollbackOn() 메서드가 처음 실행되는 곳이 바로 RuleBasedTransactionAttribute 클래스인데요.

 

@Transactional 어노테이션에 rollbackFor 옵션을 통해 클래스를 지정하게 되면 위 클래스의 rollbackRules에 해당 예외 클래스가 추가되게 되고 for 문이 실행되는 과정에서 winner 값이 rule으로 변경되어 DefaultTransactionAttribute 클래스의 rollbackOn() 메서드를 실행시키는 super.rollbackOn() 부분이 동작하지 않게 됩니다.

 

 

if (txInfo.transactionAttribute != null && txInfo.transactionAttribute.rollbackOn(ex)) {
    try {
        txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus());
    }
}

따라서 rollbackOn() 메서드는 결과값 true를 반환하고 try 구문의 rollback() 메서드가 실행되게 되는 것입니다.

 

 

//여러 개의 예외를 지정하는 방법
@Transactional(rollbackFor = {RuntimeException.class, Exception.class})

//특정 예외가 발생하면 롤백이 되지 않도록 하는 방법
@Transactional(noRollbackFor = {RuntimeException.class})

추가로 다음과 같이 여러 개의 예외를 처리할 수도 있으며, 특정 예외가 발생하면 롤백이 되지 않도록 지정할 수도 있습니다.

 

/*

위 예시들과 같은 insert 문에서는 데이터는 저장되지 않고 롤백되지만 auto increment로 설정된 table의 id 값은 롤백되지 않고 증가되는 이슈가 있습니다.

*/

 

 

 

< 함께 보면 좋은 포스팅 >

https://wildeveloperetrain.tistory.com/107

 

< 참고 자료 >

https://sup2is.github.io/2021/03/04/java-exceptions-and-spring-transactional.html