[Spring] @TransactionalEventListener 정리: AFTER_COMMIT, 이벤트와 트랜잭션 타이밍
![[Spring] @TransactionalEventListener 정리: AFTER_COMMIT, 이벤트와 트랜잭션 타이밍](https://blog.kakaocdn.net/dna/cUAAxz/dJMcaccifCZ/AAAAAAAAAAAAAAAAAAAAAGMHNwOURtT3obK6hCQkL0pnU-cksUd1Wbnei9vUOt2f/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1788188399&allow_ip=&allow_referer=&signature=SJDjfiXqzTcIe5VQ0TuqkEduxyM%3D)
[Spring] @TransactionalEventListener 정리: AFTER_COMMIT, 이벤트와 트랜잭션 타이밍
"주문이 롤백됐는데 고객한테 주문 완료 푸시가 나갔어요." 스프링 이벤트를 쓰다 한 번씩 겪는 사고예요. 이벤트로 결합을 끊는 것까지는 좋았는데, 리스너가 언제 실행되는지를 안 따진 거죠. 그 타이밍을 트랜잭션에 맞춰주는 게 @TransactionalEventListener예요.
이벤트로 부가 로직을 분리하는 패턴, @EventListener의 타이밍 사고, 그리고 AFTER_COMMIT의 동작과 두 가지 함정(DB 쓰기 증발·동기 실행)을 차례로 풀어볼게요. @Transactional 동작 원리를 알고 보면 전부 자연스러워요.
01. 왜 이벤트인가, 핵심에서 부가를 떼기

주문 생성에 알림·포인트·통계가 따라붙는다고 직접 호출로 엮으면, 알림 장애가 주문 실패가 되고 부가 기능이 늘 때마다 주문 코드를 고쳐요. 이벤트로 바꾸면 주문은 "주문 생성됨"을 발행하고 끝이에요.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public void create(OrderCommand cmd) {
Order order = orderRepository.save(Order.from(cmd));
eventPublisher.publishEvent(new OrderCreatedEvent(order.getId()));
}
}
알림·포인트·통계는 각자 리스너로 구독해요. 발행자는 구독자를 몰라요. 여기까지가 교과서고, 문제는 다음이에요 — 그 리스너, 언제 실행되나요?
02. @EventListener의 사고, 롤백됐는데 알림 발송

기본 @EventListener는 발행 즉시, 발행한 트랜잭션 안에서 실행돼요. 그래서 이 시나리오가 가능해요 — 주문 저장 → 이벤트 발행 → 리스너가 푸시 발송 → 그 직후 재고 차감 실패로 전체 롤백. DB엔 주문이 없는데 고객은 "주문 완료" 알림을 받은 거예요. 푸시·메일·외부 API처럼 롤백이 안 되는 부수효과를 트랜잭션 확정 전에 실행한 게 사고의 본질이에요.
해법이 @TransactionalEventListener예요.
@Component
public class OrderNotificationListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent event) {
pushService.send(event.getOrderId()); // 커밋 확정 후에만 실행
}
}
AFTER_COMMIT(기본값)이면 커밋이 확정된 뒤에만 리스너가 돌아요. 롤백되면 아예 실행 안 되고요. "DB 상태와 외부 부수효과의 일치"가 구조적으로 보장돼요. 반대로 롤백 시에만 실행(AFTER_ROLLBACK)이나 커밋 직전(BEFORE_COMMIT)도 phase로 고를 수 있어요 — 검증 로직은 BEFORE_COMMIT이 어울려요.
03. AFTER_COMMIT의 함정 ①, 그 안의 DB 쓰기는 증발해요

AFTER_COMMIT 리스너 안에서 repository.save()를 했는데 저장이 안 되는 미스터리가 있어요. 이유는 이름 그대로예요 — 리스너가 도는 시점엔 트랜잭션이 이미 커밋되고 끝나 있어요. "커밋에 참여"가 아니라 "커밋 후"라서, 그 안의 쓰기는 합류할 트랜잭션이 없어요. 스프링은 기존 트랜잭션의 잔재 동기화 구간에서 실행하기 때문에 새 커밋도 자동으로 일어나지 않아요. 조용히 증발해요.
리스너에서 DB를 써야 하면 새 트랜잭션을 명시해요.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW) // 새 트랜잭션 필수
public void onOrderCreated(OrderCreatedEvent event) {
historyRepository.save(OrderHistory.from(event));
}
앞 편에서 본 REQUIRES_NEW가 정확히 필요한 자리예요.
04. 함정 ②, 기본은 동기 — 응답 시간에 더해져요
@TransactionalEventListener도 기본은 같은 스레드의 동기 실행이에요. 커밋 직후, 응답이 나가기 전에 리스너가 돌아요. 리스너가 외부 API를 2초 기다리면 사용자 응답도 2초 늦어요. 리스너의 예외도 (커밋은 이미 됐지만) 호출 흐름에 영향을 줄 수 있고요.
그래서 "커밋 후 + 비동기" 조합이 흔해요 — @Async를 같이 붙이는 거예요. 단 비동기로 넘어가는 순간 새로운 함정 세트(스레드풀·예외 증발·컨텍스트 미전파)가 열리는데, 그게 바로 다음 편 주제예요.
그리고 솔직한 한계 하나 — AFTER_COMMIT + @Async여도 "커밋됐는데 이벤트 처리는 유실"이 가능해요. 커밋 직후 서버가 죽으면 리스너는 영영 안 돌아요. 푸시 하나면 감수할 수 있지만, "커밋됐으면 반드시 처리돼야 하는" 메시지(타 시스템 연동 등)라면 인메모리 이벤트로는 부족해요. 그 보장이 필요할 때 꺼내는 게 아웃박스 패턴이고, 분산 패턴 시리즈에서 다뤄요.
05. 자주 만나는 문제
롤백됐는데 알림·메일이 나갔어요
@EventListener의 즉시 실행이에요. 부수효과 리스너는 @TransactionalEventListener(AFTER_COMMIT)로 바꿔요.
AFTER_COMMIT 리스너에서 save가 안 돼요
트랜잭션이 이미 끝난 시점이라서예요. REQUIRES_NEW를 명시해요.
이벤트 발행했는데 리스너가 아예 안 돌아요
@TransactionalEventListener는 트랜잭션이 없으면 기본적으로 실행되지 않아요. 트랜잭션 밖에서 발행한 이벤트가 조용히 무시되는 거예요(fallbackExecution = true로 바꿀 수 있지만, 먼저 "왜 트랜잭션이 없지?"를 봐야 해요 — 자기호출일 수도요).
리스너 때문에 응답이 느려요
동기 실행이에요. @Async를 붙이되, 다음 편의 비동기 함정(예외·스레드풀)을 같이 챙겨요.
리스너를 언제 부를까
스프링 이벤트의 모든 결정은 한 질문으로 모여요 — "리스너가 언제 도는가". 상황에 따라 답이 갈려요.
- 푸시·메일·외부 API 같은 부수효과라면 → AFTER_COMMIT. @EventListener의 즉시 실행은 롤백과 어긋나 사고가 나요.
- 리스너 안에서 DB를 써야 한다면 → AFTER_COMMIT + REQUIRES_NEW. 커밋 후라 합류할 트랜잭션이 없어서예요.
- 리스너가 느리다면 → @Async 추가. 단 그 순간 비동기 함정 세트가 열려요.
- 유실이 절대 안 되는 메시지라면 → 인메모리 이벤트 밖, 아웃박스의 영역이에요.
방금 미뤄둔 @Async의 함정들은 바로 다음 글에서 정면으로 풀어요. @Transactional 동작 원리를 깔고 보면 위 분기가 전부 자연스러워요.
출처: Spring Framework — Transaction-bound Events · Spring — Application Events
'백엔드 > 프레임워크 & 언어' 카테고리의 다른 글
| [Spring] 애너테이션이 안 먹을 때 트러블슈팅: 트랜잭션, 캐시, 비동기 증상별 진단 가이드 (0) | 2026.08.05 |
|---|---|
| [Spring] 순환 참조 해결과 빈 초기화 함정: 생성자 주입, @Lazy, @PostConstruct 주의점 (1) | 2026.08.04 |
| [Spring] @Async 함정 정리: 스레드풀 설정, 예외 증발, 트랜잭션·컨텍스트 미전파 (0) | 2026.08.03 |
| [Spring] @Transactional이 안 먹는 이유: 프록시 자기호출, private, 내부 호출 해결 (0) | 2026.07.30 |
| [Spring] @Transactional 동작 원리: 전파(propagation), 롤백 규칙, REQUIRES_NEW (0) | 2026.07.29 |
📚 같이 보면 좋은
"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."