Spring Events란? — 서비스 간 결합을 끊는 내부 메시징
주문이 완료되면 재고를 차감하고, 이메일을 보내고, 포인트를 적립해야 한다. 이 로직을 OrderService에 전부 넣으면 주문 서비스가 재고·알림·포인트 서비스에 강하게 결합된다. Spring Application Events는 이벤트 발행-구독(pub-sub) 패턴으로 서비스 간 결합을 끊고, 관심사를 분리하는 메커니즘이다.
ApplicationEventPublisher로 이벤트를 발행하면, @EventListener를 선언한 모든 리스너가 자동으로 호출된다. 같은 JVM 내부의 동기/비동기 이벤트 시스템이며, 외부 메시지 브로커(Kafka, RabbitMQ) 없이도 도메인 이벤트를 구현할 수 있다.
1. 기본 구조 — 이벤트 정의·발행·수신
1-1. 이벤트 클래스 정의
// Spring 4.2+ 부터 POJO면 충분 (ApplicationEvent 상속 불필요)
public record OrderCompletedEvent(
String orderId,
String customerId,
long totalAmount,
List<String> productIds,
Instant completedAt
) {}
public record OrderCancelledEvent(
String orderId,
String customerId,
String reason,
Instant cancelledAt
) {}
// 공통 베이스가 필요하면 sealed interface
public sealed interface OrderEvent permits OrderCompletedEvent, OrderCancelledEvent {
String orderId();
String customerId();
Instant timestamp();
}
1-2. 이벤트 발행
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public Order completeOrder(String orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new NotFoundException(orderId));
order.complete();
orderRepository.save(order);
// 이벤트 발행 — OrderService는 후속 처리를 모른다
eventPublisher.publishEvent(new OrderCompletedEvent(
order.getId(),
order.getCustomerId(),
order.getTotalAmount(),
order.getProductIds(),
Instant.now()
));
return order;
}
}
1-3. 이벤트 수신 — @EventListener
// 재고 서비스 — 주문 완료 시 재고 차감
@Component
@RequiredArgsConstructor
public class InventoryEventListener {
private final InventoryService inventoryService;
@EventListener
public void handleOrderCompleted(OrderCompletedEvent event) {
for (String productId : event.productIds()) {
inventoryService.decreaseStock(productId);
}
}
}
// 알림 서비스 — 주문 완료 시 이메일 전송
@Component
@RequiredArgsConstructor
public class NotificationEventListener {
private final EmailService emailService;
@EventListener
public void handleOrderCompleted(OrderCompletedEvent event) {
emailService.sendOrderConfirmation(
event.customerId(),
event.orderId(),
event.totalAmount()
);
}
}
// 포인트 서비스 — 주문 완료 시 포인트 적립
@Component
@RequiredArgsConstructor
public class PointEventListener {
private final PointService pointService;
@EventListener
public void handleOrderCompleted(OrderCompletedEvent event) {
long points = event.totalAmount() / 100; // 1% 적립
pointService.earn(event.customerId(), points);
}
}
핵심: OrderService는 재고, 알림, 포인트 서비스의 존재를 모른다. 새로운 후속 처리가 필요하면 리스너만 추가하면 된다. Spring AOP와 함께 쓰면 횡단 관심사를 더욱 깔끔하게 분리할 수 있다.
2. @TransactionalEventListener — 트랜잭션과 이벤트 동기화
기본 @EventListener는 이벤트 발행 즉시 실행된다. 트랜잭션이 롤백되어도 이미 이메일이 발송되는 문제가 있다. @TransactionalEventListener는 트랜잭션 상태에 따라 실행 시점을 제어한다.
@Component
public class NotificationEventListener {
// 트랜잭션 COMMIT 후에만 실행 (기본값)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderCompleted(OrderCompletedEvent event) {
// 주문 트랜잭션이 커밋된 후에만 이메일 발송
emailService.sendOrderConfirmation(event.customerId(), event.orderId());
}
// 롤백 후 실행
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void handleOrderFailed(OrderCompletedEvent event) {
log.error("Order transaction rolled back: {}", event.orderId());
// 보상 로직
}
// 트랜잭션 완료 후 실행 (커밋/롤백 무관)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
public void handleOrderFinished(OrderCompletedEvent event) {
metricsService.recordOrderAttempt(event.orderId());
}
// 커밋 전 실행 — 같은 트랜잭션 내에서 추가 작업
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleBeforeCommit(OrderCompletedEvent event) {
auditService.logOrderEvent(event); // 같은 TX에서 감사 로그 저장
}
}
| Phase | 실행 시점 | 사용 시점 |
|---|---|---|
| AFTER_COMMIT | 트랜잭션 커밋 후 | 이메일, 알림, 외부 API (기본값) |
| AFTER_ROLLBACK | 트랜잭션 롤백 후 | 보상 로직, 에러 알림 |
| AFTER_COMPLETION | 커밋/롤백 후 | 메트릭, 리소스 정리 |
| BEFORE_COMMIT | 커밋 직전 | 같은 TX에서 감사 로그, 검증 |
주의: AFTER_COMMIT 리스너에서 예외가 발생해도 원래 트랜잭션은 이미 커밋되어 롤백되지 않는다. 리스너 내부에서 try-catch로 반드시 예외를 처리하라.
3. @Async + @TransactionalEventListener — 비동기 이벤트 처리
기본적으로 이벤트 리스너는 동기 실행된다. 이메일 발송에 3초가 걸리면 API 응답도 3초 늦어진다. @Async를 결합하면 비동기로 처리된다.
// Async 활성화
@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("Async event handler failed: {} - {}", method.getName(), ex.getMessage());
// Sentry, Slack 알림
};
}
}
// 비동기 이벤트 리스너
@Component
public class NotificationEventListener {
@Async
@TransactionalEventListener
public void handleOrderCompleted(OrderCompletedEvent event) {
// 별도 스레드에서 실행 — API 응답에 영향 없음
emailService.sendOrderConfirmation(event.customerId(), event.orderId());
pushService.sendNotification(event.customerId(), "주문이 완료되었습니다");
}
}
함정: @Async + @TransactionalEventListener 조합에서 리스너가 DB를 조회하면, 원래 트랜잭션은 이미 끝났으므로 새로운 트랜잭션이 필요하다. @Transactional 심화에서 다룬 REQUIRES_NEW를 사용하라.
@Async
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW) // 새 트랜잭션
public void handleOrderCompleted(OrderCompletedEvent event) {
Order order = orderRepository.findById(event.orderId()).orElseThrow();
// order 엔티티를 자유롭게 사용
}
4. 이벤트 순서 제어 — @Order
// 여러 리스너의 실행 순서 제어
@Component
public class InventoryEventListener {
@EventListener
@Order(1) // 가장 먼저 실행
public void handleOrderCompleted(OrderCompletedEvent event) {
inventoryService.decreaseStock(event.productIds());
}
}
@Component
public class PointEventListener {
@EventListener
@Order(2) // 두 번째
public void handleOrderCompleted(OrderCompletedEvent event) {
pointService.earn(event.customerId(), event.totalAmount() / 100);
}
}
@Component
public class NotificationEventListener {
@EventListener
@Order(3) // 마지막
public void handleOrderCompleted(OrderCompletedEvent event) {
emailService.sendConfirmation(event.customerId());
}
}
5. 조건부 이벤트 처리 — SpEL condition
@Component
public class VipNotificationListener {
// 10만원 이상 주문만 VIP 알림
@EventListener(condition = "#event.totalAmount() >= 100000")
public void handleHighValueOrder(OrderCompletedEvent event) {
vipService.notifyVipTeam(event.orderId(), event.totalAmount());
}
// 특정 상품이 포함된 주문만 처리
@EventListener(condition = "#event.productIds().contains('prod_limited_001')")
public void handleLimitedEditionOrder(OrderCompletedEvent event) {
limitedEditionService.processSpecialOrder(event.orderId());
}
}
// 여러 이벤트 타입을 하나의 리스너로
@EventListener({OrderCompletedEvent.class, OrderCancelledEvent.class})
public void handleAnyOrderEvent(Object event) {
if (event instanceof OrderCompletedEvent completed) {
auditLog("ORDER_COMPLETED", completed.orderId());
} else if (event instanceof OrderCancelledEvent cancelled) {
auditLog("ORDER_CANCELLED", cancelled.orderId());
}
}
6. 이벤트 체이닝 — 리스너가 새 이벤트 발행
@Component
public class InventoryEventListener {
// 반환값이 이벤트 객체면 자동 발행
@EventListener
public StockDepletedEvent handleOrderCompleted(OrderCompletedEvent event) {
boolean stockDepleted = inventoryService.decreaseStock(event.productIds());
if (stockDepleted) {
return new StockDepletedEvent(event.productIds(), Instant.now());
}
return null; // null이면 이벤트 발행 안 함
}
}
// 재고 소진 이벤트를 수신하는 또 다른 리스너
@Component
public class PurchasingEventListener {
@EventListener
public void handleStockDepleted(StockDepletedEvent event) {
purchasingService.triggerReorder(event.productIds());
}
}
7. 도메인 이벤트 패턴 — AbstractAggregateRoot
Spring Data JPA의 AbstractAggregateRoot를 상속하면, 엔티티가 직접 이벤트를 등록하고 save() 시점에 자동 발행된다.
@Entity
public class Order extends AbstractAggregateRoot<Order> {
@Id
private String id;
private OrderStatus status;
private String customerId;
private long totalAmount;
public void complete() {
this.status = OrderStatus.COMPLETED;
// 도메인 이벤트 등록 — save() 시 자동 발행
registerEvent(new OrderCompletedEvent(
this.id, this.customerId, this.totalAmount,
this.getProductIds(), Instant.now()
));
}
public void cancel(String reason) {
this.status = OrderStatus.CANCELLED;
registerEvent(new OrderCancelledEvent(
this.id, this.customerId, reason, Instant.now()
));
}
}
// Service에서는 save()만 호출
@Service
public class OrderService {
@Transactional
public Order completeOrder(String orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.complete();
return orderRepository.save(order); // 여기서 이벤트 자동 발행
}
}
8. 테스트 전략
@SpringBootTest
class OrderEventTest {
@Autowired private OrderService orderService;
@Autowired private ApplicationEventPublisher publisher;
@MockBean private EmailService emailService;
@MockBean private InventoryService inventoryService;
@Test
void 주문_완료_시_이벤트_리스너_호출() {
// Given
Order order = createTestOrder();
// When
orderService.completeOrder(order.getId());
// Then — 이벤트 리스너가 호출되었는지 검증
verify(inventoryService).decreaseStock(anyList());
verify(emailService).sendOrderConfirmation(any(), any(), anyLong());
}
// 이벤트 발행 자체를 검증하려면 @RecordApplicationEvents (Spring 5.3.3+)
@Test
@RecordApplicationEvents
void 주문_완료_시_이벤트_발행(@Autowired ApplicationEvents events) {
orderService.completeOrder("ord_123");
long count = events.stream(OrderCompletedEvent.class).count();
assertThat(count).isEqualTo(1);
OrderCompletedEvent event = events.stream(OrderCompletedEvent.class)
.findFirst().orElseThrow();
assertThat(event.orderId()).isEqualTo("ord_123");
}
}
// 리스너 단위 테스트
@ExtendWith(MockitoExtension.class)
class InventoryEventListenerTest {
@Mock private InventoryService inventoryService;
@InjectMocks private InventoryEventListener listener;
@Test
void 재고_차감_호출() {
var event = new OrderCompletedEvent(
"ord_1", "cust_1", 50000,
List.of("prod_1", "prod_2"), Instant.now()
);
listener.handleOrderCompleted(event);
verify(inventoryService).decreaseStock(List.of("prod_1", "prod_2"));
}
}
9. 운영 체크리스트
| 항목 | 권장 사항 | 위반 시 증상 |
|---|---|---|
| TX 동기화 | 외부 부작용은 @TransactionalEventListener(AFTER_COMMIT) | 롤백되었는데 이메일 발송됨 |
| 비동기 처리 | 느린 작업은 @Async 결합 | API 응답 지연 |
| 예외 처리 | 리스너 내부에서 try-catch 필수 | 동기 리스너 예외가 발행자로 전파 |
| 이벤트 불변성 | record 또는 불변 클래스 사용 | 리스너 간 데이터 오염 |
| 비동기 DB 접근 | REQUIRES_NEW 트랜잭션 | LazyInitializationException |
| 스케일아웃 | 서비스 분리 시 Kafka/RabbitMQ로 전환 | 단일 JVM 한계 |
마무리 — 이벤트는 결합을 끊는 가장 우아한 방법이다
Spring Application Events는 외부 인프라 없이 도메인 이벤트를 구현하는 가장 가벼운 방법이다. ApplicationEventPublisher로 이벤트를 발행하고, @EventListener로 수신하며, @TransactionalEventListener로 트랜잭션과 동기화한다.
핵심 원칙은 세 가지다. 첫째, 외부 부작용(이메일, API 호출)은 반드시 AFTER_COMMIT에서 실행하라. 둘째, 느린 작업은 @Async로 비동기 처리하라. 셋째, 이벤트 객체는 불변(record)으로 만들어 리스너 간 데이터 오염을 방지하라. 나중에 마이크로서비스로 분리할 때 이벤트를 Kafka/RabbitMQ로 전환하면 코드 변경을 최소화할 수 있다.