@TransactionalEventListener — 트랜잭션 커밋 후 이벤트를 안전하게 처리하는 방법
주문이 성공적으로 저장된 후에만 이메일을 보내고 싶은데, "저장"과 "이메일 발송"을 어떻게 안전하게 분리할 수 있을까요?
서비스 로직에서 DB 저장과 후속 작업(알림 발송, 포인트 적립, 로그 기록)을 같이 처리하다 보면 트랜잭션 경계 문제를 만나게 됩니다. 트랜잭션이 롤백되었는데 이미 이메일이 나갔다면? 공부하다 보니 이런 상황이 실무에서 꽤 자주 발생한다는 걸 알게 되었습니다.
@EventListener vs @TransactionalEventListener
먼저 두 어노테이션의 차이를 짚고 갑시다.
@EventListener — 즉시 실행
@Component
public class OrderEventHandler {
@EventListener
public void handleOrderCreated(OrderCreatedEvent event) {
// publishEvent() 호출 시 즉시, 같은 스레드에서 실행
emailService.sendOrderConfirmation(event.getOrderId());
}
}
@EventListener는 publishEvent()가 호출되는 그 시점에 즉시 실행 됩니다. 같은 트랜잭션 안에서 실행되므로, 만약 이메일 발송 후 트랜잭션이 롤백되면 이메일은 이미 나갔는데 주문은 취소된 상태 가 됩니다.
@Transactional
public void createOrder(OrderRequest request) {
Order order = orderRepository.save(Order.create(request));
// 이벤트 발행 → @EventListener가 즉시 실행 → 이메일 발송
eventPublisher.publishEvent(new OrderCreatedEvent(order.getId()));
// 여기서 예외 발생 → 트랜잭션 롤백
// 하지만 이메일은 이미 나간 상태!
validateSomething(order);
}
@TransactionalEventListener — 트랜잭션 완료 후 실행
@Component
public class OrderEventHandler {
@TransactionalEventListener
public void handleOrderCreated(OrderCreatedEvent event) {
// 트랜잭션이 커밋된 후에야 실행됨
emailService.sendOrderConfirmation(event.getOrderId());
}
}
@TransactionalEventListener는 기본적으로 트랜잭션이 커밋된 후에만 실행됩니다. 트랜잭션이 롤백되면 핸들러가 실행되지 않습니다.
@Transactional 메서드 실행
│
├─ 비즈니스 로직 실행
├─ publishEvent() — 이벤트를 등록만 해둠 (실행하지 않음)
├─ 나머지 로직 실행
│
▼
트랜잭션 커밋 시도
│
├─ 성공(커밋) → @TransactionalEventListener 실행
└─ 실패(롤백) → @TransactionalEventListener 실행 안 됨
TransactionPhase — 실행 시점 선택
@TransactionalEventListener의 phase 속성으로 실행 시점을 정할 수 있습니다.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) // 기본값
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
각 Phase의 특징
| Phase | 실행 시점 | 사용 시나리오 |
|---|---|---|
| BEFORE_COMMIT | 커밋 직전 | 커밋 전에 추가 검증이나 감사 로그 기록 |
| AFTER_COMMIT | 커밋 성공 후 (기본값) | 알림 발송, 캐시 갱신, 외부 시스템 연동 |
| AFTER_ROLLBACK | 롤백 후 | 실패 알림, 보상 처리 |
| AFTER_COMPLETION | 커밋/롤백 상관없이 완료 후 | 리소스 정리, 로깅 |
@Component
@RequiredArgsConstructor
public class OrderEventHandler {
private final EmailService emailService;
private final AuditLogService auditLogService;
private final AlertService alertService;
// 커밋 전: 감사 로그 기록 (같은 트랜잭션에서 실행)
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void auditBeforeCommit(OrderCreatedEvent event) {
auditLogService.record("ORDER_CREATED", event.getOrderId());
}
// 커밋 후: 이메일 발송
@TransactionalEventListener // phase = AFTER_COMMIT (기본값)
public void sendEmail(OrderCreatedEvent event) {
emailService.sendOrderConfirmation(event.getOrderId());
}
// 롤백 후: 실패 알림
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void handleRollback(OrderCreatedEvent event) {
alertService.notifyOrderFailed(event.getOrderId());
}
}
AFTER_COMMIT에서 DB 쓰기가 안전하지 않은 이유
공부하다 보니 이 부분에서 가장 많이 헷갈렸습니다. AFTER_COMMIT 핸들러에서 DB에 쓰려고 하면 문제가 발생합니다.
@TransactionalEventListener
public void handleOrderCreated(OrderCreatedEvent event) {
// 원래 트랜잭션은 이미 커밋됨!
// 여기서 save()를 하면?
Notification notification = Notification.create(event.getOrderId());
notificationRepository.save(notification); // 이게 안전할까?
}
AFTER_COMMIT 시점에서는 ** 원래 트랜잭션이 이미 커밋되어 닫힌 상태 **입니다.
- JPA를 사용하면 영속성 컨텍스트도 닫혀있습니다.
save()를 호출하면TransactionRequiredException이 발생하거나, 트랜잭션 없이 실행되어 예기치 않은 동작을 할 수 있습니다.
해결책: REQUIRES_NEW로 새 트랜잭션
@Component
@RequiredArgsConstructor
public class NotificationEventHandler {
private final NotificationService notificationService;
@TransactionalEventListener
public void handleOrderCreated(OrderCreatedEvent event) {
// NotificationService에서 새 트랜잭션으로 DB 쓰기
notificationService.createNotification(event.getOrderId());
}
}
@Service
@RequiredArgsConstructor
public class NotificationService {
private final NotificationRepository notificationRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW) // 새 트랜잭션
public void createNotification(Long orderId) {
Notification notification = Notification.create(orderId);
notificationRepository.save(notification);
}
}
REQUIRES_NEW가 핵심입니다. 원래 트랜잭션은 이미 끝났으므로 ** 새로운 트랜잭션을 열어야** DB 쓰기가 가능합니다.
ApplicationEventPublisher로 이벤트 발행
이벤트를 발행하는 쪽을 살펴보겠습니다.
이벤트 클래스 정의
// Spring 4.2+ 부터는 ApplicationEvent를 상속할 필요 없음
public record OrderCreatedEvent(
Long orderId,
Long userId,
BigDecimal totalAmount
) {}
Spring 4.2 이후로는 POJO만으로도 이벤트를 정의할 수 있습니다. ApplicationEvent를 상속할 필요가 없어졌습니다.
이벤트 발행
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public OrderDto createOrder(OrderCreateRequest request) {
Order order = Order.create(request);
orderRepository.save(order);
// 이벤트 발행 — 실제 실행은 트랜잭션 커밋 후
eventPublisher.publishEvent(new OrderCreatedEvent(
order.getId(),
order.getUserId(),
order.getTotalAmount()
));
return OrderDto.from(order);
}
}
publishEvent()를 호출하는 시점에는 이벤트가 ** 등록만** 됩니다. @TransactionalEventListener가 달린 핸들러는 트랜잭션 phase에 따라 나중에 실행됩니다.
@Async와의 결합 — 외부 API 호출 패턴
AFTER_COMMIT 핸들러는 기본적으로 ** 같은 스레드에서 동기적으로** 실행됩니다. 외부 API를 호출하는 경우 응답 시간이 길어질 수 있으므로, @Async와 결합하면 좋습니다.
설정
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("event-async-");
executor.initialize();
return executor;
}
}
비동기 이벤트 핸들러
@Component
@RequiredArgsConstructor
public class OrderNotificationHandler {
private final SlackClient slackClient;
private final EmailService emailService;
@Async
@TransactionalEventListener
public void sendNotifications(OrderCreatedEvent event) {
// 별도 스레드에서 비동기 실행
// 원래 트랜잭션은 이미 커밋된 상태 → 안전
slackClient.sendMessage("#orders",
"새 주문: " + event.orderId());
emailService.sendOrderConfirmation(event.userId(), event.orderId());
}
}
@Async + @TransactionalEventListener 조합의 흐름은 다음과 같습니다.
메인 스레드 비동기 스레드
│ │
├─ 비즈니스 로직 │
├─ publishEvent() │
├─ 트랜잭션 커밋 │
├─ 이벤트 → 비동기 스레드로 전달 ──→ │
├─ 응답 반환 (즉시) ├─ Slack 알림 발송
│ ├─ 이메일 발송
│ └─ 완료
주의할 점이 있습니다. @Async 핸들러에서 발생한 예외는 호출자에게 전파되지 않습니다. 실패 처리를 위해 별도의 에러 핸들링이 필요합니다.
@Async
@TransactionalEventListener
public void sendNotifications(OrderCreatedEvent event) {
try {
slackClient.sendMessage("#orders", "새 주문: " + event.orderId());
} catch (Exception e) {
// 로깅 + 재시도 큐에 넣기 등 별도 처리 필요
log.error("알림 발송 실패: orderId={}", event.orderId(), e);
retryQueue.enqueue(new NotificationRetry(event));
}
}
실무 패턴
1. 주문 완료 후 알림 발송
// 이벤트 정의
public record OrderCompletedEvent(
Long orderId,
Long userId,
String email,
BigDecimal amount
) {}
// 발행 측
@Service
@RequiredArgsConstructor
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
@Transactional
public void completeOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.complete();
eventPublisher.publishEvent(new OrderCompletedEvent(
order.getId(),
order.getUserId(),
order.getEmail(),
order.getTotalAmount()
));
}
}
// 처리 측 — 각 관심사별로 핸들러 분리
@Component
@RequiredArgsConstructor
public class OrderEmailHandler {
@Async
@TransactionalEventListener
public void sendEmail(OrderCompletedEvent event) {
emailService.send(event.email(), "주문 완료", "주문 " + event.orderId() + " 완료");
}
}
@Component
@RequiredArgsConstructor
public class OrderPointHandler {
private final PointService pointService;
@TransactionalEventListener
public void grantPoints(OrderCompletedEvent event) {
// DB 쓰기가 필요하므로 PointService에서 REQUIRES_NEW
pointService.grantOrderPoints(event.userId(), event.amount());
}
}
이 패턴의 장점은 OrderService가 알림, 포인트 같은 부수 효과를 알 필요가 없다 는 것입니다. 새로운 후속 처리가 생기면 핸들러만 추가하면 됩니다.
2. 결제 성공 후 포인트 적립
public record PaymentSucceededEvent(Long paymentId, Long userId, BigDecimal amount) {}
@Component
@RequiredArgsConstructor
public class PointAccumulationHandler {
private final PointService pointService;
@TransactionalEventListener
public void accumulatePoints(PaymentSucceededEvent event) {
// 결제 금액의 1% 포인트 적립
BigDecimal points = event.amount().multiply(BigDecimal.valueOf(0.01));
pointService.addPoints(event.userId(), points);
}
}
@Service
@RequiredArgsConstructor
public class PointService {
private final PointRepository pointRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void addPoints(Long userId, BigDecimal amount) {
PointHistory history = PointHistory.earn(userId, amount);
pointRepository.save(history);
// 포인트 잔액 업데이트
PointBalance balance = pointRepository.findBalanceByUserId(userId);
balance.add(amount);
}
}
Spring Framework 6.2+ — fallbackExecution
Spring Framework 6.2에서 추가된 fallbackExecution 속성은 중요한 변화입니다.
문제 상황
@TransactionalEventListener는 이름 그대로 트랜잭션이 있을 때만 동작합니다. 트랜잭션 없이 publishEvent()를 호출하면 핸들러가 ** 조용히 무시 **됩니다.
// 트랜잭션 없는 메서드
public void processWebhook(WebhookPayload payload) {
// 트랜잭션 없이 이벤트 발행
eventPublisher.publishEvent(new WebhookReceivedEvent(payload));
// → @TransactionalEventListener가 무시됨! 로그도 없음!
}
이건 실제로 디버깅하기 매우 어려운 버그입니다. 이벤트가 발행되었는데 핸들러가 실행되지 않고, 에러도 없으니 원인을 찾기가 힘듭니다.
fallbackExecution = true
@TransactionalEventListener(fallbackExecution = true)
public void handleEvent(WebhookReceivedEvent event) {
// 트랜잭션이 있으면 → AFTER_COMMIT에 실행
// 트랜잭션이 없으면 → 즉시 실행 (fallback)
processWebhook(event);
}
fallbackExecution = true로 설정하면 트랜잭션 컨텍스트가 없어도 이벤트가 즉시 실행됩니다.
| 상황 | fallbackExecution = false (기본) | fallbackExecution = true |
|---|---|---|
| 트랜잭션 있음 | phase에 따라 실행 | phase에 따라 실행 |
| 트랜잭션 없음 | ** 무시 (실행 안 됨)** | ** 즉시 실행** |
이 옵션이 유용한 경우는 다음과 같습니다.
- 웹훅이나 메시지 리스너처럼 트랜잭션 없이 이벤트가 발행되는 경우
- 테스트에서 트랜잭션 설정 없이 이벤트 핸들러를 검증하는 경우
- 하나의 핸들러가 트랜잭션 유무와 관계없이 동작해야 하는 경우
주의사항 정리
1. AFTER_COMMIT에서 DB 쓰기 시 REQUIRES_NEW 필수
원래 트랜잭션은 끝났으므로 새 트랜잭션이 필요합니다.
2. 이벤트 핸들러에서 예외가 발생하면
- ** 동기 실행 **: 예외가 발생하면 호출자에게 전파됩니다. 하지만 AFTER_COMMIT이라 ** 트랜잭션은 이미 커밋된 상태 **입니다. 예외가 던져져도 롤백할 수 없습니다.
- ** 비동기 실행 (@Async)**: 예외가 호출자에게 전파되지 않습니다. 별도 에러 핸들링이 필요합니다.
3. 이벤트 순서 보장
같은 phase에 여러 핸들러가 있을 때 실행 순서는 보장되지 않습니다. @Order 어노테이션으로 순서를 지정할 수 있습니다.
@Order(1)
@TransactionalEventListener
public void firstHandler(OrderCreatedEvent event) { }
@Order(2)
@TransactionalEventListener
public void secondHandler(OrderCreatedEvent event) { }
4. 테스트 시 주의
@TransactionalEventListener를 테스트하려면 실제로 트랜잭션이 커밋되어야 합니다. @Transactional이 붙은 테스트는 기본적으로 롤백하므로 AFTER_COMMIT 핸들러가 실행되지 않습니다.
// 이 테스트에서는 AFTER_COMMIT 핸들러가 실행 안 됨
@Test
@Transactional // 테스트 후 롤백 → AFTER_COMMIT 미실행
void testOrderCreated() {
orderService.createOrder(request);
// 핸들러 검증 실패
}
// 해결: @Transactional을 빼고 수동 정리
@Test
void testOrderCreated() {
OrderDto result = orderService.createOrder(request);
// AFTER_COMMIT 핸들러 실행됨
// 테스트 후 수동 정리 필요
}
정리
@EventListener는 즉시 실행,@TransactionalEventListener는 ** 트랜잭션 phase에 따라 실행 **됩니다.- 기본 phase는 AFTER_COMMIT — 트랜잭션 커밋 후에만 실행되어 롤백 시 이벤트가 무시됩니다.
- AFTER_COMMIT에서 DB 쓰기가 필요하면 REQUIRES_NEW로 새 트랜잭션 을 열어야 합니다.
- 외부 API 호출은 @Async + @TransactionalEventListener 조합으로 비동기 처리가 권장됩니다.
- Spring 6.2+의 fallbackExecution = true 는 트랜잭션 없이 발행된 이벤트도 처리할 수 있게 해줍니다.
- 이벤트 기반 아키텍처는 서비스 간 결합도를 낮추고, 후속 처리를 핸들러 추가만으로 확장 할 수 있게 합니다.