빌드노트
이벤트를 발행했는데 @TransactionalEventListener가 호출되지 않는다
일반 이벤트는 받는데 트랜잭션 리스너만 조용하다면 등록과 발행 순간의 트랜잭션을 나눠 확인한다. Spring 6.2.19와 H2에서 트랜잭션 없음·커밋·롤백을 비교하고, fallbackExecution이 커밋 이후 처리 계약을 어떻게 바꾸는지 살펴본다.
같은 이벤트를 @EventListener에서는 받는데 @TransactionalEventListener에서만 받지 못한다면, 리스너 등록과 발행 순간의 트랜잭션 상태를 나눠 확인해야 한다. 등록이 정상이어도 트랜잭션 밖에서 발행한 이벤트는 기본 설정의 트랜잭션 리스너가 처리하지 않는다. 기본 실행 시점인 AFTER_COMMIT은 이벤트를 발행한 트랜잭션이 성공적으로 커밋된 뒤라는 뜻이다. Spring의 트랜잭션 이벤트 설명 (새 탭에서 열림)
이벤트를 발행했으니 언젠가 커밋할 때 불리겠다고 생각하면 진단이 어긋난다. 트랜잭션 없이 발행한 이벤트를 보관했다가 다음 트랜잭션의 커밋에 붙이는 기능은 아니다.
트랜잭션 상태는 발행 바로 앞에서 본다
JDBC처럼 PlatformTransactionManager를 사용하는 경로라면, 실제 publishEvent(...) 호출 바로 앞에 다음 출력을 잠시 넣는다. org.springframework.transaction.support.TransactionSynchronizationManager를 import한다. 이 코드는 현재 발행 스레드에 트랜잭션과 동기화가 활성화돼 있는지 확인한다.
System.out.printf("publish tx=%s sync=%s%n",
TransactionSynchronizationManager.isActualTransactionActive(),
TransactionSynchronizationManager.isSynchronizationActive());
별도 재현에서 트랜잭션 밖의 발행은 publish tx=false sync=false, 트랜잭션 안의 발행은 publish tx=true sync=true로 나왔다. 메서드에 @Transactional이 보이는지만 확인하지 말고, 문제가 생긴 호출 경로의 이 값을 본다.
sync=true 하나만으로 실제 트랜잭션이 있다고 판단해서도 안 된다. Spring은 동기화만 활성화된 상태와 실제 트랜잭션이 있는 상태를 구별한다. 스레드 기반 트랜잭션의 이벤트 등록 구현도 두 조건을 함께 확인한다. 상태 조회 API (새 탭에서 열림), 6.2.19의 이벤트 등록 구현 (새 탭에서 열림)
일반 리스너의 로그는 그 리스너까지 이벤트가 도달했다는 증거다. 별도의 트랜잭션 리스너 빈이 등록됐다는 증거까지 되지는 않는다. 대상 클래스가 컴포넌트 스캔이나 @Bean으로 등록됐는지, 발행하는 컨텍스트에서 그 빈을 조회할 수 있는지, 이벤트 매개변수 타입과 condition이 맞는지도 확인한다. 직접 new로 만든 객체에 애너테이션을 붙인 것만으로는 리스너 빈이 되지 않는다. 애너테이션 기반 이벤트 리스너 등록 (새 탭에서 열림)
Spring 컨텍스트를 직접 조립했다면 트랜잭션 이벤트 처리 기반도 확인한다. Spring의 트랜잭션 관리를 활성화하면 이 처리가 함께 등록되며, 그 외 구성에서는 TransactionalEventListenerFactory 빈이 필요하다. 아래 재현은 @EnableTransactionManagement를 사용하고 해당 팩토리와 리스너 빈을 조회한 뒤 실행했다. 리스너 어댑터의 등록 조건 (새 탭에서 열림)
같은 리스너를 커밋과 롤백에서 비교했다
OpenJDK 17.0.2, Spring Framework 6.2.19, H2 2.3.232에서 합성 이벤트로 따로 실행했다. 각 조건마다 새 Spring 컨텍스트와 전용 메모리 DB를 만들고, 일반 리스너 한 개와 비교할 트랜잭션 리스너 한 개를 등록했다. 외부 서비스는 호출하지 않았다. DataSourceTransactionManager와 TransactionTemplate을 사용했으며 비동기 실행 설정은 넣지 않았다.
기본 @TransactionalEventListener를 두었을 때 관찰은 다음과 같았다.
- 트랜잭션 없이 발행하면 일반 리스너만 1회 호출됐다. 트랜잭션 리스너는 0회였고, 나중에 빈 트랜잭션을 커밋해도 추가 호출은 없었다.
- 한 트랜잭션에서 행을 INSERT하고 이벤트를 발행한 뒤 정상 커밋하면 두 리스너가 각각 1회 호출됐다. 커밋 뒤 별도 연결에서 조회한 행 수는 1이었다.
- 같은 INSERT와 발행 뒤
setRollbackOnly()로 롤백하면 일반 리스너만 1회 호출됐다. 트랜잭션 리스너는 0회였고 별도 연결의 행 수는 0이었다.
정상 커밋 로그의 아래 부분은 publishEvent의 반환과 DB 커밋, 트랜잭션 리스너 호출을 구별하는 데 쓸 수 있다. DB_COMMIT_RETURN은 실제 JDBC commit()이 정상 반환한 직후에 기록했다. independentRows는 트랜잭션에 묶이지 않은 별도 연결로 조회한 행 수다.
02 PUBLISH tx=true sync=true
03 EVENT_LISTENER id=commit_strict
04 PUBLISH_RETURN
05 TX_BODY_END
06 DB_COMMIT_RETURN independentRows=1
07 TRANSACTIONAL_LISTENER fallback=false id=commit_strict independentRows=1
08 TRANSACTION_TEMPLATE_RETURN
일반 리스너는 발행 도중 실행됐고, 트랜잭션 리스너는 커밋이 끝난 뒤 실행됐다. publishEvent가 반환됐다는 사실만으로 AFTER_COMMIT 리스너가 이미 끝났다고 판단할 수 없다. 반대로 일반 리스너가 호출됐다는 사실만으로 저장이 성공했다고 판단할 수도 없다. 롤백 조건에서도 일반 리스너는 호출됐기 때문이다. 일반 이벤트의 동기 실행은 Spring의 기본 설정이며, 별도 executor나 @Async를 쓰면 이 실험의 호출 순서를 그대로 적용할 수 없다. 이벤트의 기본 실행 방식 (새 탭에서 열림)
정상 커밋에서는 같은 트랜잭션 리스너가 호출되는데 트랜잭션 없는 발행에서만 조용하다면, 이 비교에서 리스너 등록은 확인된 셈이다. 반대로 발행 직전 값이 true/true이고 정상 커밋도 확인했는데 호출되지 않는다면, 트랜잭션 없음만으로 설명할 수 없다. 실제 phase, 빈 등록, 이벤트 타입과 조건을 다시 살펴본다.
fallback을 켜기 전에 처리 계약을 정한다
리스너를 @TransactionalEventListener(fallbackExecution = true)로 바꾸고 같은 세 조건을 다시 실행했다. 트랜잭션 없는 발행에서는 이번에 리스너가 1회 호출됐으며, PUBLISH_RETURN보다 먼저 실행됐다. 정상 커밋에서도 1회 호출됐다. 하지만 활성 트랜잭션을 롤백한 경우에는 여전히 0회였다.
fallbackExecution은 트랜잭션이 없을 때도 처리하도록 허용하는 옵션이다. 트랜잭션을 새로 만들어 주거나, 롤백된 이벤트까지 기본 AFTER_COMMIT 리스너로 보내는 옵션은 아니다. 트랜잭션이 있으면 지정한 단계에 따른 처리가 유지된다. TransactionalEventListener API (새 탭에서 열림)
저장 성공 뒤에만 실행해야 하는 작업이라면 기본 설정을 유지하고 저장과 이벤트 발행이 실제 같은 트랜잭션 안에 있도록 발행 위치나 업무 경계를 고친다. TransactionTemplate을 쓰는 코드에서는 저장과 발행을 같은 콜백 안에 둔다. 콜백이 끝나고 트랜잭션까지 종료된 다음 이벤트를 발행하면 이미 트랜잭션 밖이다. 발행 직전 상태와 정상 커밋·롤백의 호출 차이로 수정 결과를 확인한다.
트랜잭션 없는 발행도 처리해도 되는 업무라면 fallback을 선택할 수 있다. 이때 리스너가 호출됐다는 사실은 커밋 성공의 증거가 아니다. 트랜잭션 결과와 관계없이 이벤트 자체를 처리해야 한다면 일반 @EventListener가 그 계약에 맞는다. 옵션을 바꾸기 전에 “이 처리는 저장 성공에 종속되는가”부터 정해야 한다.
이번 실행은 동기 JDBC 경로에서 발행 시점과 커밋 결과에 따른 호출 여부를 확인했다. JPA, 리액티브 트랜잭션, 비동기 리스너, 프로세스 종료 후 재전달은 검증하지 않았다. 특히 리액티브 트랜잭션은 Reactor 컨텍스트를 사용하므로 위 스레드 상태 출력만으로 진단하지 않는다. 리액티브 적용 조건 (새 탭에서 열림)
또한 AFTER_COMMIT 리스너에서 DB에 추가로 쓴 내용까지 저장되는지는 이번 실험에 포함하지 않았다. 공식 API는 완료 단계에 원래 트랜잭션 자원이 남아 있어도 그 경로의 변경이 커밋되는 것은 아니라고 설명한다. 리스너 호출 확인과 그 안의 추가 저장 검증은 구별해야 한다. 완료 단계의 데이터 접근 주의점 (새 탭에서 열림)