Spring의 @EventListener는 간단해 보이지만, 실무에서 여러 함정이 있다.
트랜잭션 타이밍, 예외 전파, 실행 순서 등 자주 발생하는 문제들을 정리했다.
트랜잭션 주의
문제: 커밋 전 실행
@Transactional 메소드 내에서 이벤트 발행 시, @EventListener는 트랜잭션 커밋 전에 실행된다.
// ❌ 위험: 트랜잭션 커밋 전에 이벤트 처리됨@Servicepublic class OrderService { @Transactional public void createOrder(Order order) { orderRepository.save(order); eventPublisher.publishEvent(new OrderCreated(order.getId())); // 이벤트가 지금 발행됨 — 하지만 트랜잭션은 아직 커밋되지 않음! }}@Componentpublic class EmailListener { @EventListener public void sendEmail(OrderCreated event) { // 트랜잭션 커밋 전에 실행됨 // 주문 조회 시 데이터가 없을 수 있음! Order order = orderRepository.findById(event.getOrderId()); // null일 수 있음! }}
해결책: @TransactionalEventListener
@Componentpublic class EmailListener { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void sendEmail(OrderCreated event) { // 트랜잭션 커밋 후에만 실행됨 Order order = orderRepository.findById(event.getOrderId()); // 반드시 존재 }}
TransactionPhase 옵션:
AFTER_COMMIT (기본값): 커밋 성공 후 실행
AFTER_ROLLBACK: 롤백 후 실행
AFTER_COMPLETION: 커밋/롤백 상관없이 완료 후 실행
BEFORE_COMMIT: 커밋 직전 실행
동기 이벤트의 예외 전파
문제: 리스너 예외가 발행자 트랜잭션을 롤백
@EventListener는 기본적으로 동기 실행되어 예외가 발행자에게 전파된다.
@Servicepublic class OrderService { @Transactional public void createOrder(Order order) { orderRepository.save(order); eventPublisher.publishEvent(new OrderCreated(order.getId())); // 리스너에서 예외 발생 시 여기까지 롤백됨! }}@Componentpublic class NotificationListener { @EventListener public void sendEmail(OrderCreated event) { emailService.send(...); // 외부 서비스 호출 실패 시 // OrderService의 트랜잭션도 롤백됨! }}
트랜잭션 컨텍스트가 전파되지 않으므로, 리스너 내부에서 DB 작업은 새 트랜잭션으로 시작된다.
@EventListener@Asyncpublic void handle(OrderCreated event) { // 여기서 호출하는 @Transactional 메서드는 새 트랜잭션 orderService.updateStatus(event.getOrderId());}
해결책 2: 트랜잭션 커밋 후 실행 (권장)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)public void sendEmail(OrderCreated event) { // 이미 커밋된 후라 예외가 트랜잭션 롤백을 유발하지 않음 // 단, 예외는 여전히 caller에게 전파됨 (에러 응답, 로깅 등 영향) // 동기 실행이므로 응답 시간에도 영향}
예외 전파를 완전히 차단하려면 @Async와 함께 사용하거나 try-catch로 감싸야 한다.
이벤트 순서 의존
문제: 실행 순서가 보장되지 않음
리스너가 특정 순서로 실행된다고 가정하면 문제가 생긴다.
// ❌ 위험: 실행 순서가 보장되지 않음!@EventListener@Order(1)public void saveToDatabase(OrderCreated event) { orderRepository.save(event.toOrder());}@EventListener@Order(2) // @Order가 있어도 다른 Bean의 순서는 보장 안됨public void sendNotification(OrderCreated event) { // DB에 저장된 후라고 가정하면 위험! Order order = orderRepository.findById(event.orderId()); // null일 수 있음!}
@Order는 같은 Bean 내 에서만 의미가 있다.
다른 Bean 간의 순서는 Spring의 Bean 로딩 순서에 따라 달라질 수 있다.
해결책 1: 단일 리스너에서 순차 처리
@EventListenerpublic void handleOrderCreated(OrderCreated event) { Order order = orderRepository.save(event.toOrder()); notificationService.send(order);}
해결책 2: 이벤트 체이닝
@EventListenerpublic void saveOrder(OrderCreated event) { Order order = orderRepository.save(event.toOrder()); eventPublisher.publish(new OrderPersisted(order.getId()));}@EventListenerpublic void sendNotification(OrderPersisted event) { // 이제 순서가 보장됨 Order order = orderRepository.findById(event.orderId()); notificationService.send(order);}
해결책 3: SmartApplicationListener
순서가 정말 중요하면 SmartApplicationListener를 구현한다:
@Componentpublic class OrderedEmailListener implements SmartApplicationListener { @Override public int getOrder() { return 100; // 낮을수록 먼저 실행 } @Override public boolean supportsEventType(Class<? extends ApplicationEvent> eventType) { return OrderCreated.class.isAssignableFrom(eventType); } @Override public void onApplicationEvent(ApplicationEvent event) { // ... }}
이벤트 루프 (무한 발행)
문제: A → B → A 순환
이벤트 핸들러가 다시 원래 이벤트를 발행하면 무한 루프가 발생한다.
// ❌ 무한 루프!@EventListenerpublic void onOrderCreated(OrderCreated event) { inventoryService.reserve(event.orderId()); eventPublisher.publish(new InventoryReserved(event.orderId()));}@EventListenerpublic void onInventoryReserved(InventoryReserved event) { // 비즈니스 로직 실수로 OrderCreated 다시 발행 eventPublisher.publish(new OrderCreated(event.orderId()));}
public class OrderSaga { private OrderSagaState state = OrderSagaState.CREATED; public void handle(OrderCreated event) { if (state != OrderSagaState.CREATED) return; // ... state = OrderSagaState.INVENTORY_RESERVED; } public void handle(PaymentCompleted event) { if (state != OrderSagaState.INVENTORY_RESERVED) return; // ... state = OrderSagaState.COMPLETED; }}
마치며
미묘한 차이들을 알지 못하는경우 이벤트가 유실되는 상황이 발생할수 있다.
충분히 합의하고 진행하는것이 좋다.