안녕하세요. 채권플랫폼 팀 토마스입니다.
렌딧에서는 차입자가 대출 상환일에 맞춰 상환한 원리금을, 해당 채권에 투자한 투자자에게 지급하고 있습니다.

여기서 대출의 상환처리 및 상환에 의한 채권의 변동을 처리하는 시스템와
투자자에게 해당 상환에 대한 투자금과 투자수익금을 지급하는 시스템은 분리되어 있습니다.

따라서, 채권 관리 서버에서 채권의 변동이 처리되었을 때, 그 채권의 정보를 투자 서버에 투자금 생성을 요청하기 위해
@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
@Transactional
2
fun service() {
3
    // 트랜잭션 작업
4
    val name = "tomas"
5
    eventPublisher.publishEvent(Event(name))
6
}
1
@TransactionalEventListener
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)
  1. service() 에 @Transactional 이 붙어 있어서, Proxy 로서 트랜잭션 처리를 시작합니다.
  2. 트랜잭션을 관리하는 PlatformTransactionManager 를 생성하고, 이를 이용하여 트랜잭션을 생성 후 시작합니다. 그리고 대상 메서드를 실행시킵니다.
    • 시작된 트랜잭션은 TransactionInfo 객체로 이후 메서드들에 전달됩니다.
    • TransactionInfo 객체에는 트랜잭션 매니저, 실행해야 할 메서드의 정보 등이 들어 있습니다.
    • 메서드는 invocation.proceedWithInvocation(); 에서 실행됩니다.
  3. 트랜잭션의 커밋을 시도합니다.
    • 트랜잭션을 커밋(doCommit(status);)을 하며, 전후로 trigger~ 형태의 메서드들이 실행됨을 볼 수 있습니다.
    • trigger~ 시리즈는 TransactionSynchronizationUtils 에 정의된 invoke~ 시리즈를 호출합니다.
  4. triggerAfterCompletion()
    • @TransactionalEventListener 에 의해 실행됨을 추측할 수 있습니다.
    • 현재 트랜잭션에 등록된 TransactionSynchronization 객체를 불러와서 TransactionSynchronizationUtils.invokeAfterCompletion() 를 실행시킵니다.
  5. 위에서 불러온 TransactionSynchronization 객체들의 afterCompletion() 메서드를 실행시킵니다.
  6. TransactionalApplicationListenerSynchronization (TransactionSynchronization 의 구현체) 의 afterCompletion 은 실제 이벤트 로직을 실행시킵니다.


위의 과정으로 알 수 있는 것은, @TransactionalEventListener 는 트랜잭션에 TransactionalApplicationListenerSynchronization 객체를 TransactionSynchronization 으로서 등록시켜 트랜잭션의 전 후에 이벤트를 실행할 수 있었다 라는 것입니다.

이 로그정보를 가지고 예외가 출력되지 않은 이유를 밝히기 전에, 로그에서 나온 TransactionSynchronization 에 대해 알아봅시다.

TransactionSynchronization

Oracle Docs

  • 트랜잭션 커밋의 전후에 작업을 실행시키기 위한 인터페이스. 이를 TransactionSynchronization 이라 합니다.
  • beforeCommit, beforeCompletion, afterCommit, afterCompletion 네가지 메서드를 구현할 수 있습니다.
  • 구현체에 따라 처리 방법이 다르며, @TransactionalEventListener 는 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 입니다.

  1. BEFORE_COMMIT
  2. AFTER_COMMIT
  3. AFTER_ROLLBACK
  4. AFTER_COMPLETION

그리고 @TransactionalEventListener 의 기본값은 AFTER_COMMIT 입니다.


어디서 예외를 먹었을까 ?

이젠 예외를 어디서 먹었는지 알아봅시다.
그 이유를 알기 위해서는 로그의 5, 6번을 자세히 봐야 합니다.

6번 로그 : TransactionalApplicationListenerSynchronization.afterCompletion

@TransactionalEventListener 에 의해 트랜잭션에 등록된 TransactionalApplicationListenerSynchronization 의 afterCompletion() 는 아래와 같이 생겼습니다.

1
@Override
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 로 로깅한다는 사실을 인지했기 때문에, 예외상황에 예외를 인지할 수 있는 (어떻게 보면 당연한 ?) 코드로 만들 수 있었습니다.

누군가 저와 비슷한 에러를 겪고, 우연한 경로로 이 글을 보게 된다면 도움이 되셨으면 좋겠습니다!

제 글을 읽어주셔서 감사합니다. 오늘도 모두 화이팅!