@TransactionalEventListener AFTER_COMMIT 리스너의 DB 쓰기는 왜 반영되지 않는가

seonwooj0810·1일 전

1. 도입

주문이 커밋된 뒤 감사(audit) 이력을 남기려고 @TransactionalEventListener를 붙였다. 예외도 없고 로그도 깨끗한데 audit 테이블은 비어 있었다. 커밋이 "끝난 뒤"에 도는 리스너라면 거기서 한 쓰기는 새 트랜잭션에서 커밋될 것 같은데, 실제로는 그렇지 않았다.

원인은 AFTER_COMMIT이 afterCommit() 콜백에서 돈다는 오해와, 커밋 직후엔 트랜잭션 리소스가 이미 정리됐다는 오해가 겹친 데 있었다.

2. 핵심 개념: 등록해 두었다가 afterCompletion에서 실행한다

@TransactionalEventListener는 publishEvent() 시점에 리스너를 실행하지 않는다. 현재 스레드에 트랜잭션이 활성화돼 있으면 이벤트를 TransactionSynchronization으로 등록해 두고, 트랜잭션이 끝나는 시점에 실행한다. 이때 phase마다 어느 콜백에서 도는지가 다르다.

TransactionPhase실행 콜백조건
BEFORE_COMMITbeforeCommit()항상
AFTER_COMMIT (기본)afterCompletion(status)status == COMMITTED
AFTER_ROLLBACKafterCompletion(status)status == ROLLED_BACK
AFTER_COMPLETIONafterCompletion(status)무조건

TransactionPhase javadoc도 AFTER_COMMIT이 afterCommit()이 아니라 afterCompletion에서 처리된다고 명시한다.

3. 내부 동작: clearSynchronization과 cleanup 사이의 구간

AbstractPlatformTransactionManager#processCommit의 순서를 따라가 보면 이렇다.

triggerBeforeCommit        ← BEFORE_COMMIT 리스너
triggerBeforeCompletion
doCommit                   ← 물리 COMMIT
triggerAfterCommit         ← afterCommit() 콜백 (예외가 호출자에게 전파)
finally:
  triggerAfterCompletion(COMMITTED)
    ├─ clearSynchronization()     ★1 synchronization 비활성화
    └─ invokeAfterCompletion()    ← AFTER_COMMIT 리스너 실행
finally:
  cleanupAfterCompletion()        ★2 커넥션 unbind, holder.clear()

리스너는 ★1과 ★2 사이에서 돈다. 이때 스레드의 상태를 정리하면 다음과 같다.

상태값
물리 커밋이미 끝남
ConnectionHolder 바인딩아직 남아 있음
ConnectionHolder.transactionActive아직 true

DataSourceTransactionManager#isExistingTransaction은 "홀더가 바인딩돼 있고 transactionActive가 true인가"로 판단한다. 그래서 이 구간에서 @Transactional(REQUIRED) 서비스를 호출하면 새 트랜잭션을 열지 않고 "기존 트랜잭션에 참여" 한다. 참여한 트랜잭션은 isNewTransaction() == false라서 doCommit에 들어가지 않고, 원래 트랜잭션의 커밋은 이미 끝났다. TransactionSynchronization#afterCompletion javadoc에 적힌 대로 "그 뒤에 커밋이 더 이상 따라오지 않는다". 쓰기가 사라진 이유가 이것이다.

AFTER_COMMIT 리스너는 커밋은 끝났지만 커넥션은 아직 묶여 있는 틈에서 돈다. 그래서 REQUIRED는 이미 끝난 트랜잭션에 올라탄다.

예외 처리도 다르다. TransactionSynchronizationUtils#invokeAfterCompletion은 콜백마다 catch (Throwable)로 감싸서 ERROR 로그만 남긴다. 반면 afterCommit() 쪽은 예외가 호출자에게 전파된다. 이름은 비슷하지만 실패 처리 방식이 정반대인 셈이다.

4. 예시: 조용히 실패하는 형태와 올바른 형태

logging.level.org.springframework.transaction: DEBUG를 켜고 돌려 보면 차이가 로그로 드러난다.

@Component
class OrderListener {
    private final AuditService audit;   // audit.write()는 @Transactional(REQUIRED)

    // ❌ 끝난 트랜잭션에 참여: "Participating in existing transaction"만 찍히고 커밋 로그가 없다
    @TransactionalEventListener
    public void onPlaced(OrderPlaced e) {
        audit.write(e);
    }

    // ✅ 새 트랜잭션을 연다: "Suspending current transaction, creating new transaction"
    @TransactionalEventListener
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void onPlacedSafe(OrderPlaced e) {
        audit.write(e);
    }
}

Spring 6.1부터는 RestrictedTransactionalEventListenerFactory가 리스너 메서드에 REQUIRED @Transactional을 붙이면 기동을 막는다. 다만 이 검사는 리스너 메서드 자체의 애너테이션만 본다. 위 ❌처럼 다른 빈의 REQUIRED 서비스를 부르는 경우는 걸러지지 않는다.

5. 정리

AFTER_COMMIT 리스너는 afterCompletion에서 돈다. 이 시점엔 커넥션이 아직 바인딩돼 있어서 REQUIRED 호출은 이미 커밋된 트랜잭션에 참여하고, 그 쓰기는 커밋되지 않는다. 리스너에서 DB에 써야 한다면 REQUIRES_NEW로 새 트랜잭션을 연다.

다음엔 @Async 조합에서 이 참여 문제가 어떻게 달라지는지, 그리고 크래시 시 이벤트 유실을 Spring Modulith의 Event Publication Registry가 어떻게 메우는지를 볼 생각이다.

참고 자료

  • Spring Framework 소스 (spring-tx): TransactionalApplicationListenerSynchronization, AbstractPlatformTransactionManager#processCommit/triggerAfterCompletion, TransactionSynchronizationUtils#invokeAfterCompletion, DataSourceTransactionManager#isExistingTransaction, RestrictedTransactionalEventListenerFactory
  • TransactionPhase / TransactionSynchronization#afterCompletion javadoc
  • Spring Framework Reference — Data Access › Transaction Management › Transaction-bound Events

0개의 댓글