Spring @TransactionalEventListener AFTER_COMMIT 함정: 저장 로직이 반영되지 않는 이유
도입부
주문 트랜잭션이 성공한 뒤 이메일을 보내거나 후속 상태를 저장할 때 @TransactionalEventListener를 사용할 수 있다. 기본 실행 시점은 AFTER_COMMIT이므로 커밋 성공 후에만 처리해야 하는 작업에 잘 맞는다.
문제는 리스너에서 엔티티를 수정했는데 예외 없이 끝나면서도 DB에 반영되지 않는 상황이다. AFTER_COMMIT은 이름 그대로 기존 트랜잭션의 커밋이 완료된 뒤 실행되기 때문이다.
외부 알림처럼 DB 저장이 필요 없는 작업은 AFTER_COMMIT에서 처리할 수 있다. 리스너에서 DB를 변경해야 한다면 별도의 REQUIRES_NEW 트랜잭션이 필요하고, 메시지 유실까지 막아야 한다면 아웃박스 패턴을 검토해야 한다.
@TransactionalEventListener란?
일반 @EventListener는 이벤트가 발행되면 기본적으로 그 자리에서 리스너를 실행한다. 이벤트를 발행한 메서드가 나중에 롤백돼도 이메일이나 외부 API 호출은 이미 끝났을 수 있다.
@TransactionalEventListener는 실행 시점을 트랜잭션 단계에 연결한다.
| 단계 | 실행 시점 | 대표 용도 |
|---|---|---|
BEFORE_COMMIT |
커밋 직전 | 같은 트랜잭션 안의 검증·추가 작업 |
AFTER_COMMIT |
커밋 성공 후 | 알림, 캐시 무효화, 후속 작업 |
AFTER_ROLLBACK |
롤백 후 | 실패 기록, 보상 작업 신호 |
AFTER_COMPLETION |
커밋 또는 롤백 후 | 공통 정리와 관측성 |
기본값은 AFTER_COMMIT이다. 활성 트랜잭션 밖에서 이벤트가 발행되면 기본적으로 실행되지 않으며, 필요할 때만 fallbackExecution = true를 사용할 수 있다.
쉬운 예시: 주문 생성 후 이벤트 발행
이벤트는 불변 record로 정의한다.
public record OrderCreatedEvent(Long orderId) {
}
주문 저장과 이벤트 발행은 같은 트랜잭션에서 수행한다.
아래 Spring 예제들은 트랜잭션 흐름에 집중하기 위해 생성자 주입 코드를 생략했다.
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher publisher;
@Transactional
public Long createOrder(String email) {
Order order = orderRepository.save(
Order.create(email)
);
publisher.publishEvent(
new OrderCreatedEvent(order.getId())
);
return order.getId();
}
}
이벤트는 메서드 안에서 발행되지만 AFTER_COMMIT 리스너는 주문이 실제로 커밋된 뒤 실행된다. 주문 저장이 롤백되면 리스너도 실행되지 않는다.
함정: 리스너의 엔티티 수정이 저장되지 않는다
다음 코드는 자연스러워 보이지만 기대대로 동작하지 않을 수 있다.
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT
)
public void handle(OrderCreatedEvent event) {
Order order = orderRepository.findById(event.orderId())
.orElseThrow();
order.markNotificationRequested();
}
리스너가 실행될 때 영속성 컨텍스트나 DB 커넥션이 접근 가능한 상태로 남아 있을 수 있다. 조회와 수정도 예외 없이 끝날 수 있다. 그러나 원래 트랜잭션은 이미 커밋됐으므로 변경 감지 결과를 다시 커밋할 단계가 없다.
Spring 공식 Javadoc도 AFTER_COMMIT, AFTER_ROLLBACK, AFTER_COMPLETION에서 실행한 데이터 변경은 트랜잭션 자원에 커밋되지 않는다고 경고한다.
해결: 별도 서비스에서 REQUIRES_NEW 사용
후속 DB 저장은 별도 빈으로 분리해 새 트랜잭션을 시작한다.
@Service
public class NotificationStatusService {
private final OrderRepository orderRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markRequested(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow();
order.markNotificationRequested();
}
}
리스너는 이 서비스를 호출한다.
@Component
public class OrderEventListener {
private final NotificationStatusService statusService;
@TransactionalEventListener
public void handle(OrderCreatedEvent event) {
statusService.markRequested(event.orderId());
}
}
별도 빈으로 분리하는 이유는 Spring 트랜잭션이 프록시를 통해 적용되기 때문이다. 같은 클래스에서 this.markRequested()로 호출하면 REQUIRES_NEW가 적용되지 않을 수 있다. 자세한 원리는 @Transactional 실전 함정 정리에서 확인할 수 있다.
단순히 기본 전파 속성의 @Transactional을 추가하는 것은 해결책이 아니다. Spring Framework 6.1 이상은 트랜잭션 이벤트 리스너에 @Transactional을 함께 사용할 때 REQUIRES_NEW 또는 NOT_SUPPORTED만 지원하도록 제한한다.
외부 API와 아웃박스는 언제 사용할까?
이메일이나 푸시 호출은 주문 커밋 후 실행해야 하므로 AFTER_COMMIT과 잘 맞는다. 다만 기본 리스너는 같은 요청 스레드에서 동기 실행되므로 외부 API가 느리면 사용자 응답도 늦어진다.
@Async를 더하면 스레드는 분리되지만 작업이 영속 저장되는 것은 아니다. 커밋 직후 애플리케이션이 종료되면 메모리에 있던 이벤트가 사라질 수 있다.
| 요구 수준 | 권장 방식 |
|---|---|
| 실패해도 재시도가 필요 없는 알림 | AFTER_COMMIT |
| 요청 지연만 줄이면 되는 비핵심 작업 | AFTER_COMMIT + @Async |
| 반드시 전달해야 하는 메시지 | 아웃박스 테이블 + 발행 워커 |
| 중복 시 문제가 생기는 작업 | 아웃박스 + 멱등성 키 |
아웃박스는 주문과 “발행할 이벤트”를 같은 DB 트랜잭션에 저장한다. 별도 워커가 미발행 행을 읽어 외부 시스템으로 전달하므로 DB 커밋과 메시지 발행 사이의 유실 가능성을 줄인다.
실무에서 확인할 점
fallbackExecution = true는 트랜잭션 유무에 따라 실행 시점이 달라질 수 있으므로 신중하게 사용한다.@Transactional테스트는 기본적으로 롤백되므로AFTER_COMMIT리스너가 실행되지 않는 것이 정상이다. 테스트 메서드 안에서 결과까지 검증하려면TestTransaction으로 실제 커밋 지점을 만든다.- 외부 API 실패는 이미 커밋된 주문을 되돌리지 않는다. 재시도와 보상 흐름이 필요하다.
- 재시도를 허용하면 중복 이벤트가 생길 수 있으므로 주문 ID와 이벤트 종류를 이용한 멱등성 처리가 필요하다.
핵심 요약
@TransactionalEventListener의 기본 단계는AFTER_COMMIT이다.AFTER_COMMIT에서는 원래 트랜잭션이 이미 끝났다.- 후속 DB 저장은 별도 빈의
REQUIRES_NEW로 분리한다. @Async는 스레드를 분리하지만 이벤트 유실을 막지는 않는다.- 반드시 전달해야 하는 작업은 아웃박스와 멱등성을 사용한다.
마무리
AFTER_COMMIT은 “후속 작업도 주문 트랜잭션에 포함한다”는 뜻이 아니라 “주문 트랜잭션 성공 후 후속 작업을 시작한다”는 뜻이다. 이 차이를 이해하면 저장되지 않는 엔티티와 비동기 이벤트 유실을 줄일 수 있다.
DB 커넥션과 후속 트랜잭션의 관계를 운영 관점에서 더 살펴보려면 HikariCP 커넥션 풀 설정도 함께 참고할 수 있다.
공식 자료:
'Programming > Spring' 카테고리의 다른 글
| Spring Framework 7 @Retryable 실전 설정: 지수 백오프·지터·재시도 예외 구분 (0) | 2026.08.03 |
|---|---|
| Spring Bean Scope 실무 정리: Singleton·Prototype·Request 동작과 함정 (0) | 2026.07.30 |
| @Transactional 실전 함정 정리 — 셀프 호출부터 롤백까지 (0) | 2026.07.22 |
| HikariCP 커넥션 풀 설정, 숫자의 근거 정리 (0) | 2026.07.18 |
| 스프링 MVC와 DispatcherServlet 내부 동작: 모든 요청은 여기로 집결! (0) | 2025.08.24 |