Spring에서 애플리케이션 이벤트를 처리할 때는 두 가지 대표적인 방식이 있습니다.
- @EventListener
- @TransactionalEventListener(AFTER_COMMIT)
두 방식은 “언제 실행되는지”와 “트랜잭션 경계와의 관계”가 달라서 같은 이벤트 처리라도 DB 반영 결과가 완전히 달라질 수 있습니다. 두방식의 차이점을 간단한 프로젝트에서 로그로 확인해보겠습니다.
비교 표
항목 | @EventListener | @TransactionalEventListener(AFTER_COMMIT) |
실행 시점 | 이벤트 발행 즉시(동기) | 메인 트랜잭션 커밋 직후 콜백 실행 |
트랜잭션 경계 | 서비스 트랜잭션과 동일 | 메인 트랜잭션은 이미 커밋 완료 상태 |
DB 반영 | 서비스 커밋과 함께 반영 | 콜백에서 저장하려면 새 트랜잭션 필요(예: REQUIRES_NEW), 아니면 UPDATE/커밋 없음 |
예외 전파 | 리스너 예외 → 서비스까지 전파(롤백) | 리스너 예외는 메인 트랜잭션에 영향 없음(이미 커밋) |
대표 로그 패턴 | publishEvent 직후 리스너 로그가 같은 스레드에서 바로 등장 | Registered transaction synchronization → publishEvent returned → Committing... → 그 다음에 리스너 실행 |
대표 사용처 | 동기 후처리(같이 성공/실패해야 할 때) | 커밋 이후 비즈 후처리(실패 격리·지연 처리) |
주의점 | 리스너 실패가 전체 롤백 유발 | 콜백에서 DB 쓰기 시 UPDATE/커밋 로그가 안 나올 수 있음(새 TX 필수) |
어떻게 테스트했나?
간단한 미니 프로젝트를 만들어 CustomerService 안에서 고객(Customer)을 저장한 직후 이벤트를 발행하도록 했습니다.
이벤트리스너에서 하는 일은 저장한 고객에 토큰 정보를 추가하는 것입니다.
- @EventListener 버전: 같은 트랜잭션에서 동기 실행되도록 구성
- @TransactionalEventListener(AFTER_COMMIT) 버전: 메인 트랜잭션이 커밋된 이후 실행되도록 구성
테스트 코드는 고객을 생성하고 이벤트를 발행한 뒤, 로그와 DB 반영 결과(token 필드 값)를 확인하는 방식으로 차이를 비교했습니다.
// Service
@Transactional
public Customer createCustomer(String name, String email) {
//고객 저장
Customer customer = new Customer(name, email);
customerRepository.save(customer);
//이벤트 발행
publisher.publishEvent(new CustomerCreatedEvent(customer));
return customer;
}
@EventListener //(동기, 같은 트랜잭션)
public void handleSync(CustomerCreatedEvent event) {
//고객에 토큰정보 추가
event.getCustomer().setToken("token-sync");
customerRepository.save(event.getCustomer()); // 메인 TX에 함께 반영
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleAfterCommit(CustomerCreatedEvent event) {
//고객에 토큰정보 추가
event.getCustomer().setToken("token-after");
customerRepository.save(event.getCustomer()); // 새 TX 없으면 DB 반영 안됨
}
로그로 확인
1) @EventListener (동기, 같은 트랜잭션)
리스너가 같은 스레드/같은 트랜잭션에서 실행되고, UPDATE가 메인 커밋에 묶여 실제 DB에 반영됩니다.
// 서비스 트랜잭션 시작
DEBUG [Test worker] o.s.orm.jpa.JpaTransactionManager - Creating new transaction with name [com.example.txeventlab.service.CustomerService.createCustomer]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
// Customer INSERT
DEBUG [Test worker] org.hibernate.SQL - insert into customer (email,name,token,id) values (?,?,?,default)
// 이벤트 발행 직전
INFO [Test worker] c.e.t.service.CustomerService - [SERVICE] saved customer id=1, now publishing event
// ★ 즉시 실행: publishEvent 안에서 리스너가 동기 호출됨
INFO [Test worker] c.e.t.l.CustomerCreatedEventListener_Event - [EVENT] @EventListener received CustomerCreatedEvent{customer=Customer(id=1, name=Matt, email=matt@gmail.com, token=null)}
// ★ 같은 트랜잭션 참여
DEBUG [Test worker] o.s.orm.jpa.JpaTransactionManager - Found thread-bound EntityManager [SessionImpl(...)] for JPA transaction
DEBUG [Test worker] o.s.orm.jpa.JpaTransactionManager - Participating in existing transaction
// ★ 리스너 저장 후
INFO [Test worker] c.e.t.l.CustomerCreatedEventListener_Event - [EVENT] token persisted in SAME transaction
// 리스너 끝난 뒤에야 서비스가 반환 → 동기 호출 확정
INFO [Test worker] c.e.t.service.CustomerService - [SERVICE] publishEvent returned
// ★ 단일 커밋(INSERT+UPDATE 함께)
DEBUG [Test worker] o.s.orm.jpa.JpaTransactionManager - Committing JPA transaction on EntityManager [SessionImpl(...)]
DEBUG [Test worker] org.hibernate.SQL - update customer set email=?,name=?,token=? where id=?
2) @TransactionalEventListener(AFTER_COMMIT) (커밋 후 콜백)
AFTER_COMMIT 콜백은 커밋 이후라서 repo.save를 해도 새 트랜잭션 없이는 UPDATE/커밋이 발생하지 않습니다.
→ 최종 token은 null.
// 서비스 트랜잭션 시작 → INSERT
DEBUG [Test worker] o.s.orm.jpa.JpaTransactionManager - Creating new transaction with name [com.example.txeventlab.service.CustomerService.createCustomer]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
DEBUG [Test worker] org.hibernate.SQL - insert into customer (email,name,token,id) values (?,?,?,default)
// 이벤트 발행 직전
INFO [Test worker] c.e.t.service.CustomerService - [SERVICE] saved customer id=1, now publishing event
// ★ AFTER_COMMIT 패턴: 즉시 실행이 아니라 '트랜잭션 동기화 등록'만 함
DEBUG [Test worker] o.s.t.e.TransactionalApplicationListenerMethodAdapter - Registered transaction synchronization for org.springframework.context.PayloadApplicationEvent[...]
// publishEvent 바로 반환(리스너 아직 실행 X)
INFO [Test worker] c.e.t.service.CustomerService - [SERVICE] publishEvent returned
// 메인 트랜잭션 커밋
DEBUG [Test worker] o.s.orm.jpa.JpaTransactionManager - Initiating transaction commit
DEBUG [Test worker] o.s.orm.jpa.JpaTransactionManager - Committing JPA transaction on EntityManager [SessionImpl(1784116580<open>)]
// ★ 커밋 직후에야 리스너 실행 시작(이미 커밋 완료 상태)
INFO [Test worker] c.e.t.l.CustomerCreatedEventListener_Event - [EVENT] @EventListener received CustomerCreatedEvent{customer=Customer(id=1, name=Matt, email=matt@gmail.com, token=null)}
// EM이 바인딩되어 '참여'처럼 보이나, 더 이상 커밋은 없음(Javadoc의 participate but no commit)
DEBUG [Test worker] o.s.orm.jpa.JpaTransactionManager - Found thread-bound EntityManager [SessionImpl(1784116580<open>)] for JPA transaction
DEBUG [Test worker] o.s.orm.jpa.JpaTransactionManager - Participating in existing transaction
// repo.save(...) 수행 로그이지만, 이 블록 전체에 UPDATE/commit 로그가 없음
TRACE [Test worker] o.s.t.i.TransactionInterceptor - Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
TRACE [Test worker] o.s.t.i.TransactionInterceptor - Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
INFO [Test worker] c.e.t.l.CustomerCreatedEventListener_Event - [EVENT] token persisted in SAME transaction
// 검증 조회 → token=null 로 확인(UPDATE가 실제 반영되지 않음)
DEBUG [Test worker] org.hibernate.SQL - select c1_0.id,c1_0.email,c1_0.name,c1_0.token from customer c1_0 where c1_0.id=?
Expected :Customer(id=1, name=Matt, email=matt@gmail.com, token=1576246959)
Actual :Customer(id=1, name=Matt, email=matt@gmail.com, token=null)
로그 확인 시 참고 사항 (Javadoc 해석과 일치)
로그에 EM 바인딩/participating가 보이는 부분
- afterCommit 시점엔 “트랜잭션은 이미 커밋”, 하지만 리소스(EM)는 접근 가능하게 남아 있을 수 있다.
- 그러나 더 이상의 커밋은 없다 → UPDATE/commit 로그가 찍히지 않았음을 확인할 수 있다.
- DB에 실제 반영하려면 콜백 내부에서 새 트랜잭션을 시작해야 한다. → @Transactional(propagation = REQUIRES_NEW) 권장.
선택 기준 : 어떻게 선택할까?
- 동일 트랜잭션에서 즉시 처리 + 같이 성공/실패: @EventListener
- 장점: 일관성 명확, 커밋 1번.
- 단점: 리스너 예외가 서비스까지 전파(전체 롤백).
- 커밋 후 후처리 + 실패 격리: @TransactionalEventListener(AFTER_COMMIT)
- DB 쓰기 필요하면 "콜백에서 @Transactional(REQUIRES_NEW)" 또는 "@Async + @Transactional"로 새 트랜잭션 열기.
- 장점: 메인 트랜잭션과 분리, 후처리 지연.
- 단점: 새 TX를 명시하지 않으면 실제 반영 안 됨(이번 테스트처럼)