본문 바로가기
Spring/Core

[Spring] @TransactionalEventListener

by 기몬식 2025. 7. 23.

TransactionalEventListener

@EventListener는 코드의 결합도를 낮추고 도메인 이벤트를 기반으로 유연한 구조를 만드는 데 유용하지만 트랜잭션을 사용하는 환경에서는 예상치 못한 문제가 발생할 수 있습니다. 다음과 같은 코드가 있다고 예를 들겠습니다.


@Transactional
fun createOrder() {
  orderRepository.save(...)
  applicationEventPublisher.publishEvent(OrderCreatedEvent(...))
}

앞서 작성한 이벤트리스너에서 알아봤듯이 @EventListener는 동기적으로 실행됩니다. orderRepository.save()는 아직 커밋되지 않았지만 이벤트 리스너에서 이 해당 값에 접근하거나 관련 작업을 시도할 때 커밋 전 상태에서 잘못된 동작이 발생할 수 있습니다. 이런 문제를 보완하기 위해 도입된 것이 바로 @TransactionalEventListener 입니다. 또한 트랜잭션의 어느 시점에 이벤트를 처리할지를 명시적으로 제어하기 위해서 다음과 같은 Enum 으로 관리됩니다.


public enum TransactionPhase {

    BEFORE_COMMIT,

    AFTER_COMMIT,

    AFTER_ROLLBACK,

    AFTER_COMPLETION

}

  • BEFORE_COMMIT: 커밋 직전에 실행됨
  • AFTER_COMMIT: 커밋 완료 후 실행됨
  • AFTER_ROLLBACK: 트랜잭션이 롤백된 경우에만 실행됨
  • AFTER_COMPLETION: 커밋 또는 롤백 후 항상 실행됨

먼저 스프링의 @Transactional 은 스프링 AOP 기반으로 동작하며 적용되는 메소드는 프록시 객체를 통해 호출됩니다. 이때 해당 메소드를 호출할 때 TransactionInterceptor 를 통해 메소드 호출 전후로 실제로 트랜잭션 경계를 설정하고 관리하는 로직을 담당합니다.



public class TransactionInterceptor extends TransactionAspectSupport implements MethodInterceptor, Serializable {
  ...

  @Override
  @Nullable
  public Object invoke(MethodInvocation invocation) throws Throwable {
    // Work out the target class: may be {@code null}.
    // The TransactionAttributeSource should be passed the target class
    // as well as the method, which may be from an interface.
    Class<?> targetClass = (invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null);

    // Adapt to TransactionAspectSupport's invokeWithinTransaction...
    return invokeWithinTransaction(invocation.getMethod(), targetClass, invocation::proceed);
  }

  ...
}

이 때 TransactionInterceptor는 실제 주요 로직을 TransactionAspectSupport에 위임하며 invokeWithinTransaction 를 실행합니다.


protected Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass,
                                       InvocationCallback invocation) throws Throwable {

  // 트랜잭션 속성 조회
  TransactionAttribute txAttr = getTransactionAttributeSource()
      ?.getTransactionAttribute(method, targetClass);

  // 트랜잭션 매니저 결정
  PlatformTransactionManager ptm = asPlatformTransactionManager(
      determineTransactionManager(txAttr, targetClass)
  );

  // 메서드 식별자 (로그용 등)
  String joinpointId = methodIdentification(method, targetClass, txAttr);

  // 트랜잭션 시작
  TransactionInfo txInfo = createTransactionIfNecessary(ptm, txAttr, joinpointId);

  try {
      // 실제 대상 메서드 실행 (프록시 체인 내부에서 호출됨)
      Object retVal = invocation.proceedWithInvocation();

      // 트랜잭션 커밋
      commitTransactionAfterReturning(txInfo);
      return retVal;

  } catch (Throwable ex) {
      // 예외 발생 시 트랜잭션 롤백
      completeTransactionAfterThrowing(txInfo, ex);
      throw ex;

  } finally {
      // ThreadLocal 정리 (트랜잭션 컨텍스트 제거)
      cleanupTransactionInfo(txInfo);
  }

  // 생략: ReactiveTransactionManager 분기
  // 생략: CallbackPreferringPlatformTransactionManager 분기
  // 생략: Vavr Try, Future 처리 로직
}

다음은 실제 트랜잭션 로직을 위임 받아 실행되는 메소드로 주요 메소드들에 대한 설명을 하면 다음과 같습니다.

  • TransactionAttribute 조회: @Transactional에 명시된 속성(propagation, readOnly, rollbackFor ...)과 같은 메타 데이터를 조회
  • PlatformTransactionManager 결정: 조회한 TransactionAttribute에 따라 사용할 TransactionManager를 결정
  • 트랜잭션 생성: 전파 전략(propagation)에 따라 트랜잭션에 참여하거나 새로운 트랜잭션을 만들며 TransactionSynchronizationManager 에 동기화 컨텍스트를 등록
  • 실제 메소드 호출: AOP 체인상 다음 메소드를 실행 및 실제 대상 객체의 메소드가 호출
  • 커밋: 예외 없이 메소드가 반환되면 트랜잭션 커밋을 수행
  • 롤백: 지정된 rollback rule에 해당하는 예외가 발생하면 트랜잭션을 롤백
  • 트랜잭션 컨텍스트 정리: 트랜잭션 관련 ThreadLocal 정보를 모두 정리

invokeWithinTransaction 메소드를 통해 선언적 트랜잭션의 진입점을 구성하고 트랜잭션 시작과 커밋/롤백 그리고 스레드 컨텍스트 정리하는 흐름을 알 수 있습니다. 하지만 여기서 중요한 부분은 트랜잭션의 경계를 어떻게 확장 가능한 훅(hook)으로 노출하는지 에 대한 문제입니다. 이에 대해 스프링은 트랜잭션의 라이프사이클(시작 ~ 종료)에 맞춰 특정 작업을 후킹하여 실행할 수 있도록 하는 콜백 인터페이스이자 확장 지점인 TransactionSynchronization 인터페이스를 제공합니다.


public interface TransactionSynchronization {

  void beforeCommit(boolean readOnly);

  void beforeCompletion();

  void afterCommit();

  void afterCompletion(int status);
}

스프링이 내부적으로 관리하는 TransactionSynchronizationManager 컨텍스트에 등록된 모든 TransactionSynchronization 인스턴스들은 트랜잭션의 진행 상황에 따라 순차적으로 실행됩니다. 등록된 여러 개의 후처리 콜백들을 AbstractPlatformTransactionManager 내부에서 이들을 순서대로 호출하게 됩니다.


private void processCommit(DefaultTransactionStatus status) throws TransactionException {

  try {
      boolean beforeCompletionInvoked = false;
      boolean commitListenerInvoked = false;
      boolean unexpectedRollback = false;

      // 1. 커밋 준비 작업 수행
      prepareForCommit(status);

      // 2. 커밋 전 트랜잭션 동기화 콜백 실행
      triggerBeforeCommit(status);
      triggerBeforeCompletion(status);
      beforeCompletionInvoked = true;

      // 3. 트랜잭션 커밋 수행
      if (status.hasSavepoint()) {
          // Savepoint 있는 경우 release
          unexpectedRollback = status.isGlobalRollbackOnly();
          this.transactionExecutionListeners.forEach(l -> l.beforeCommit(status));
          commitListenerInvoked = true;
          status.releaseHeldSavepoint();

      } else if (status.isNewTransaction()) {
          // 새 트랜잭션인 경우 doCommit 호출
          unexpectedRollback = status.isGlobalRollbackOnly();
          this.transactionExecutionListeners.forEach(l -> l.beforeCommit(status));
          commitListenerInvoked = true;
          doCommit(status);

      } else if (isFailEarlyOnGlobalRollbackOnly()) {
          // Global rollback-only 마킹된 경우 예외 유도
          unexpectedRollback = status.isGlobalRollbackOnly();
      }

      // 4. 예상치 못한 rollback-only 마킹된 경우 예외
      if (unexpectedRollback) {
          throw new UnexpectedRollbackException("rollback-only로 표시되어 트랜잭션 롤백됨");
      }

  } catch (UnexpectedRollbackException ex) {
      // 명시적 rollback 발생 처리
      triggerAfterCompletion(status, STATUS_ROLLED_BACK);
      this.transactionExecutionListeners.forEach(l -> l.afterRollback(status, null));
      throw ex;

  } catch (TransactionException ex) {
      // 커밋 중 트랜잭션 관련 예외 발생 시 rollback 여부 판단
      if (isRollbackOnCommitFailure()) {
          doRollbackOnCommitException(status, ex);
      } else {
          triggerAfterCompletion(status, STATUS_UNKNOWN);
          if (commitListenerInvoked) {
              this.transactionExecutionListeners.forEach(l -> l.afterCommit(status, ex));
          }
      }
      throw ex;

  } catch (RuntimeException | Error ex) {
      // 실행 중 예외 발생 시 커밋 전 콜백 미실행된 경우 처리 후 롤백
      if (!beforeCompletionInvoked) {
          triggerBeforeCompletion(status);
      }
      doRollbackOnCommitException(status, ex);
      throw ex;
  }

  try {
      // 5. 커밋 이후 콜백 실행
      triggerAfterCommit(status);
  } finally {
      // 6. 커밋 완료 이후 후처리 및 리스너 호출
      triggerAfterCompletion(status, STATUS_COMMITTED);
      if (commitListenerInvoked) {
          this.transactionExecutionListeners.forEach(l -> l.afterCommit(status, null));
      }
  }

  } finally {
    // 7. 트랜잭션 종료 후 클린업
    cleanupAfterCompletion(status);
  }
}

AbstractPlatformTransactionManage#processCommit() 내부 로직을 보게 되면 다음과 같은 콜백 실행 지점이 존재합니다.


triggerBeforeCommit(status);       // 커밋 직전
triggerBeforeCompletion(status);   // 커밋 직전 (무조건 실행)
doCommit(status);                  // 실제 커밋
triggerAfterCommit(status);        // 커밋 직후
triggerAfterCompletion(status, STATUS_COMMITTED); // 커밋/롤백 여부와 무관하게 실행

앞서 언급한 각 트리거 메서드들은 내부적으로 등록된 모든 TransactionSynchronization 콜백들을 순회하며 실행됩니다. 그렇다면 @TransactionalEventListener 역시 TransactionSynchronization의 구현체로 등록되어 트랜잭션 콜백의 일환으로 실행되는 것인지 직접 디버깅을 통해 그 동작 방식을 확인해 보겠습니다.

동작



@Component
class SampleTransactionalHandler {

  @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
  fun handle(event: SampleTransactionalEvent) {
      println("after commit : ${event.message}")
  }
}

@Service
class SampleService(
  private val applicationEventPublisher: ApplicationEventPublisher
) {

  @Transactional
  fun save() {
      println("saving message")
      applicationEventPublisher.publishEvent(SampleTransactionalEvent("hello world!"))
  }

}

data class SampleTransactionalEvent(val message: String)

먼저 디버깅하기 앞서 다음과 같은 클래스들을 미리 정의합니다.


@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
@SpringBootTest
class SampleServiceTest(
  private val sampleService: SampleService,
) {

  @Test
  fun testTransactionalEvent() {
      sampleService.save()
  }

}

그 후 작성한 테스트 코드를 실행합니다.

TransactionalApplicationListenerMethodAdapter 래핑


먼저 EventListenerMethodProcessor 에서 @TransactionalEventListener, @EventListener 가 선언되어 있는 메소드를 찾습니다.



TransactionalEventListenerFactory 에 의해 TransactionalApplicationListenerMethodAdapter 로 매핑됩니다. 그리고 @EventListener 와 동일하게 처리 가능한 ApplicationListener 의 함수를 찾아 invoke() 합니다.




이 때 @TransactionalEventListener 는 이벤트를 발행하기 이전에 현재 쓰레드에서 트랜잭션 동기화가 활성화 && 실제 물리적인 트랜잭션도 진행 중 일 때 TransactionSynchronization 콜백을 등록하게 됩니다.



AbstractPlatformTransactionManager#processCommit 에 등록된 phase 에 따라 AFTER_COMMIT 시점에 이벤트를 실행합니다.

마무리

트랜잭션은 단순한 DB 작업의 묶음이 아니라 시스템 동작의 신뢰성과 일관성을 보장하는 경계선입니다. 스프링은 이 경계를 TransactionManager를 통해 명확히 정의하고 TransactionSynchronization을 통해 확장 가능한 훅(Hook)을 제공합니다.


또한 @TransactionalEventListener가 이러한 트랜잭션 훅을 어떻게 활용하는지도 확인했습니다. 이를 통해 단일 시스템 내에서도 이벤트 기반 아키텍처처럼 느슨한 결합을 구현할 수 있으며 트랜잭션이 보장되는 안전한 이벤트 처리를 가능하게 합니다.


오탈자 및 오류 내용을 댓글 또는 메일로 알려주시면, 검토 후 조치하겠습니다.

'Spring > Core' 카테고리의 다른 글

[Spring] EventListener  (1) 2025.07.10
[Spring] @Transactional 속성  (1) 2023.12.24
[Spring] Spring AOP 동작 방식  (0) 2023.08.30
[Spring] Spring AOP JDK Dynamic & CGLIB Proxy 생성 방식  (0) 2023.08.28
[Spring] Spring AOP란?  (0) 2023.08.27