문제
@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
'java > spring' 카테고리의 다른 글
[Spring] Service - 인터페이스 도입과 버저닝 (0) | 2023.06.26 |
---|---|
[Spring] 의존성 역정 원리(DIP) 관련 용어 (0) | 2023.06.23 |
[Spring] 스프링 디렉터리 패키지 구조 (0) | 2023.06.22 |
[Spring] Spring Data JPA의 페이징 (2) | 2023.06.21 |
[Spring] @ControllerAdvice, 특정 예외 발생 시 404에러가 발생하는 이슈 (0) | 2023.06.21 |