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