java/spring

[Spring] @TransactionalEventListener에서 예외가 발생하지 않는 이슈

danuri 2023. 6. 23. 22:07

문제

@TransactionalEventListener에서 예외가 발생하지 않는 이슈

이게 무슨말인가 하면, @TransactionalEventListener에서 예외 발생 시, 예외를 찍어주지 않는다.

처음엔 예외 자체가 발생하지 않았다고 생각했는데, 로그를 debug로 찍어보면 잘 나온다.

즉, 예외는 발생했지만 해당 예외가 error가 아닌 debug로 찍힌다는 것.

 

원인

콜스택을 분석해봤다.

 

@Transactional이 붙은 메서드를 본 메서드,

@TransactionalEventListener를 이벤트 메서드라고 했을 때,

 

이벤트 메서드는 본 메서드의 트랜잭션이 커밋된 이후, 실행하는 메서드다.

이 때 이벤트 메서드는 실행 후 종료 직전 TransactionSynchronization.afterCompletion() 메서드를 실행시킨다.

 

TransactionSynchronization은 트랜잭션 커밋 전후의 작업을 실행시키기 위한 인터페이스다.

beforCommit, beforeCompletion, afterCommit, afterCompletion 네 가지 메서드를 갖고 있다.

 

해당 메서드들은 구현체에 따라 처리 방법이 다른데, @TransactionalEventListener는 TransactionalApplicationListenerSynchronization 구현체를 사용한다.

 

즉, 이벤트 메서드가 종료 직전 실행하는 TransactionSynchronization.afterCompletion() 메서드는 TransactionalApplicationListenerSynchronization.afterCompletion()를 호출하는 것이다.

 

코드는 다음과 같다.

@Override
public void afterCompletion(int status) {
    TransactionPhase phase = this.listener.getTransactionPhase();
    if (phase == TransactionPhase.AFTER_COMMIT && status == STATUS_COMMITTED) {
        processEventWithCallbacks();
    }
    else if (phase == TransactionPhase.AFTER_ROLLBACK && status == STATUS_ROLLED_BACK) {
        processEventWithCallbacks();
    }
    else if (phase == TransactionPhase.AFTER_COMPLETION) {
        processEventWithCallbacks();
    }
}

여러 if 조건들이 있지만 어떤 조건이든 결국 processEventWithCallbacks() 메서드를 호출하는데,
해당 메서드는 이벤트 로직을 실행하는 메서드다.

즉, afterCompletion()에서 이벤트 로직을 실행한다는 것이다 -> 예외가 발생해도 여기서 발생할 것이다.

 

그렇다면 afterCompletion을 호출하는 곳을 살펴보자.

바로 TransactionSynchronizationUtils.invokeAfterCompletion()이다.

public static void invokeAfterCompletion(@Nullable List<TransactionSynchronization> synchronizations,
        int completionStatus) {

    if (synchronizations != null) {
        for (TransactionSynchronization synchronization : synchronizations) {
            try {
                synchronization.afterCompletion(completionStatus);
            }
            catch (Throwable ex) {
                logger.debug("TransactionSynchronization.afterCompletion threw exception", ex);
            }
        }
    }
}

synchronization.afterCompletion() 부분이 방금 살펴봤던 이벤트를 로직을 실행하는 부분이다.

주목해야 할 점은 catch 문이다.

catch 문에서 예외를 잡아서 debug 로그로 찍어주고 있었다 -> 찾았다..!

 

즉, 이벤트 로직에서 예외가 터져도 저기서 예외를 잡아서 debug 로그로 찍어주니,

일반 info 로그 레벨에서는 알 길이 없었던 것이다.

 

왜 이렇게 동작할까?

 

여러 자료, 커뮤니티를 찾아봐도 같은 이슈에 대해 고민하는 사람들이 많았다. 그리고 명확한 해답도 없는 것 같다.

일단 TransactionSynchronization의 Java Doc은 다음과 같이 기재되어있다.

 

1. beforeCommit, afterCommit은 예외가 터지면 전파된다.

Throws: RuntimeException – in case of errors; will be propagated to the caller (note: do not throw 
TransactionException subclasses here!)

2. beforeCompletion, afterCompletion은 예외가 터지면 전파되지 않는다.

Throws: RuntimeException – in case of errors; will be logged but not propagated (note: do not throw 
TransactionException subclasses here!)

 

그래서 이유가 뭘까?

여러 자료를 찾아가며 정리한 생각은 다음과 같다.

우선 어떤 트랜잭션에서 예외가 발생(ERROR)했다는 것은 트랜잭션 롤백을 의미한다.

@TransactionalEventListener 메서드에서 예외가 발생하면 이미 본 메서드의 트랜잭션이 이미 커밋된 상태다.

따라서 이미 데이터베이스가 변경됐고 커밋된 상태이므로, 이벤트 메서드에서 예외를 던져서 롤백하는 것은 불가능하다.트랜잭션의 원자성, 일관성을 위해서라도 다 커밋된 트랜잭션을 이벤트 메서드에서 롤백하는 것은 위험하기도 하다.

따라서 이벤트 메서드의 예외를 debug 로그로 출력하고, 문제 해결을 위한 후속 조치를 취할 수 있도록 하는 것이 일반적인 방법이다.

 

해결

@TransactionalEventListener 메서드를 @Async로 감싸서 비동기로 실행시켰다.

이벤트 메서드를 새로운 스레드에서 실행시켜서 본 스레드의 트랜잭션에 영향을 받지 않도록 하니 정상적으로 예외가 출력됐다.

 

+) "도둑탈을 쓴 애쉬"님 댓글 참고

감사하게도, 아래 댓글에서 본 이슈와 관련된 스프링 프로젝트 PR을 알려주셨다.

"TransactionSynchronizationUtils.invokeAfterCompletion에서 debug 레벨로 로그를 찍었던 것은 이유가 명확하지 않으며, 예외를 볼 수 있도록 error 로그로 수정한다."

https://github.com/spring-projects/spring-framework/pull/30776

-> 해당 PR에서 error 레벨로 수정된 것을 확인할 수 있다!

 

참고자료

https://lenditkr.github.io/spring/transactional-event-listener/index.html