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리스너에는 반드시 예외 처리와 재시도 로직 포함