Spring Event 비동기 처리 심화

Spring Event 비동기 처리가 중요한 이유

Spring의 이벤트 시스템은 컴포넌트 간 느슨한 결합(Loose Coupling)을 구현하는 핵심 메커니즘입니다. 주문 완료 후 이메일 발송, 재고 차감, 포인트 적립 같은 부가 로직을 서비스에 직접 호출하면 의존성이 폭발합니다. 이벤트 기반 아키텍처로 전환하면 각 관심사를 독립적으로 처리할 수 있습니다.

ApplicationEvent와 EventPublisher 기본 구조

Spring 4.2 이전에는 ApplicationEvent를 상속해야 했지만, 현재는 POJO 이벤트를 그대로 발행할 수 있습니다.

// 이벤트 정의 - 단순 POJO
public record OrderCompletedEvent(
    Long orderId,
    Long userId,
    BigDecimal totalAmount,
    LocalDateTime completedAt
) {}

// 이벤트 발행
@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(),
            LocalDateTime.now()
        ));

        return order;
    }
}

이벤트 발행은 ApplicationEventPublisher.publishEvent() 한 줄로 끝납니다. Spring이 등록된 모든 리스너에게 자동으로 전달합니다.

@EventListener vs @TransactionalEventListener

이벤트 리스너는 두 가지 방식으로 등록합니다. 차이를 정확히 이해해야 데이터 정합성 문제를 방지할 수 있습니다.

@EventListener — 즉시 실행

@Component
@Slf4j
public class OrderEventListener {

    @EventListener
    public void handleOrderCompleted(OrderCompletedEvent event) {
        log.info("주문 완료 이벤트 수신: orderId={}", event.orderId());
        // 이 시점에 트랜잭션이 아직 커밋되지 않았을 수 있음!
    }
}

@EventListener는 이벤트 발행 시점에 동기적으로 즉시 실행됩니다. 발행자의 트랜잭션 컨텍스트 안에서 동작하므로, 리스너에서 예외가 발생하면 발행자 트랜잭션도 롤백됩니다.

@TransactionalEventListener — 트랜잭션 커밋 후 실행

@Component
@Slf4j
public class OrderNotificationListener {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void sendOrderConfirmation(OrderCompletedEvent event) {
        // 트랜잭션 커밋이 확정된 후에만 실행
        log.info("주문 확인 메일 발송: orderId={}", event.orderId());
        emailService.sendOrderConfirmation(event.orderId());
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void handleOrderFailure(OrderCompletedEvent event) {
        // 롤백 시에만 실행
        log.warn("주문 처리 실패 알림: orderId={}", event.orderId());
    }
}
속성 @EventListener @TransactionalEventListener
실행 시점 publishEvent() 호출 즉시 트랜잭션 phase에 따라
트랜잭션 영향 발행자 트랜잭션 공유 독립적 (AFTER_COMMIT 기준)
예외 전파 발행자에게 전파됨 전파되지 않음 (커밋 후)
사용 사례 동일 트랜잭션 내 작업 알림, 외부 API 호출

@Async로 비동기 이벤트 처리

기본적으로 Spring 이벤트는 동기 실행입니다. 이메일 발송이나 외부 API 호출처럼 시간이 걸리는 작업은 비동기로 처리해야 응답 지연을 방지할 수 있습니다.

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

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("event-async-");
        executor.setRejectedExecutionHandler(new CallerRunsPolicy());
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) ->
            log.error("비동기 이벤트 처리 실패: method={}", method.getName(), ex);
    }
}

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

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleInventoryDeduction(OrderCompletedEvent event) {
        // 별도 스레드에서 비동기 실행
        inventoryService.deductStock(event.orderId());
    }

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handlePointAccumulation(OrderCompletedEvent event) {
        // 병렬로 포인트 적립 처리
        pointService.accumulate(event.userId(), event.totalAmount());
    }
}

핵심 포인트: @Async@TransactionalEventListener를 조합하면 트랜잭션 커밋 확정 후 별도 스레드에서 비동기 실행됩니다. 이것이 실무에서 가장 많이 쓰이는 패턴입니다.

이벤트 순서 제어: @Order

동일 이벤트에 여러 리스너가 등록된 경우, @Order로 실행 순서를 제어할 수 있습니다.

@Component
public class OrderEventHandlers {

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

    @EventListener
    @Order(2)  // 나중에 실행
    public void processOrder(OrderCompletedEvent event) {
        // 처리 로직
    }
}

이벤트 필터링: condition 속성

SpEL 표현식으로 특정 조건의 이벤트만 처리할 수 있습니다.

@EventListener(condition = "#event.totalAmount.compareTo(new java.math.BigDecimal('100000')) > 0")
public void handleHighValueOrder(OrderCompletedEvent event) {
    // 10만원 이상 주문만 VIP 알림
    vipNotificationService.notifyHighValueOrder(event);
}

커스텀 이벤트 계층 구조 설계

실무에서는 이벤트를 도메인별 계층 구조로 설계합니다. sealed interface를 활용하면 타입 안전성을 확보할 수 있습니다.

// 이벤트 계층 구조
public sealed interface OrderEvent permits
    OrderCreatedEvent, OrderCompletedEvent, OrderCancelledEvent {
    Long orderId();
    LocalDateTime occurredAt();
}

public record OrderCreatedEvent(
    Long orderId, Long userId, LocalDateTime occurredAt
) implements OrderEvent {}

public record OrderCompletedEvent(
    Long orderId, Long userId, BigDecimal totalAmount, LocalDateTime occurredAt
) implements OrderEvent {}

public record OrderCancelledEvent(
    Long orderId, String reason, LocalDateTime occurredAt
) implements OrderEvent {}

// 모든 주문 이벤트를 한 리스너에서 처리
@EventListener
public void handleAllOrderEvents(OrderEvent event) {
    switch (event) {
        case OrderCreatedEvent e -> auditLog.created(e.orderId());
        case OrderCompletedEvent e -> auditLog.completed(e.orderId());
        case OrderCancelledEvent e -> auditLog.cancelled(e.orderId(), e.reason());
    }
}

이벤트 유실 방지: Transactional Outbox 패턴

@TransactionalEventListener(AFTER_COMMIT)는 커밋 후 애플리케이션이 죽으면 이벤트가 유실됩니다. 이를 방지하려면 Outbox 패턴을 적용합니다.

@Entity
@Table(name = "outbox_events")
public class OutboxEvent {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String aggregateType;  // "Order"

    @Column(nullable = false)
    private String eventType;      // "OrderCompleted"

    @Column(columnDefinition = "JSON")
    private String payload;

    @Column(nullable = false)
    private LocalDateTime createdAt;

    @Column(nullable = false)
    private boolean processed = false;
}

// 발행 시 DB에 함께 저장
@Transactional
public Order completeOrder(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    order.complete();
    orderRepository.save(order);

    // 같은 트랜잭션에서 outbox에 저장 → 원자성 보장
    outboxRepository.save(new OutboxEvent(
        "Order", "OrderCompleted",
        objectMapper.writeValueAsString(new OrderCompletedEvent(...)),
        LocalDateTime.now()
    ));

    return order;
}

// 스케줄러로 미처리 이벤트 발행
@Scheduled(fixedDelay = 5000)
@Transactional
public void publishPendingEvents() {
    List<OutboxEvent> events = outboxRepository
        .findByProcessedFalseOrderByCreatedAt();

    for (OutboxEvent event : events) {
        eventPublisher.publishEvent(
            deserializeEvent(event.getEventType(), event.getPayload())
        );
        event.setProcessed(true);
    }
}

이 패턴은 Spring Modulith 이벤트 외부화에서 프레임워크 레벨로 지원됩니다.

테스트: 이벤트 발행 검증

Spring Boot 3.1+에서는 ApplicationEvents를 활용한 이벤트 테스트가 가능합니다.

@SpringBootTest
@RecordApplicationEvents  // 이벤트 기록 활성화
class OrderServiceTest {

    @Autowired
    private ApplicationEvents events;

    @Autowired
    private OrderService orderService;

    @Test
    void 주문_완료시_이벤트가_발행된다() {
        // when
        orderService.completeOrder(1L);

        // then
        long count = events.stream(OrderCompletedEvent.class).count();
        assertThat(count).isEqualTo(1);

        OrderCompletedEvent event = events.stream(OrderCompletedEvent.class)
            .findFirst().orElseThrow();
        assertThat(event.orderId()).isEqualTo(1L);
    }
}

성능 최적화 팁

이벤트 시스템을 운영 환경에서 사용할 때 주의할 점입니다.

  • 스레드 풀 분리: 이벤트 처리용 전용 Executor를 설정하여 HTTP 요청 스레드와 격리
  • 리스너 예외 처리: @Async 리스너의 예외는 기본적으로 무시되므로 반드시 AsyncUncaughtExceptionHandler 구현
  • 이벤트 직렬화: 이벤트 객체에 Entity 전체를 넣지 말고 ID만 전달 → 리스너에서 필요 시 조회
  • 순환 이벤트 방지: 리스너에서 다시 이벤트를 발행하면 무한 루프 위험 → 이벤트 체인 깊이를 제한

Spring 이벤트 시스템은 Spring Modulith와 결합하면 모듈 간 통신의 표준이 됩니다. 동기/비동기 선택, 트랜잭션 바인딩, 이벤트 유실 방지까지 정확히 이해하면 확장 가능한 아키텍처를 설계할 수 있습니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux