예외 먹는 @TransactionalEventListener
안녕하세요. 채권플랫폼 팀 토마스입니다.
렌딧에서는 차입자가 대출 상환일에 맞춰 상환한 원리금을, 해당 채권에 투자한 투자자에게 지급하고 있습니다.
여기서 대출의 상환처리 및 상환에 의한 채권의 변동을 처리하는 시스템와
투자자에게 해당 상환에 대한 투자금과 투자수익금을 지급하는 시스템은 분리되어 있습니다.
따라서, 채권 관리 서버에서 채권의 변동이 처리되었을 때, 그 채권의 정보를 투자 서버에 투자금 생성을 요청하기 위해
@TransactionalEventListener + Sqs 를 사용했습니다.
@TransactionalEventListener
- Transaction 과 연동되어 실행되는 EventListener 를 쉽게 정의할 수 있는 annotation이다.
- Event 발생시 TransactionalApplicationListenerMethodAdapter 에 의해서 해당 event가 transaction의 지정된 TransactionPhase 에 실행되도록 구현되어 있다.
- 지정된 TransactionPhase 란 에너테이션 옵션으로 줄 수 있으며, default 는 TransactionPhase.AFTER_COMMIT 이다.
- ex. AFTER_COMMIT TransactionPhase 사용시 event가 transaction이 commit 후 실행된다.
하지만 @TransactionalEventListener 를 사용할 때는 주의해야 할 점이 있습니다. 같이 알아봅시다.
문제가 된 코드 - 재현
스프링 부트 : 2.7.2
1 |
|
2 | fun service() { |
3 | // 트랜잭션 작업 |
4 | val name = "tomas" |
5 | eventPublisher.publishEvent(Event(name)) |
6 | } |
1 |
|
2 | fun onEventPublished(event: Event) { |
3 | val queueName = "queue-naem" |
4 | messagingTemplate.convertAndSend(queueName, event) |
5 | } |
별다른 문제는 없어 보입니다.
동작도 에러 없이 잘 돌아갑니다.
다만 Sqs 콘솔에서 메세지가 오지 않습니다. 몇 번 더 시도해 봅니다. 여전히 Sqs 콘솔에는 메세지가 없습니다.
뭐가문제야?
문제의 답은 queueName 의 오타였습니다. 큐 이름에 오타가 있으니 AWS SQS 상에서 해당 큐를 찾지 못했던 것입니다.
그렇다면 스프링에서는 왜 큐를 찾지 못했다는 예외 상황을 보여주지 않을까요?
어떻게 된 일인지 자세히 확인하기 위해서 로그를 디버그 레벨로 바꿔보았습니다.
1 | DEBUG --- [scheduling-1, -, -] com.amazonaws.request : Sending Request: POST https://sqs.ap-northeast-2.amazonaws.com / Parameters ~ |
2 | DEBUG --- [scheduling-1, -, -] o.s.t.s.TransactionSynchronizationUtils : |
3 | TransactionSynchronization.afterCompletion threw exception |
4 | org.springframework.messaging.core.DestinationResolutionException: |
5 | The queue does not exist or no access to perform action sqs:GetQueueUrl.; |
6 | nested exception is com.amazonaws.services.sqs.model.QueueDoesNotExistException: |
7 | The specified queue does not exist or you do not have access to it. |
8 | ... |
무언가가 나왔습니다! 우리가 기대하던 큐가 존재하지 않는다는 내용이네요.
에러로 찍히길 기대한 메세지가 디버그 레벨로 찍히고 있군요 😨
@TransactionalEventListener 가 붙은 이벤트 로직의 예외는 정상적으로 발생했습니다.
그럼 어디선가 예외를 잡아서 DEBUG 레벨로 출력하고 별다른 처리를 하지 않았다는 것입니다.
어느 곳에서 예외를 먹었을까요. 콜스택으로 추적 해봅시다. (주요한 콜 스택만 추려보았습니다.)
1 | 1. CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:763) |
2 | 2. TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:407) |
3 | 3. AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:785) |
4 | 4. AbstractPlatformTransactionManager.triggerAfterCompletion(AbstractPlatformTransactionManager.java:952) |
5 | 5. TransactionSynchronizationUtils.invokeAfterCompletion(TransactionSynchronizationUtils.java:172) |
6 | 6. TransactionalApplicationListenerSynchronization.afterCompletion(TransactionalApplicationListenerSynchronization.java:67) |
- service() 에 @Transactional 이 붙어 있어서, Proxy 로서 트랜잭션 처리를 시작합니다.
- 트랜잭션을 관리하는 PlatformTransactionManager 를 생성하고, 이를 이용하여 트랜잭션을 생성 후 시작합니다. 그리고 대상 메서드를 실행시킵니다.
- 시작된 트랜잭션은
TransactionInfo
객체로 이후 메서드들에 전달됩니다. TransactionInfo
객체에는 트랜잭션 매니저, 실행해야 할 메서드의 정보 등이 들어 있습니다.- 메서드는
invocation.proceedWithInvocation();
에서 실행됩니다.
- 시작된 트랜잭션은
- 트랜잭션의 커밋을 시도합니다.
- 트랜잭션을 커밋(
doCommit(status);
)을 하며, 전후로trigger~
형태의 메서드들이 실행됨을 볼 수 있습니다. trigger~
시리즈는TransactionSynchronizationUtils
에 정의된invoke~
시리즈를 호출합니다.
- 트랜잭션을 커밋(
- triggerAfterCompletion()
- @TransactionalEventListener 에 의해 실행됨을 추측할 수 있습니다.
- 현재 트랜잭션에 등록된
TransactionSynchronization
객체를 불러와서TransactionSynchronizationUtils.invokeAfterCompletion()
를 실행시킵니다.
- 위에서 불러온
TransactionSynchronization
객체들의afterCompletion()
메서드를 실행시킵니다. TransactionalApplicationListenerSynchronization
(TransactionSynchronization 의 구현체) 의afterCompletion
은 실제 이벤트 로직을 실행시킵니다.
위의 과정으로 알 수 있는 것은, @TransactionalEventListener 는 트랜잭션에 TransactionalApplicationListenerSynchronization
객체를 TransactionSynchronization 으로서 등록시켜 트랜잭션의 전 후에 이벤트를 실행할 수 있었다 라는 것입니다.
이 로그정보를 가지고 예외가 출력되지 않은 이유를 밝히기 전에, 로그에서 나온 TransactionSynchronization 에 대해 알아봅시다.
TransactionSynchronization
- 트랜잭션 커밋의 전후에 작업을 실행시키기 위한 인터페이스. 이를 TransactionSynchronization 이라 합니다.
- beforeCommit, beforeCompletion, afterCommit, afterCompletion 네가지 메서드를 구현할 수 있습니다.
- 구현체에 따라 처리 방법이 다르며, @TransactionalEventListener 는
TransactionalApplicationListenerSynchronization
라는 구현체를 사용합니다.
- 스프링은 트랜잭션 commit 시 등록된 모든 TransactionSynchronization 를 확인하여
- 트랜잭션에 TransactionSynchronization 를 등록하는 방법은
TransactionSynchronizationManager.registerSynchronization(TransactionSynchronization synchronization)
를 사용하면 됩니다. - @TransactionalEventListener 은 이벤트 publish 시점에 리스너를 읽어
TransactionalApplicationListenerMethodAdapter.onApplicationEvent()
에서 TransactionalApplicationListenerSynchronization 구현체를 등록 함을 볼 수 있습니다.
그리고 TransactionSynchronization 인터페이스의 beforeCommit(), afterCommit() 의 javadoc 주석을 보면 다음과 같이 적혀있습니다.
1 | in case of errors; will be propagated to the caller (note: do not throw TransactionException subclasses here!) |
예외가 적혀 전파된다고 합니다.
반면, beforeCompletion(), afterCompletion() 를 보면 다음과 같이 적혀있습니다.
1 | in case of errors; will be logged but not propagated (note: do not throw TransactionException subclasses here!) |
예외는 로깅만하고 전파되지 않는다고 합니다.
TransactionPhase
@TransactionalEventListener 에너테이션의 옵션으로 줄 수 있는 enum class 입니다.
- BEFORE_COMMIT
- AFTER_COMMIT
- AFTER_ROLLBACK
- AFTER_COMPLETION
그리고 @TransactionalEventListener 의 기본값은 AFTER_COMMIT 입니다.
어디서 예외를 먹었을까 ?
이젠 예외를 어디서 먹었는지 알아봅시다.
그 이유를 알기 위해서는 로그의 5, 6번을 자세히 봐야 합니다.
6번 로그 : TransactionalApplicationListenerSynchronization.afterCompletion
@TransactionalEventListener 에 의해 트랜잭션에 등록된 TransactionalApplicationListenerSynchronization 의 afterCompletion() 는 아래와 같이 생겼습니다.
1 |
|
2 | public void afterCompletion(int status) { |
3 | TransactionPhase phase = this.listener.getTransactionPhase(); |
4 | if (phase == TransactionPhase.AFTER_COMMIT && status == STATUS_COMMITTED) { |
5 | processEventWithCallbacks(); |
6 | } |
7 | else if (phase == TransactionPhase.AFTER_ROLLBACK && status == STATUS_ROLLED_BACK) { |
8 | processEventWithCallbacks(); |
9 | } |
10 | else if (phase == TransactionPhase.AFTER_COMPLETION) { |
11 | processEventWithCallbacks(); |
12 | } |
13 | } |
@TransactionalEventListener 에 등록된 Phase 와 트랜잭션의 현재 status 를 비교하고, 이벤트 로직을 실행합니다.
우리는 에너테이션을 기본값으로 썼기 때문에, phase == TransactionPhase.AFTER_COMMIT && status == STATUS_COMMITTED
의 조건을 만족해서 실행되었을 것 입니다.
5번 로그 : TransactionSynchronizationUtils.invokeAfterCompletion
TransactionSynchronizationUtils.invokeAfterCompletion
메서드는 아래와 같이 생겼습니다.
1 | public static void invokeAfterCompletion(@Nullable List<TransactionSynchronization> synchronizations, int completionStatus) { |
2 | if (synchronizations != null) { |
3 | for (TransactionSynchronization synchronization : synchronizations) { |
4 | try { |
5 | synchronization.afterCompletion(completionStatus); |
6 | } |
7 | catch (Throwable ex) { |
8 | logger.debug("TransactionSynchronization.afterCompletion threw exception", ex); |
9 | } |
10 | } |
11 | } |
12 | } |
synchronization.afterCompletion(completionStatus); 에서 발생하는 예외는 다시 던지지 않고, 디버그로 로그만 남기는 것을 발견했습니다.
위에서 확인한 TransactionSynchronization 의 주석에 적힌 동작과 같습니다.
이벤트 로직에서 예외가 발생하든 말든 더 이상 전파시키지 않고 여기서 DEBUG 로 예외를 찍고 먹어버린 것입니다.
근데 여기서 저는 약간 이상한 점을 느꼈습니다.
분명 @TransactionalEventListener 를 쓸 때 TransactionPhase 를 따로 등록하지 않아 TransactionPhase.AFTER_COMMIT
로 들어갔을 것입니다.
그리고 TransactionalApplicationListenerSynchronization 의 afterCommit() 가 실행되리라 생각했습니다.
하지만 정작 실행된 것은 afterCommit() 이 아닌 afterCompletion()
이었죠.
그럼 TransactionalApplicationListenerSynchronization.afterCommit() 은 어떻게 생겼을까요 ?
정답은, 구현되어 있지 않다
입니다.
따라서, TransactionSynchronization 의 디폴트 메서드가 실행되고, 아무것도 실행되지 않습니다.
1 | public interface TransactionSynchronization extends Flushable { |
2 | ... |
3 | default void afterCommit() { |
4 | } |
5 | } |
그리고 afterCompletion() 에서는 위에서 본 대로 AFTER_COMMIT, AFTER_ROLLBACK, AFTER_COMPLETION 를 모두 처리하고 있습니다.
AFTER_COMPLETION 은 커밋, 롤백 상관없이 실행시킬 phase 를 의미하며,
커밋일 때만 또는 롤백일 때만을 특정하기 위해서 AFTER_COMMIT, AFTER_ROLLBACK 를 사용할 수 있다는 것입니다.
그리고, 이 세 Phase 는 같은 sequence 에서 실행됩니다.
이는 TransactionPhase 의 javadoc 에서도 언급하고 있습니다.
1 | /** |
2 | * Handle the event after the commit has completed successfully. |
3 | * Note: This is a specialization of AFTER_COMPLETION and therefore |
4 | * executes in the same sequence of events as AFTER_COMPLETION |
5 | * (and not in TransactionSynchronization#afterCommit()). |
6 | */ |
7 | AFTER_COMMIT, |
TransactionPhase 의 AFTER_COMMIT 은 AFTER_COMPLETION 의 specialization 버전이라고 합니다.
그리고 이는 TransactionalApplicationListenerSynchronization.afterCommit() 에 실행되지 않습니다.
해결 방법
우리는 여전히 @TransactionalEventListener 에서 예외가 발생했을 때 인지해야 하는 니즈가 있습니다. 어떻게 하면 좋을까요 ?
렌딧 개발팀들과 이야기를 나눈 결과 크게 두 가지 해결책이 제시되었습니다.
방법 1. @TransactionalEventListener 의 코드를 try-catch 로 감싸기.
- 코드 단에서 예외를 잡아서 에러 레벨로 로그를 출력한다.
- 로그를 출력하고 다시 예외를 던져도 위의 내용처럼 예외를 먹어버릴 것이므로, 로그 출력에 의미가 있다.
- 중복을 최소화하기 위해 AOP 를 활용할 수 있을 것이다.
방법 2. @TransactionalEventListener 를 @Async 로 실행하기.
- 기본적으로 event 의 publish 는 동기적으로 동작한다.
- 이를 caller 스레드와 다른 스레드에서 Async 로 이벤트 로직을 처리하도록 변경한다.
- 그러면
SimpleAsyncUncaughtExceptionHandler
가 예외를 잡아서 처리한다. - 필요하다면, 커스텀한 AsyncExceptionHandler 를 정의하여 원하는 예외 처리를 할 수 있다.
- 트랜잭션은 thread-bound 이기 때문에, @Async 로 인해 다른 스레드가 할당되는 순간 트랜잭션에서 벗어날 수 있고, 예외를 caller 에게서 완전히 분리할 수 있다.
렌딧에서는 둘 중 예외를 단지 찍어줄 뿐인 1번보다는 원하는 처리를 유연하게 할 수 있는 2번을 선택해서 사용하기로 했습니다.
또, 이벤트 기반에서 비동기 처리는 자연스럽고 코드 또한 1번보다 2번이 더 간단하게 처리할 수 있습니다.
의문점
사실 해결 방법을 찾던 중 강제로 예외를 Caller 까지 전파시키게 할 수도 있습니다.
@TransactionalEventListener 가 커밋 후의 동작을 위해 등록시키는 TransactionalApplicationListenerSynchronization 를 새로운 구현현체로 대체 해주면 됩니다.
TransactionalApplicationListenerSynchronization 대체하기
- TransactionPhase == AFTER_COMMIT 일 때에는 afterCommit() 을 실행하도록 TransactionSynchronization 을 오버라이딩 한 구현체를 만든다.
- TransactionalEventListenerFactory 빈을 커스텀한다. 커스텀한 빈에서는 새로 구현한 TransactionalApplicationListenerSynchronization 를 반환하도록 한다.
- 나머지 동작은 유지하면서 TransactionPhase AFTER_COMMIT 은 예외를 전파할 수 있다.
- 예시 코드
하지만 이전에 한번 생각해봅시다. @TransactionalEventListener 는 왜 예외가 발생하지 않는 afterCompletion() 에서 동작하게 만들었을까요?
TransactionSynchronization 의 afterCommit, afterCompletion 은 모두 트랜잭션이 커밋된 후에 동작합니다.
그리고 스프링에서 다루는 트랜잭션은 thread-bound 입니다. 기본적으로 한 스레드당 하나의 트랜잭션이 동작합니다. 다른 트랜잭션의 예외가 방해할 일 도 없습니다.
따라서, @TransactionalEventListener 의 코드가 afterCommit() 에서 예외가 발생한다고 해도 상관없을 것 같은데 말이죠.
사실 설계자의 답을 추측할 수 밖에 없었지만, 문제가 되는 상황은 있습니다.ChainedTransactionManager
를 사용하면, 여러 datasource 에 대한 트랜잭션을 하나의 트랜잭션 처럼 사용할 수 있습니다.
하지만 ChainedTransactionManager
는 내부적으로 두 트랜잭션의 커밋을 순차적으로 진행하는데, 앞번의 트랜잭션이 정상적으로 커밋 된 후 뒷번의 트랜잭션이 롤백할 상황이 되었을 때 앞번의 트랜잭션을 롤백시킬 수 없다는 문제 때문에
Deprecated 되었는데요,
에러가 발생하는 예시 코드는 위 예시코드와 같은 프로젝트 깃허브 에서 볼 수 있습니다.
필요하다면 @TransactionalEventListener 에너테이션에 대한 동작을 위와 같이 바꿔쓰되, ChainedTransactionManager 처럼 문제 될 상황은 없는지 잘 확인해서 쓰면 될 것 같습니다.
이번 이슈를 통해 @TransactionalEventListener 기본 동작은 이벤트 로직의 예외를 DEBUG 로 로깅한다는 사실을 인지했기 때문에, 예외상황에 예외를 인지할 수 있는 (어떻게 보면 당연한 ?) 코드로 만들 수 있었습니다.
누군가 저와 비슷한 에러를 겪고, 우연한 경로로 이 글을 보게 된다면 도움이 되셨으면 좋겠습니다!
제 글을 읽어주셔서 감사합니다. 오늘도 모두 화이팅!
- 추가 참고 링크
비슷한 고민을 하고 있는 사람들