Spring ApplicationEvent란?
Spring의 ApplicationEvent는 애플리케이션 내부에서 이벤트 기반 통신을 구현하는 핵심 메커니즘입니다. 서비스 간 직접 의존성을 제거하고, 느슨한 결합(loose coupling)으로 비즈니스 로직을 분리할 수 있습니다. 주문 완료 후 이메일 발송, 재고 차감, 포인트 적립 등 부가 효과(side effect)를 메인 로직에서 분리하는 데 특히 유용합니다.
이벤트 발행: ApplicationEventPublisher
Spring 4.2+부터는 ApplicationEvent를 상속할 필요 없이 임의의 객체를 이벤트로 발행할 수 있습니다.
// 이벤트 정의 — 단순 레코드
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());
}
}
public record UserRegisteredEvent(Long userId, String email, Instant occurredAt) {}
// 이벤트 발행
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public Order completeOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new EntityNotFoundException("Order", orderId));
order.complete();
orderRepository.save(order);
// 이벤트 발행 — 트랜잭션 내에서 발행됨
eventPublisher.publishEvent(new OrderCompletedEvent(
order.getId(),
order.getUserId(),
order.getTotalAmount()
));
return order;
}
}
이벤트 구독: @EventListener
@EventListener는 동기적으로 실행됩니다. 발행자와 같은 스레드, 같은 트랜잭션 내에서 실행되므로, 리스너에서 예외가 발생하면 발행자의 트랜잭션도 롤백됩니다.
@Component
@RequiredArgsConstructor
@Slf4j
public class OrderEventListener {
private final InventoryService inventoryService;
private final PointService pointService;
// 기본: 동기 실행 (같은 트랜잭션)
@EventListener
public void handleOrderCompleted(OrderCompletedEvent event) {
log.info("Order completed: {}", event.orderId());
inventoryService.deductStock(event.orderId());
}
// 조건부 리스닝: SpEL 조건
@EventListener(condition = "#event.totalAmount() > 100000")
public void handleLargeOrder(OrderCompletedEvent event) {
log.info("Large order detected: {} ({}원)", event.orderId(), event.totalAmount());
pointService.addBonusPoints(event.userId(), 500);
}
// 이벤트 체이닝: 반환값이 새 이벤트로 발행됨
@EventListener
public PointsAwardedEvent awardPoints(OrderCompletedEvent event) {
int points = event.totalAmount().intValue() / 100;
return new PointsAwardedEvent(event.userId(), points);
}
}
@TransactionalEventListener: 트랜잭션 페이즈 바인딩
실무에서 가장 중요한 패턴입니다. @TransactionalEventListener는 트랜잭션의 특정 단계에서 리스너를 실행합니다. 트랜잭션 커밋 후에만 이메일을 발송하거나 외부 API를 호출해야 할 때 필수입니다.
@Component
@RequiredArgsConstructor
@Slf4j
public class NotificationEventListener {
private final EmailService emailService;
private final SlackNotifier slackNotifier;
// AFTER_COMMIT (기본값): 트랜잭션 성공 후 실행
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendOrderConfirmation(OrderCompletedEvent event) {
// 트랜잭션이 커밋된 후에만 실행
// → 롤백되면 이메일 발송 안 함 ✅
emailService.sendOrderConfirmation(event.userId(), event.orderId());
}
// AFTER_ROLLBACK: 트랜잭션 롤백 후 실행
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void notifyOrderFailure(OrderCompletedEvent event) {
slackNotifier.alert("Order " + event.orderId() + " failed!");
}
// AFTER_COMPLETION: 커밋/롤백 상관없이 항상 실행
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
public void logOrderResult(OrderCompletedEvent event) {
log.info("Order {} processing completed", event.orderId());
}
// BEFORE_COMMIT: 커밋 직전 실행 (같은 트랜잭션)
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void auditBeforeCommit(OrderCompletedEvent event) {
// 이 리스너에서 예외 → 트랜잭션 롤백
auditRepository.save(new AuditLog("ORDER_COMPLETED", event.orderId()));
}
}
| Phase | 실행 시점 | 트랜잭션 상태 | 용도 |
|---|---|---|---|
BEFORE_COMMIT |
커밋 직전 | 활성 (롤백 가능) | 감사 로그, 유효성 검증 |
AFTER_COMMIT |
커밋 성공 후 | 완료 | 알림, 외부 API, 캐시 갱신 |
AFTER_ROLLBACK |
롤백 후 | 완료 | 실패 알림, 보상 처리 |
AFTER_COMPLETION |
커밋 또는 롤백 후 | 완료 | 리소스 정리, 공통 로깅 |
@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-async-");
executor.setRejectedExecutionHandler(new CallerRunsPolicy());
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> {
log.error("Async event error in {}: {}", method.getName(), ex.getMessage(), ex);
};
}
}
@Component
public class AsyncOrderListener {
// @Async + @TransactionalEventListener 조합
@Async
@TransactionalEventListener
public void sendEmailAsync(OrderCompletedEvent event) {
// 별도 스레드에서 실행
// 트랜잭션 커밋 후 + 비동기 = 안전하고 빠름
emailService.sendOrderConfirmation(event.userId(), event.orderId());
}
// 주의: @Async 리스너의 예외는 발행자에게 전파되지 않음
// AsyncUncaughtExceptionHandler로 별도 처리 필요
}
AFTER_COMMIT 리스너의 트랜잭션 함정
AFTER_COMMIT 리스너 내에서 DB 쓰기를 하면 트랜잭션이 이미 완료된 상태라 저장이 실패하거나 무시됩니다. 이 문제를 해결하려면 새 트랜잭션을 명시적으로 시작해야 합니다.
@Component
@RequiredArgsConstructor
public class OrderFollowUpListener {
private final NotificationRepository notificationRepository;
// ❌ 안티패턴: AFTER_COMMIT에서 DB 쓰기
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void saveNotification(OrderCompletedEvent event) {
// 트랜잭션 없음 → 저장 실패 가능!
notificationRepository.save(new Notification(event.userId(), "주문 완료"));
}
// ✅ 해결: REQUIRES_NEW 트랜잭션
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveNotificationFixed(OrderCompletedEvent event) {
// 새 트랜잭션에서 실행 → 정상 저장
notificationRepository.save(new Notification(event.userId(), "주문 완료"));
}
}
이벤트 기반 아키텍처 설계 패턴
실무에서는 도메인 이벤트를 체계적으로 관리하는 패턴이 필요합니다. Spring Modulith의 이벤트 방식과 유사하게, 도메인 엔티티에서 이벤트를 수집하는 패턴을 적용할 수 있습니다.
// AbstractAggregateRoot 활용: 엔티티에서 이벤트 등록
@Entity
public class Order extends AbstractAggregateRoot<Order> {
@Id @GeneratedValue
private Long id;
@Enumerated(EnumType.STRING)
private OrderStatus status;
private BigDecimal totalAmount;
private Long userId;
public Order complete() {
this.status = OrderStatus.COMPLETED;
// 이벤트를 엔티티에 등록 → save() 시 자동 발행
registerEvent(new OrderCompletedEvent(this.id, this.userId, this.totalAmount));
return this;
}
public Order cancel(String reason) {
this.status = OrderStatus.CANCELLED;
registerEvent(new OrderCancelledEvent(this.id, this.userId, reason));
return this;
}
}
// Repository save() 시 등록된 이벤트가 자동 발행됨
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
@Transactional
public Order completeOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.complete();
return orderRepository.save(order);
// save() 호출 시 registerEvent로 등록된 이벤트 자동 발행!
}
}
이벤트 리스너 테스트
이벤트 발행과 리스너 동작을 검증하는 테스트 전략입니다.
@SpringBootTest
class OrderEventTest {
@Autowired private OrderService orderService;
@Autowired private ApplicationEvents events; // Spring 6.1+
@MockBean private EmailService emailService;
// ApplicationEvents로 발행된 이벤트 검증
@Test
@RecordApplicationEvents
void shouldPublishEventOnOrderComplete() {
orderService.completeOrder(1L);
long count = events.stream(OrderCompletedEvent.class).count();
assertThat(count).isEqualTo(1);
OrderCompletedEvent event = events.stream(OrderCompletedEvent.class)
.findFirst().orElseThrow();
assertThat(event.orderId()).isEqualTo(1L);
}
// 리스너가 호출되었는지 검증
@Test
void shouldSendEmailAfterOrderComplete() {
orderService.completeOrder(1L);
verify(emailService, timeout(5000))
.sendOrderConfirmation(anyLong(), eq(1L));
}
}
운영 베스트 프랙티스
- 외부 호출은 AFTER_COMMIT: 이메일, API 호출, 메시지 큐 발행은 반드시
@TransactionalEventListener(phase = AFTER_COMMIT)을 사용하세요 - AFTER_COMMIT + DB 쓰기: 새 트랜잭션(
REQUIRES_NEW)이 필요합니다 — 기존 트랜잭션은 이미 완료 상태 - 비동기 리스너 에러 처리:
@Async리스너의 예외는 발행자에게 전파되지 않으므로 별도 핸들러 필수 - AbstractAggregateRoot 활용: 도메인 엔티티에서 이벤트를 등록하면 발행 시점을 JPA save()와 자동 연동할 수 있습니다
- 이벤트는 불변 객체: Java record로 정의하여 불변성과 명확한 계약을 보장하세요
- 이벤트 폭풍 주의: 이벤트 체이닝이 깊어지면 디버깅이 어려워집니다 — 2단계 이하로 유지하세요