Back to posts

Spring @EventListener 주의사항

트랜잭션 타이밍, 예외 전파, 실행 순서 등 Spring 이벤트 리스너에서 자주 발생하는 문제들과 해결 방법을 정리했다.


들어가며

Spring의 @EventListener는 간단해 보이지만, 실무에서 여러 함정이 있다. 트랜잭션 타이밍, 예외 전파, 실행 순서 등 자주 발생하는 문제들을 정리했다.

트랜잭션 주의

문제: 커밋 전 실행

@Transactional 메소드 내에서 이벤트 발행 시, @EventListener는 트랜잭션 커밋 전에 실행된다.

// ❌ 위험: 트랜잭션 커밋 전에 이벤트 처리됨
@Service
public class OrderService {
    @Transactional
    public void createOrder(Order order) {
        orderRepository.save(order);
        eventPublisher.publishEvent(new OrderCreated(order.getId()));
        // 이벤트가 지금 발행됨 — 하지만 트랜잭션은 아직 커밋되지 않음!
    }
}
 
@Component
public class EmailListener {
    @EventListener
    public void sendEmail(OrderCreated event) {
        // 트랜잭션 커밋 전에 실행됨
        // 주문 조회 시 데이터가 없을 수 있음!
        Order order = orderRepository.findById(event.getOrderId()); // null일 수 있음!
    }
}

해결책: @TransactionalEventListener

@Component
public 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는 기본적으로 동기 실행되어 예외가 발행자에게 전파된다.

@Service
public class OrderService {
    @Transactional
    public void createOrder(Order order) {
        orderRepository.save(order);
        eventPublisher.publishEvent(new OrderCreated(order.getId()));
        // 리스너에서 예외 발생 시 여기까지 롤백됨!
    }
}
 
@Component
public class NotificationListener {
    @EventListener
    public void sendEmail(OrderCreated event) {
        emailService.send(...); // 외부 서비스 호출 실패 시
        // OrderService의 트랜잭션도 롤백됨!
    }
}

이메일 발송 실패 때문에 주문 생성이 롤백되는 건 대부분 원하는 동작이 아니다.

해결책 1: 비동기 처리 (권장)

@EventListener
@Async
public void sendEmail(OrderCreated event) {
    emailService.send(...);
    // 예외가 발생해도 발행자에게 전파되지 않음
}

주의: @Async는 새 스레드에서 실행된다

트랜잭션 컨텍스트가 전파되지 않으므로, 리스너 내부에서 DB 작업은 새 트랜잭션으로 시작된다.

@EventListener
@Async
public 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: 단일 리스너에서 순차 처리

@EventListener
public void handleOrderCreated(OrderCreated event) {
    Order order = orderRepository.save(event.toOrder());
    notificationService.send(order);
}

해결책 2: 이벤트 체이닝

@EventListener
public void saveOrder(OrderCreated event) {
    Order order = orderRepository.save(event.toOrder());
    eventPublisher.publish(new OrderPersisted(order.getId()));
}
 
@EventListener
public void sendNotification(OrderPersisted event) {
    // 이제 순서가 보장됨
    Order order = orderRepository.findById(event.orderId());
    notificationService.send(order);
}

해결책 3: SmartApplicationListener

순서가 정말 중요하면 SmartApplicationListener를 구현한다:

@Component
public 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 순환

이벤트 핸들러가 다시 원래 이벤트를 발행하면 무한 루프가 발생한다.

// ❌ 무한 루프!
@EventListener
public void onOrderCreated(OrderCreated event) {
    inventoryService.reserve(event.orderId());
    eventPublisher.publish(new InventoryReserved(event.orderId()));
}
 
@EventListener
public void onInventoryReserved(InventoryReserved event) {
    // 비즈니스 로직 실수로 OrderCreated 다시 발행
    eventPublisher.publish(new OrderCreated(event.orderId()));
}

해결책 1: 이벤트 흐름 다이어그램

발행/구독 관계를 시각화해서 순환을 미리 발견한다.

OrderCreated → InventoryReserved → PaymentRequested → ...
                    ↓
              (절대 OrderCreated로 돌아가지 않음)

해결책 3: Saga 패턴

복잡한 이벤트 흐름은 Saga로 명시적인 상태 머신을 만든다

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;
    }
}

마치며

미묘한 차이들을 알지 못하는경우 이벤트가 유실되는 상황이 발생할수 있다. 충분히 합의하고 진행하는것이 좋다.

참고 자료

문서내용
Spring Application Events공식 문서
Transaction-bound Events@TransactionalEventListener 상세
Spring @Async비동기 처리