티스토리 뷰
주문 요청이 전달되면 주문 정보를 저장하고, 알림 메시지를 발행해야 한다. 이 부가 로직을 주문 생성 메서드에 직접 넣으면 어떻게 될까? 핵심 로직과 부가 로직이 뒤섞이고, 부가 기능이 추가될 때마다 주문 생성 메서드가 계속 비대해진다.
Spring은 ApplicationEvent을 통해 이러한 문제를 해결한다. ApplicationEvent은 애플리케이션 내부에서 이벤트 기반의 처리를 가능하게한다. 주문 생성 로직은 "주문이 생성됐다"는 이벤트만 발행하고, 이력 저장이나 알림 메시지 발송 같은 부가 로직은 이벤트 기반으로 처리한다. spring-context 모듈에서 ApplicationEvent 관련 구성요소들을 제공한다.
핵심 구성 요소
| 구성 요소 | 역할 |
|---|---|
ApplicationEvent |
이벤트 데이터를 담는 객체 |
ApplicationEventPublisher |
이벤트를 발행(publish)하는 인터페이스 |
ApplicationListener<E> |
이벤트를 구독(subscribe)하는 인터페이스 |
@EventListener |
리스너를 선언하는 애노테이션 방식 |
// 이벤트 정의
public class OrderCreatedEvent {
private final Long orderId;
public OrderCreatedEvent(Long orderId) {
this.orderId = orderId;
}
public Long getOrderId() { return orderId; }
}
// 이벤트 발행
@Service
@RequiredArgsConstructor
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
@Transactional
public void createOrder(OrderRequest request) {
Order order = orderRepository.save(new Order(request));
eventPublisher.publishEvent(new OrderCreatedEvent(order.getId()));
}
}
// 이벤트 구독
@Component
public class OrderNotificationListener {
@EventListener
public void handle(OrderCreatedEvent event) {
// 알림 메시지 발송
}
}
Spring은 발행된 이벤트의 타입을 기준으로 리스너를 매칭한다. 리스너 메서드의 파라미터 타입이 발행된 이벤트 타입과 일치해야 호출된다. 위 예시에서는 OrderCreatedEvent를 발행했으므로, 파라미터로 OrderCreatedEvent를 받는 리스너만 실행된다.
동작 흐름

- Publisher가
ApplicationEventPublisher.publishEvent(event)호출 - Spring의
ApplicationContext가 등록된 리스너를 찾음 - 해당 이벤트 타입을 처리할 수 있는 리스너를 동기적으로 순서대로 호출
여기서 하나 짚고 넘어갈 게 있다. 기본 동작에서는 publishEvent()를 호출한 스레드가 리스너 메서드까지 직접 실행한다. 같은 콜스택 안에서 순차 호출되는 것이므로, 리스너가 느리면 발행자의 후속 로직도 그만큼 늦어진다.
이메일 발송이나 푸시 알림처럼 응답 시간에 영향을 주면 안 되는 작업이라면 이건 문제가 된다. 이때 @Async를 붙이면 리스너를 별도 스레드에서 실행하고, Publisher 스레드는 즉시 다음 코드로 넘어간다.
그래서 이메일·푸시·Kafka 발행처럼 응답 시간에 영향을 주면 안 되는 작업은 @Async를 함께 쓰는 게 일반적이다.
TransactionalEventListener
이벤트로 분리했다고 끝이 아니다. 한 가지 더 생각해볼 게 있다.
일반 @EventListener는 트랜잭션의 성공 여부와 관계없이 이벤트가 발행된다. 예를 들어 주문 생성 로직에서 이벤트를 발행한 뒤, 이후 로직에서 예외가 터져 트랜잭션이 롤백되었다고 하자. 주문은 저장되지 않았는데 "주문 완료" 이메일이 나가버린다.
이런 문제를 방지하기 위해 @TransactionalEventListener가 만들어졌다. 이름 그대로 트랜잭션 시점에 따라 이벤트 처리를 제어하는 기능이다. 트랜잭션의 어느 시점에 리스너를 실행할지 phase 속성으로 지정할 수 있다.
| Phase1 | 실행 시점 | 주요 용도 |
|---|---|---|
| AFTER_COMMIT (기본값) | 커밋 성공 후 | 알림, 외부 API 호출 |
| AFTER_ROLLBACK | 롤백 후 | 실패 로깅, 보상 처리 |
| AFTER_COMPLETION | 커밋/롤백 무관하게 완료 후 | 리소스 정리 |
| BEFORE_COMMIT | 커밋 직전 | 추가 검증, 감사 로그 |
기본값이 AFTER_COMMIT인 이유는 명확하다. DB에 저장된 게 확실한 경우에만 외부 시스템에 알리겠다는 의도다.
@Component
public class OrderNotificationListener {
@TransactionalEventListener // 기본값: phase = AFTER_COMMIT
public void handle(OrderCreatedEvent event) {
// 트랜잭션이 커밋된 후에만 실행된다
}
}
한 가지 주의할 점이 있다. @TransactionalEventListener도 클라이언트 요청을 처리하는 스레드에서 실행된다. 이벤트 발행 시점과 바로 동기적이지 않을 뿐, 전체 흐름 안에서는 여전히 동기적으로 동작한다. 비동기 처리가 필요하다면 여기에도 @Async를 함께 사용해야 한다.
마무리
이벤트라고 하면 Kafka나 RabbitMQ 같은 메시지 큐만 떠올렸다. 서비스 간 통신에서만 이벤트를 쓴다고 생각했던 거다. 그런데 애플리케이션 내부에서도 ApplicationEvent를 활용해 이벤트 기반의 흐름을 구성할 수 있다는 걸 알게 되면서, 핵심 로직과 부가 로직을 분리하는 방법이 하나 더 생겼다. 코드의 결합도를 낮추고 품질을 한 단계 더 끌어올릴 수 있는 도구라고 생각한다.
- 트랜잭션 생명주기 중 어느 단계인지를 나타내는 값 [본문으로]
'라이브러리&프레임워크 > Spring' 카테고리의 다른 글
| SLA, SLO, SLI 개념 정리 (0) | 2026.04.19 |
|---|---|
| 동시성 테스트 유틸, 정말 동시 시작을 보장하는가? (0) | 2026.04.14 |
| 이커머스 좋아요 집계 방식 비교 (0) | 2026.03.15 |
| 동시성 전략, 경합 빈도만 보면 안 되는 이유 (0) | 2026.03.09 |
| 유비쿼터스 언어 (0) | 2026.03.01 |
