Spring Event 시스템이란?
Spring의 이벤트 시스템은 컴포넌트 간 느슨한 결합(loose coupling)을 구현하는 핵심 메커니즘입니다. 주문 서비스가 결제·알림·재고 서비스를 직접 호출하는 대신, “주문 완료” 이벤트를 발행하면 각 서비스가 독립적으로 반응합니다. ApplicationEventPublisher로 이벤트를 발행하고, @EventListener로 수신하는 Observer 패턴의 Spring 구현체입니다.
기본 구조: 이벤트 정의와 발행
Spring 4.2부터 이벤트 클래스가 ApplicationEvent를 상속할 필요가 없습니다. 일반 POJO로 충분합니다.
// 이벤트 정의 - record 활용 (Java 17+)
public record OrderCompletedEvent(
Long orderId,
Long userId,
BigDecimal totalAmount,
LocalDateTime completedAt
) {}
// 이벤트 발행
@Service
@RequiredArgsConstructor
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
@Transactional
public Order completeOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new EntityNotFoundException("Order not found"));
order.complete();
orderRepository.save(order);
// 이벤트 발행 — 트랜잭션 내부
eventPublisher.publishEvent(new OrderCompletedEvent(
order.getId(),
order.getUserId(),
order.getTotalAmount(),
LocalDateTime.now()
));
return order;
}
}
Java 17의 record는 이벤트 정의에 이상적입니다. 불변(immutable)이고, equals/hashCode/toString이 자동 생성되어 이벤트의 값 객체(Value Object) 특성과 완벽히 맞습니다.
@EventListener vs @TransactionalEventListener
이벤트 리스너의 실행 시점을 제어하는 것이 Spring Event 시스템의 핵심입니다.
@Component
@Slf4j
public class OrderEventHandler {
// 1. 즉시 실행 — 발행 시점에 동기적으로 실행
@EventListener
public void handleImmediate(OrderCompletedEvent event) {
log.info("즉시 처리: 주문 #{}", event.orderId());
// 발행자와 같은 트랜잭션에서 실행됨!
// 여기서 예외 발생 → 발행자 트랜잭션도 롤백
}
// 2. 트랜잭션 커밋 후 실행 (기본값: AFTER_COMMIT)
@TransactionalEventListener
public void handleAfterCommit(OrderCompletedEvent event) {
log.info("커밋 후 처리: 알림 발송 #{}", event.orderId());
// DB에 확실히 반영된 후 실행
// 여기서 예외 발생해도 발행자 트랜잭션은 이미 커밋됨
}
// 3. 롤백 후 실행
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void handleAfterRollback(OrderCompletedEvent event) {
log.warn("롤백 감지: 주문 #{} 실패 알림", event.orderId());
// 보상 트랜잭션, 실패 알림 등
}
// 4. 트랜잭션 완료 후 (커밋/롤백 무관)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
public void handleAfterCompletion(OrderCompletedEvent event) {
log.info("완료 후 리소스 정리: #{}", event.orderId());
}
}
| 어노테이션 | 실행 시점 | 트랜잭션 공유 | 용도 |
|---|---|---|---|
@EventListener |
즉시 (동기) | 발행자와 동일 | 데이터 보강, 검증 |
AFTER_COMMIT |
커밋 후 | 별도 | 알림, 외부 API |
AFTER_ROLLBACK |
롤백 후 | 별도 | 보상 처리, 실패 알림 |
AFTER_COMPLETION |
완료 후 | 별도 | 리소스 정리 |
핵심 규칙: 외부 시스템 호출(이메일, 메시지큐, 웹훅)은 반드시 @TransactionalEventListener(AFTER_COMMIT)를 사용하세요. @EventListener로 이메일을 보내면 트랜잭션 롤백 시 “보내졌지만 DB에는 없는” 유령 알림이 발생합니다.
비동기 이벤트 처리
이벤트 리스너를 비동기로 실행하면 발행자의 응답 시간에 영향을 주지 않습니다.
// 1. 비동기 설정
@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-");
executor.setRejectedExecutionHandler(new CallerRunsPolicy());
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
log.error("비동기 이벤트 실패: {} - {}", method.getName(), ex.getMessage(), ex);
}
}
// 2. 비동기 리스너
@Component
public class NotificationHandler {
@Async
@TransactionalEventListener
public void sendNotification(OrderCompletedEvent event) {
// 별도 스레드에서 실행 — 발행자 응답 시간에 영향 없음
emailService.send(event.userId(), "주문 완료", "...");
pushService.send(event.userId(), "주문이 완료되었습니다");
}
}
주의: @Async + @TransactionalEventListener 조합에서 리스너 내부에서 새 트랜잭션이 필요하면 @Transactional(propagation = REQUIRES_NEW)를 명시해야 합니다. 비동기 스레드에는 원래 트랜잭션 컨텍스트가 전파되지 않습니다.
이벤트 체이닝과 리턴 타입
@EventListener가 값을 리턴하면 그 값이 새로운 이벤트로 발행됩니다.
public record PaymentProcessedEvent(Long orderId, String paymentId) {}
public record InventoryReservedEvent(Long orderId, List<String> skus) {}
public record ShipmentCreatedEvent(Long orderId, String trackingNumber) {}
@Component
public class OrderSaga {
@EventListener
public InventoryReservedEvent onPaymentProcessed(PaymentProcessedEvent event) {
List<String> reserved = inventoryService.reserve(event.orderId());
return new InventoryReservedEvent(event.orderId(), reserved);
// → 자동으로 InventoryReservedEvent 발행
}
@EventListener
public ShipmentCreatedEvent onInventoryReserved(InventoryReservedEvent event) {
String tracking = shipmentService.create(event.orderId(), event.skus());
return new ShipmentCreatedEvent(event.orderId(), tracking);
// → 자동으로 ShipmentCreatedEvent 발행
}
// 여러 이벤트를 동시에 발행할 때
@EventListener
public Collection<?> onOrderCompleted(OrderCompletedEvent event) {
return List.of(
new PaymentProcessedEvent(event.orderId(), "PAY-123"),
new AuditLogEvent("ORDER_COMPLETED", event.orderId())
);
}
}
이벤트 체이닝은 Saga 패턴을 단순하게 구현할 수 있는 강력한 기능입니다. 하지만 체인이 길어지면 디버깅이 어려워지므로, 3단계 이상의 체이닝은 명시적인 오케스트레이터로 전환하는 것을 권장합니다.
조건부 리스닝과 SpEL
@EventListener의 condition 속성으로 특정 조건에서만 리스너를 실행할 수 있습니다.
@Component
public class ConditionalHandler {
// 금액이 100,000원 이상인 주문만 처리
@EventListener(condition = "#event.totalAmount.compareTo(T(java.math.BigDecimal).valueOf(100000)) >= 0")
public void handleHighValueOrder(OrderCompletedEvent event) {
fraudDetectionService.check(event.orderId());
vipNotificationService.notify(event.userId());
}
// 특정 사용자만 처리
@EventListener(condition = "#event.userId == 1L")
public void handleAdminOrder(OrderCompletedEvent event) {
log.info("관리자 주문 감지");
}
// 여러 이벤트 타입을 하나의 리스너로
@EventListener({OrderCompletedEvent.class, OrderCancelledEvent.class})
public void handleOrderStateChange(Object event) {
auditService.log(event);
}
}
SpEL 조건은 간단한 필터링에 적합합니다. 복잡한 비즈니스 로직은 리스너 메서드 내부에서 처리하는 것이 가독성과 테스트 용이성 면에서 더 좋습니다. 이벤트 기반 아키텍처는 Spring CompletableFuture 병렬 처리와 조합하면 비동기 파이프라인을 더 효과적으로 구성할 수 있습니다.
테스트 전략
이벤트 기반 코드의 테스트는 발행·수신을 분리해서 검증합니다.
@SpringBootTest
class OrderEventTest {
@Autowired ApplicationEventPublisher publisher;
@MockBean EmailService emailService;
@Autowired OrderEventHandler handler;
// 1. 이벤트 발행 검증
@Test
void shouldPublishEvent(@Autowired ApplicationEvents events) {
orderService.completeOrder(1L);
assertThat(events.stream(OrderCompletedEvent.class)).hasSize(1);
assertThat(events.stream(OrderCompletedEvent.class).findFirst().get().orderId())
.isEqualTo(1L);
}
// 2. 리스너 단위 테스트 (이벤트 시스템 없이)
@Test
void handlerSendsEmail() {
var event = new OrderCompletedEvent(1L, 100L, BigDecimal.TEN, LocalDateTime.now());
handler.handleAfterCommit(event);
verify(emailService).send(eq(100L), anyString(), anyString());
}
// 3. 비동기 리스너 테스트
@Test
void asyncListenerCompletes() {
publisher.publishEvent(new OrderCompletedEvent(1L, 100L, BigDecimal.TEN, LocalDateTime.now()));
await().atMost(Duration.ofSeconds(5))
.untilAsserted(() -> verify(emailService).send(eq(100L), anyString(), anyString()));
}
}
Spring 6의 ApplicationEvents는 테스트에서 발행된 이벤트를 캡처하는 공식 API입니다. Spring WireMock API 테스트와 함께 사용하면 이벤트 리스너가 외부 API를 호출하는 시나리오도 완전히 테스트할 수 있습니다.
운영 베스트 프랙티스
| 항목 | 권장 사항 |
|---|---|
| 이벤트 설계 | 불변 record 사용, 필요한 데이터만 포함 |
| 외부 호출 | 반드시 AFTER_COMMIT + @Async |
| 에러 처리 | 비동기 리스너에 AsyncUncaughtExceptionHandler 설정 |
| 순서 보장 | @Order 어노테이션으로 리스너 실행 순서 지정 |
| 모니터링 | 이벤트 발행/처리 메트릭을 Micrometer로 추적 |
| 규모 확장 | 프로세스 간 이벤트가 필요하면 Kafka/RabbitMQ로 전환 |
정리
Spring Event 시스템은 모놀리스 내부에서 도메인 이벤트를 구현하는 가장 간결한 방법입니다. @EventListener는 동기 처리에, @TransactionalEventListener는 트랜잭션 경계를 넘는 처리에 사용합니다. 비동기 처리 시 스레드 풀 설정과 예외 핸들러를 반드시 구성하고, 이벤트 체이닝은 3단계 이내로 유지하세요. 규모가 커지면 메시지 브로커로 자연스럽게 전환할 수 있는 것이 이벤트 기반 설계의 최대 장점입니다.