Spring Event 비동기 처리

Spring Event 시스템이란?

Spring의 이벤트 시스템은 컴포넌트 간 느슨한 결합(Loose Coupling)을 구현하는 핵심 메커니즘이다. 주문 완료 후 이메일 발송, 재고 차감, 알림 전송 같은 부수 효과(Side Effect)를 서비스 간 직접 의존 없이 처리할 수 있다. Spring 4.2부터 어노테이션 기반으로 대폭 간소화되었고, @TransactionalEventListener로 트랜잭션과 연동할 수 있게 되었다.

기본 구조: Event 발행과 수신

// 1. 이벤트 정의 — record로 간결하게
public record OrderCompletedEvent(
    Long orderId,
    Long userId,
    BigDecimal totalAmount,
    Instant occurredAt
) {
    public OrderCompletedEvent(Long orderId, Long userId, BigDecimal totalAmount) {
        this(orderId, userId, totalAmount, Instant.now());
    }
}

// 2. 이벤트 발행
@Service
@RequiredArgsConstructor
public class OrderService {
    private final ApplicationEventPublisher eventPublisher;
    private final OrderRepository orderRepository;

    @Transactional
    public Order completeOrder(Long orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
        order.complete();
        orderRepository.save(order);

        eventPublisher.publishEvent(new OrderCompletedEvent(
            order.getId(), order.getUserId(), order.getTotalAmount()
        ));
        return order;
    }
}

// 3. 이벤트 수신
@Component
@Slf4j
public class OrderEventHandler {

    @EventListener
    public void handleOrderCompleted(OrderCompletedEvent event) {
        log.info("주문 완료 이벤트 수신: orderId={}", event.orderId());
        // 이메일 발송, 알림 등
    }
}

@EventListener는 기본적으로 동기(Synchronous)로 실행된다. 발행자의 스레드에서 리스너가 바로 실행되며, 리스너에서 예외가 발생하면 발행자에게 전파된다.

@TransactionalEventListener 심화

트랜잭션 커밋 후에만 이벤트를 처리해야 하는 경우가 대부분이다. 주문이 롤백되었는데 이메일이 발송되면 안 되기 때문이다.

@Component
@RequiredArgsConstructor
public class NotificationEventHandler {
    private final EmailService emailService;
    private final SlackNotifier slackNotifier;

    // 트랜잭션 커밋 후 실행 (기본값)
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void sendOrderConfirmation(OrderCompletedEvent event) {
        emailService.sendOrderConfirmation(event.userId(), event.orderId());
    }

    // 롤백 후 실행 — 보상 트랜잭션, 알림 등
    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void handleOrderFailure(OrderCompletedEvent event) {
        slackNotifier.alert("주문 처리 실패: " + event.orderId());
    }

    // 트랜잭션 완료 후 (커밋/롤백 무관)
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
    public void cleanup(OrderCompletedEvent event) {
        // 리소스 정리
    }
}
TransactionPhase 실행 시점 사용 사례
AFTER_COMMIT 커밋 성공 후 이메일, 알림, 외부 API 호출
AFTER_ROLLBACK 롤백 후 실패 알림, 보상 처리
AFTER_COMPLETION 트랜잭션 종료 후 리소스 정리
BEFORE_COMMIT 커밋 직전 검증, 감사 로그 저장

비동기 이벤트 처리

이벤트 리스너가 무거운 작업(이메일 발송, 외부 API)을 수행할 때는 비동기로 전환해야 한다. 그렇지 않으면 API 응답 시간이 리스너 처리 시간만큼 늘어난다.

// 비동기 활성화
@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "eventExecutor")
    public Executor eventTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(8);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("event-");
        executor.setRejectedExecutionHandler(
            new ThreadPoolExecutor.CallerRunsPolicy()
        );
        executor.initialize();
        return executor;
    }
}

// 비동기 리스너
@Component
@Slf4j
public class AsyncOrderEventHandler {

    @Async("eventExecutor")
    @TransactionalEventListener
    public void handleOrderCompleted(OrderCompletedEvent event) {
        log.info("[{}] 비동기 처리 시작: orderId={}",
            Thread.currentThread().getName(), event.orderId());
        // 무거운 작업 수행
    }
}

주의: @Async + @TransactionalEventListener 조합에서 리스너의 예외는 발행자에게 전파되지 않는다. 반드시 리스너 내부에서 예외 처리를 해야 한다.

이벤트 리스너 조건부 실행

@Component
public class ConditionalEventHandler {

    // SpEL 조건: 금액이 100,000원 이상일 때만 실행
    @EventListener(condition = "#event.totalAmount.compareTo(T(java.math.BigDecimal).valueOf(100000)) >= 0")
    public void handleHighValueOrder(OrderCompletedEvent event) {
        // VIP 주문 특별 처리
    }

    // 여러 이벤트 타입 처리
    @EventListener({OrderCompletedEvent.class, OrderCancelledEvent.class})
    public void handleOrderStateChange(Object event) {
        // 주문 상태 변경 공통 처리
    }

    // 리스너에서 새 이벤트 발행 (체이닝)
    @EventListener
    public InventoryUpdatedEvent handleOrderForInventory(OrderCompletedEvent event) {
        // 재고 차감 후 새 이벤트 반환 → 자동 발행
        return new InventoryUpdatedEvent(event.orderId());
    }
}

AFTER_COMMIT의 함정과 해결

@TransactionalEventListener(AFTER_COMMIT)에서 DB를 조회하면 트랜잭션이 이미 종료된 상태라 LazyInitializationException이 발생할 수 있다. 또한 새로운 쓰기 작업은 별도 트랜잭션이 필요하다.

@Component
@RequiredArgsConstructor
public class AuditEventHandler {
    private final AuditLogRepository auditLogRepository;

    // ❌ 실패: AFTER_COMMIT에서 직접 save → 트랜잭션 없음
    @TransactionalEventListener
    public void auditWrong(OrderCompletedEvent event) {
        auditLogRepository.save(new AuditLog(event.orderId())); // 실패!
    }

    // ✅ 해결 1: REQUIRES_NEW 트랜잭션
    @TransactionalEventListener
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void auditCorrect(OrderCompletedEvent event) {
        auditLogRepository.save(new AuditLog(event.orderId()));
    }

    // ✅ 해결 2: TransactionTemplate 사용
    @TransactionalEventListener
    public void auditWithTemplate(OrderCompletedEvent event) {
        transactionTemplate.executeWithoutResult(status ->
            auditLogRepository.save(new AuditLog(event.orderId()))
        );
    }
}

이 패턴은 Spring Outbox 패턴에서 이벤트 저장 시 자주 사용된다.

이벤트 실행 순서 제어

@Component
public class OrderedEventHandlers {

    @EventListener
    @Order(1)  // 먼저 실행
    public void validateFirst(OrderCompletedEvent event) {
        // 검증 로직
    }

    @EventListener
    @Order(2)  // 두 번째 실행
    public void processSecond(OrderCompletedEvent event) {
        // 처리 로직
    }

    @EventListener
    @Order(3)  // 마지막 실행
    public void notifyLast(OrderCompletedEvent event) {
        // 알림 발송
    }
}

@Order 값이 작을수록 먼저 실행된다. 동기 리스너에서만 순서가 보장되며, @Async와 함께 사용하면 순서를 보장할 수 없다.

테스트 전략

@SpringBootTest
class OrderEventTest {

    @Autowired
    private ApplicationEventPublisher eventPublisher;

    @MockitoBean
    private EmailService emailService;

    @Autowired
    private OrderService orderService;

    @Test
    void 주문_완료시_이메일_발송() {
        // given
        Order order = createTestOrder();

        // when
        orderService.completeOrder(order.getId());

        // then — @TransactionalEventListener는 트랜잭션 커밋 후 실행
        verify(emailService, timeout(3000))
            .sendOrderConfirmation(anyLong(), eq(order.getId()));
    }

    @Test
    void 이벤트_직접_발행_테스트() {
        // 이벤트만 독립적으로 테스트
        var event = new OrderCompletedEvent(1L, 100L, BigDecimal.valueOf(50000));
        eventPublisher.publishEvent(event);

        verify(emailService).sendOrderConfirmation(100L, 1L);
    }
}

비동기 리스너 테스트 시 timeout()을 사용하거나 Spring Test Slice 기법으로 동기 모드로 전환하여 테스트할 수 있다.

실무 설계 가이드라인

Spring Event는 단일 JVM 내부에서 동작한다. 분산 환경에서는 Kafka, RabbitMQ 같은 메시지 브로커가 필요하다. 적용 기준:

  • Spring Event 적합: 모놀리식, 트랜잭션 연동 부수효과, 도메인 이벤트
  • 메시지 브로커 필요: 서비스 간 통신, 재시도/DLQ 필요, 이벤트 영속화
  • 이벤트 클래스는 불변(Immutable)으로 설계 — Java record 권장
  • 리스너에서 발행자의 도메인 객체를 직접 참조하지 말 것 — ID만 전달
  • @Async 리스너에는 반드시 예외 처리와 재시도 로직 포함
위로 스크롤
WordPress Appliance - Powered by TurnKey Linux