주문이 커밋된 뒤 감사(audit) 이력을 남기려고 @TransactionalEventListener를 붙였다. 예외도 없고 로그도 깨끗한데 audit 테이블은 비어 있었다. 커밋이 "끝난 뒤"에 도는 리스너라면 거기서 한 쓰기는 새 트랜잭션에서 커밋될 것 같은데, 실제로는 그렇지 않았다.
원인은 AFTER_COMMIT이 afterCommit() 콜백에서 돈다는 오해와, 커밋 직후엔 트랜잭션 리소스가 이미 정리됐다는 오해가 겹친 데 있었다.
@TransactionalEventListener는 publishEvent() 시점에 리스너를 실행하지 않는다. 현재 스레드에 트랜잭션이 활성화돼 있으면 이벤트를 TransactionSynchronization으로 등록해 두고, 트랜잭션이 끝나는 시점에 실행한다. 이때 phase마다 어느 콜백에서 도는지가 다르다.
| TransactionPhase | 실행 콜백 | 조건 |
|---|---|---|
BEFORE_COMMIT | beforeCommit() | 항상 |
AFTER_COMMIT (기본) | afterCompletion(status) | status == COMMITTED |
AFTER_ROLLBACK | afterCompletion(status) | status == ROLLED_BACK |
AFTER_COMPLETION | afterCompletion(status) | 무조건 |
TransactionPhase javadoc도 AFTER_COMMIT이 afterCommit()이 아니라 afterCompletion에서 처리된다고 명시한다.
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() 쪽은 예외가 호출자에게 전파된다. 이름은 비슷하지만 실패 처리 방식이 정반대인 셈이다.
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 서비스를 부르는 경우는 걸러지지 않는다.
AFTER_COMMIT 리스너는 afterCompletion에서 돈다. 이 시점엔 커넥션이 아직 바인딩돼 있어서 REQUIRED 호출은 이미 커밋된 트랜잭션에 참여하고, 그 쓰기는 커밋되지 않는다. 리스너에서 DB에 써야 한다면 REQUIRES_NEW로 새 트랜잭션을 연다.
다음엔 @Async 조합에서 이 참여 문제가 어떻게 달라지는지, 그리고 크래시 시 이벤트 유실을 Spring Modulith의 Event Publication Registry가 어떻게 메우는지를 볼 생각이다.
TransactionalApplicationListenerSynchronization, AbstractPlatformTransactionManager#processCommit/triggerAfterCompletion, TransactionSynchronizationUtils#invokeAfterCompletion, DataSourceTransactionManager#isExistingTransaction, RestrictedTransactionalEventListenerFactoryTransactionPhase / TransactionSynchronization#afterCompletion javadoc