ApplicationEvent — 객체 간 결합을 끊는 이벤트 기반 설계
주문이 완료되면 이메일도 보내고, 포인트도 적립하고, 재고도 차감해야 합니다. 이 모든 후속 작업을 주문 서비스에서 직접 호출해야 할까요? 그러면 주문 서비스가 모든 것을 알아야 하는데, 이 결합을 어떻게 끊을 수 있을까요?
개념 정의
ApplicationEvent 는 스프링이 제공하는 이벤트 발행/구독 메커니즘입니다. 어떤 일이 발생했을 때 이벤트를 발행하면, 관심 있는 리스너들이 각자 처리합니다. 발행자는 누가 듣고 있는지 알 필요가 없습니다.
왜 필요한가
이벤트 없이 직접 호출하는 코드를 봅시다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final EmailService emailService; // 결합
private final PointService pointService; // 결합
private final InventoryService inventoryService; // 결합
private final AnalyticsService analyticsService; // 결합
@Transactional
public void completeOrder(Order order) {
orderRepository.save(order);
emailService.sendOrderConfirmation(order); // 이메일 실패하면?
pointService.addPoints(order.getUserId(), 100); // 포인트 서비스 장애면?
inventoryService.decrease(order.getProductId()); // 재고 서비스 추가되면?
analyticsService.trackOrder(order); // 또 다른 서비스가 추가되면?
}
}
직접 호출 방식의 문제는 연쇄적으로 발생합니다.
- OrderService가 모든 후속 서비스에 직접 의존합니다.
- 새로운 후속 작업(분석, 쿠폰 등)이 추가될 때마다 OrderService를 수정해야 합니다.
- 후속 작업 하나가 실패하면 같은 트랜잭션이므로 주문 저장까지 롤백됩니다.
- 이메일 발송이 느려지면 주문 API 응답도 함께 느려집니다.
내부 동작
이벤트 발행 흐름
1. 발행자가 ApplicationEventPublisher.publishEvent(event) 호출
2. ApplicationEventMulticaster가 등록된 리스너를 검색
3. 이벤트 타입에 맞는 리스너를 찾아 호출
4. 기본적으로 동기 실행 (같은 스레드)
이벤트 정의
스프링 4.2부터는 POJO를 이벤트로 사용할 수 있습니다. ApplicationEvent를 상속할 필요가 없습니다.
// 이벤트 객체 (단순 POJO)
public record OrderCompletedEvent(
Long orderId,
Long userId,
Long productId,
int amount
) {}
이벤트 발행
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public void completeOrder(Order order) {
orderRepository.save(order);
// 이벤트 발행 — 누가 듣는지 알 필요 없음
eventPublisher.publishEvent(new OrderCompletedEvent(
order.getId(), order.getUserId(),
order.getProductId(), order.getAmount()
));
}
}
이벤트 수신
@Component
@RequiredArgsConstructor
public class OrderEventListener {
private final EmailService emailService;
@EventListener
public void handleOrderCompleted(OrderCompletedEvent event) {
emailService.sendOrderConfirmation(event.orderId());
}
}
이어서 이벤트를 구독하는 리스너를 정의합니다.
@Component
@RequiredArgsConstructor
public class PointEventListener {
private final PointService pointService;
@EventListener
public void handleOrderCompleted(OrderCompletedEvent event) {
pointService.addPoints(event.userId(), 100);
}
}
새로운 후속 작업이 필요하면 리스너만 추가하면 됩니다. OrderService는 변경할 필요가 없습니다.
코드 예제
@TransactionalEventListener
기본 @EventListener는 트랜잭션 안에서 실행됩니다. 즉, 리스너에서 예외가 발생하면 주문 트랜잭션도 롤백될 수 있습니다. 이때 @TransactionalEventListener를 사용합니다.
@Component
public class OrderEventListener {
// 트랜잭션이 커밋된 후에 실행
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendEmail(OrderCompletedEvent event) {
// 주문이 확실히 저장된 후에 이메일 발송
emailService.sendOrderConfirmation(event.orderId());
}
// 트랜잭션이 롤백된 후에 실행
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void handleRollback(OrderCompletedEvent event) {
log.warn("주문 {} 롤백됨", event.orderId());
}
}
TransactionPhase 옵션:
AFTER_COMMIT(기본값): 커밋 후 실행AFTER_ROLLBACK: 롤백 후 실행AFTER_COMPLETION: 커밋이든 롤백이든 완료 후 실행BEFORE_COMMIT: 커밋 직전 실행
주의: @TransactionalEventListener는 트랜잭션 밖에서 실행됨
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void afterOrderCommit(OrderCompletedEvent event) {
// 이 시점에서는 이미 트랜잭션이 커밋된 후!
// 여기서 DB 쓰기를 하려면 새 트랜잭션이 필요
// pointRepository.save(...); ← TransactionRequiredException!
}
// 해결: 새 트랜잭션으로 실행
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void afterOrderCommit(OrderCompletedEvent event) {
pointRepository.save(new Point(event.userId(), 100)); // 새 트랜잭션에서 실행
}
비동기 이벤트
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public TaskExecutor eventTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("event-");
return executor;
}
}
@Component
public class NotificationListener {
@Async("eventTaskExecutor")
@EventListener
public void sendPushNotification(OrderCompletedEvent event) {
// 별도 스레드에서 실행 → 주문 응답 지연 없음
pushService.send(event.userId(), "주문 완료!");
}
}
비동기 이벤트에서 주의할 점:
- 발행자의 트랜잭션과 별개로 동작합니다
- 예외가 발생해도 발행자에게 전파되지 않습니다
- 실패 시 재처리 로직을 별도로 구현해야 합니다
이벤트 리스너에서 또 다른 이벤트 발행
@Component
public class PointEventListener {
@EventListener
public PointEarnedEvent handleOrderCompleted(OrderCompletedEvent event) {
// 포인트 적립
pointService.addPoints(event.userId(), 100);
// 반환값이 이벤트 객체면 자동으로 발행됨
return new PointEarnedEvent(event.userId(), 100);
}
}
이어서 이벤트를 구독하는 리스너를 정의합니다.
@Component
public class BadgeListener {
@EventListener
public void handlePointEarned(PointEarnedEvent event) {
// 포인트 적립 이벤트를 받아서 뱃지 확인
badgeService.checkAndAward(event.userId());
}
}
조건부 이벤트 처리
@Component
public class VIPOrderListener {
@EventListener(condition = "#event.amount >= 100000")
public void handleHighValueOrder(OrderCompletedEvent event) {
// 10만원 이상 주문에만 반응
vipService.upgrade(event.userId());
}
}
이벤트 발행 순서 제어
@Component
public class OrderEventListeners {
@EventListener
@Order(1)
public void validateFirst(OrderCompletedEvent event) {
// 먼저 실행
}
@EventListener
@Order(2)
public void processSecond(OrderCompletedEvent event) {
// 나중 실행
}
}
주의할 점
1. @TransactionalEventListener에서 DB 쓰기를 하면 TransactionRequiredException이 발생한다
@TransactionalEventListener(phase = AFTER_COMMIT)은 트랜잭션이 이미 커밋된 후에 실행됩니다. 이 시점에서 repository.save()를 호출하면 활성 트랜잭션이 없어 TransactionRequiredException이 발생합니다. DB 쓰기가 필요하면 반드시 @Transactional(propagation = REQUIRES_NEW)로 새 트랜잭션을 시작해야 합니다.
2. 기본 이벤트는 동기 실행이라 리스너 예외가 발행자를 롤백시킨다
@EventListener는 기본적으로 동기로 실행되며, 발행자와 같은 스레드·같은 트랜잭션에서 동작합니다. 리스너에서 예외가 발생하면 발행자의 트랜잭션까지 롤백됩니다. 주문 저장 후 이메일 발송 리스너가 실패하면 주문 자체가 롤백되는 심각한 문제가 됩니다. 후속 작업은 @TransactionalEventListener 또는 @Async로 분리해야 합니다.
3. @Async 이벤트는 실패해도 발행자가 알 수 없다
비동기 이벤트 리스너에서 예외가 발생하면 발행자에게 전파되지 않고 로그만 남깁니다. 포인트 적립이나 알림 발송 같은 중요 작업이 조용히 실패할 수 있습니다. 실패 시 재처리가 필요한 작업에는 Spring Modulith의 Event Publication Log나 별도의 재시도 메커니즘을 적용해야 합니다.
정리
| 항목 | 설명 |
|---|---|
| 핵심 가치 | 발행자와 구독자의 결합을 끊음 |
| 기본 동작 | 동기 (같은 스레드, 같은 트랜잭션) |
| 비동기 전환 | @Async 추가 |
| 트랜잭션 분리 | @TransactionalEventListener(phase = AFTER_COMMIT) |
| 리스너에서 DB 쓰기 | @Transactional(propagation = REQUIRES_NEW) 필수 |
| 확장 | MSA 도메인 이벤트 패턴으로 자연스럽게 전환 가능 |