danuri
오늘의 기록
danuri
전체 방문자
오늘
어제
  • 오늘의 기록 (307)
    • java (150)
      • java (33)
      • spring (63)
      • jpa (36)
      • querydsl (7)
      • intelliJ (9)
    • kotlin (8)
    • python (24)
      • python (10)
      • data analysis (13)
      • crawling (1)
    • ddd (2)
    • chatgpt (2)
    • algorithm (33)
      • theory (9)
      • problems (23)
    • http (8)
    • git (8)
    • database (5)
    • aws (12)
    • devops (10)
      • docker (6)
      • cicd (4)
    • book (44)
      • clean code (9)
      • 도메인 주도 개발 시작하기 (10)
      • 자바 최적화 (11)
      • 마이크로서비스 패턴 (0)
      • 스프링으로 시작하는 리액티브 프로그래밍 (14)
    • tistory (1)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

인기 글

태그

  • reactive
  • JPA
  • DDD
  • Security
  • RDS
  • 등가속도 운동
  • Saving Plans
  • Bitmask
  • gitlab
  • Spring
  • 자바 최적화
  • mockito
  • CICD
  • Jackson
  • nuribank
  • 도메인 주도 설계
  • Java
  • PostgreSQL
  • Thymeleaf
  • docker
  • AWS
  • ChatGPT
  • SWAGGER
  • 트랜잭션
  • POSTGIS
  • 마이크로서비스패턴
  • S3
  • Database
  • Kotlin
  • connection

최근 댓글

최근 글

hELLO · Designed By 정상우.
danuri

오늘의 기록

java/spring

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

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

 

 

 

 

 

저작자표시 비영리 동일조건 (새창열림)

'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
    'java/spring' 카테고리의 다른 글
    • [Spring] Service - 인터페이스 도입과 버저닝
    • [Spring] 의존성 역정 원리(DIP) 관련 용어
    • [Spring] 스프링 디렉터리 패키지 구조
    • [Spring] Spring Data JPA의 페이징
    danuri
    danuri
    IT 관련 정보(컴퓨터 지식, 개발)를 꾸준히 기록하는 블로그입니다.

    티스토리툴바