Observer 패턴으로 구현된 느슨한 결합의 힘
실무에서 서비스 간 결합도를 낮추면서도 자연스럽게 후처리를 연결하고 싶을 때 스프링 이벤트는 특히 유용한 기능이라고 생각합니다.
특히 핵심 비즈니스 로직은 그대로 유지하면서도 부가 로직을 깔끔하게 분리하고 싶을 때 선택해볼만한 구조라고 볼 수 있습니다.
이 글에서는 스프링 이벤트의 개념, 동작 방식, 트랜잭션과의 연동 지점까지 차근차근 정리해보겠습니다.
1. 스프링 이벤트란? — 핵심 개념과 배경
스프링 이벤트는 한 객체에서 발생한 특정 이벤트를 다른 객체들이 감지하고 반응하도록 하는 메커니즘이라고 이해하시면 됩니다.
서비스 간 결합을 줄이기 위해 사용하며, 특정 변경사항을 “알리는” 것에 특화되어 있습니다.
- 이벤트 발행자: publishEvent()를 호출합니다.
- 이벤트 리스너: 이벤트를 받아 후속 작업을 수행합니다.
스프링 내부 구조는 사실상 Observer 패턴을 그대로 구현하고 있습니다.
덕분에 비즈니스 서비스에 불필요한 의존성을 만들지 않고도 필요한 후처리를 각각의 Listener에 분리해둘 수 있게 됩니다.
2. Observer 패턴과의 관계: 스프링 이벤트가 제공하는 구조적 이점
Observer 패턴은 Observable(피관찰자)과 Observer(관찰자) 구조로 이루어져 있으며, 상태 변화가 발생하면 등록된 Observer에게 알림을 보내는 패턴입니다.
스프링 이벤트는 이 구조를 그대로 가져갑니다.
- Observable = 이벤트를 발행하는 주체(publishEvent)
- Observer = 이벤트를 받는 Listener
- 관계 = 1:N
예시를 간단히 보면 다음과 같이 구성할 수 있습니다.
public record OrderCreatedEvent(Order order) {}
@Service
public class OrderService {
private final ApplicationEventPublisher publisher;
public void createOrder(Order order) {
orderRepository.save(order);
publisher.publishEvent(new OrderCreatedEvent(order));
}
}
그리고 여러 서비스에서 각각 Listener를 등록해놓을 수 있습니다.
@EventListener
public void sendNotification(OrderCreatedEvent event) { ... }
@EventListener
public void recordLog(OrderCreatedEvent event) { ... }
이렇게 하나의 이벤트가 여러 후속 로직을 자연스럽게 호출하도록 구성해볼 수 있습니다.
이 구조 덕분에 핵심 로직과 부가 로직의 결합도가 크게 낮아집니다.
3. EventListener vs TransactionalEventListener — 언제 무엇을 써볼까
@EventListener
- 이벤트가 발행되면 즉시 실행됩니다.(동기적 실행)
- 트랜잭션과 무관하게 동작합니다.
- Listener에서 예외가 발생하면 발행자의 트랜잭션에 영향을 줍니다.
- 동기적 처리나 같은 트랜잭션 내에서 후처리가 필요할 때 사용해보면 좋습니다.
@TransactionalEventListener
- 이벤트는 “기록”만 해두고, trigger에 맞춰 실행됩니다.
- 기본값은 AFTER_COMMIT입니다.
- Listener에서 예외가 발생해도 발행자 트랜잭션에는 영향을 주지 않습니다.(Before commit은 예외)
- 알림, 외부 시스템 연동, 저장 완료 이후의 후처리에 적합합니다.
아래 표처럼 정리해볼 수 있습니다.
| 구분 | @EventListener | @TransactionalEventListener |
| 실행 시점 | publish 즉시 | 트랜잭션 커밋/롤백 후 |
| 트랜잭션 의존성 | 없음 | 있음 |
| 예외 전파 | 발행자에 영향 | 발행자에 영향 없음(Before commit은 예외) |
| 주요 용도 | 동기·내부 후처리 | 알림, 외부 연동 |
실무에서는 주로 TransactionalEventListener가 더 자주 사용됩니다.
4. 트랜잭션 시점 옵션(Triggers)을 정리해보겠습니다
@TransactionalEventListener는 트랜잭션 실행 시점을 선택할 수 있습니다.
AFTER_COMMIT (기본값)
트랜잭션이 정상적으로 커밋된 이후에 실행됩니다.
데이터가 확실히 반영된 뒤 실행해야 하는 작업에 적합합니다.
AFTER_ROLLBACK
트랜잭션 롤백 시에만 실행됩니다.
실패 로그 기록, 보상 트랜잭션 등에 사용할 수 있습니다.
AFTER_COMPLETION
트랜잭션 성공·실패 여부와 상관없이 항상 실행됩니다.
cleanup 같은 작업에 잘 어울립니다.
BEFORE_COMMIT
커밋 직전에 실행됩니다.
여기서 예외가 발생하면 전체 트랜잭션이 롤백됩니다.
커밋 전 검증이 필요할 때 사용해볼 수 있습니다.
실행 흐름은 다음과 같습니다.
@Transactional
public void createOrder() {
orderRepository.save(order);
publisher.publishEvent(new OrderCreatedEvent(order));
// @EventListener는 여기서 즉시 실행됩니다.
// BEFORE_COMMIT은 메서드가 종료된 뒤 커밋 직전에 실행됩니다.
}
참고로 트랜잭션이 없는 곳에서 TransactionalEventListener를 호출하면 무시됩니다.
필요하다면 fallbackExecution = true 옵션을 사용해볼 수 있습니다.
이 옵션을 사용하면 트랜잭션 외부에서도(=트랜잭션 없이도) TransactionalEventListener가 실행되도록 동작하게 됩니다.
5. 이벤트 객체 설계: POJO vs ApplicationEvent
스프링 4.2 이전에는 이벤트 객체를 만들 때 반드시 ApplicationEvent를 상속해서 구현해야 했습니다.
이 방식은 스프링 컨테이너가 이벤트의 source와 timestamp를 관리할 수 있다는 장점이 있었지만, 불필요한 상속 구조가 생기고 객체 설계가 다소 무거워지는 단점이 있었습니다.
예전 방식은 다음과 같았습니다.
public class OrderCreatedEvent extends ApplicationEvent {
private final Order order;
public OrderCreatedEvent(Object source, Order order) {
super(source);
this.order = order;
}
public Order getOrder() {
return order;
}
}
하지만 스프링 4.2 이후로는 상속이 필수 조건이 아니게 되었고, 단순한 POJO나 record 객체를 이벤트로 자유롭게 사용할 수 있게 되었습니다.
이 방식이 훨씬 가볍고, 객체 모델도 명확해져서 대부분의 실무에서는 아래처럼 설계하는 편이 일반적입니다.
public record CouponCreatedEvent(Long couponId, Long userId) {}
ApplicationEvent를 상속해야 하는 경우는 timestamp나 source가 꼭 필요한 특수한 상황 정도입니다.
대부분의 실무에서는 일반 POJO나 record를 권장합니다.
6. 이벤트 발행 시 고려해볼 점들
1. 자신의 이벤트는 자신이 발행하는 것이 좋습니다
Order 서비스에서 갑자기 Coupon 이벤트를 발행하면 서비스 간 의존이 생기게 됩니다.
이벤트의 목적이 모호해지고, 결국 결합도가 높아지게 됩니다.
2. 비즈니스 로직을 이벤트로 대체하려고 하지 않는 것이 좋습니다
이벤트를 과도하게 사용하면 흐름 추적이 어려워집니다.
핵심 로직은 서비스에서 명확하게 유지하고, 부가 로직만 이벤트로 분리하는 것이 이상적입니다.
3. 트랜잭션 밖에서 TransactionalEventListener를 호출하면 무시됩니다
별도 예외가 발생하지 않기 때문에 의도치 않은 무작동이 발생할 수 있습니다.
필요하다면 fallbackExecution 옵션을 고려할 수 있습니다.
4. Listener 내부 예외를 확실히 제어하는 것이 필요합니다
동기/비동기와 트랜잭션 시점에 따라 예외 전파 방식이 달라지므로 용도에 맞게 Listener를 분리하는 것이 좋습니다.
마무리해보겠습니다
스프링 이벤트는 구조적으로 단순하지만, 트랜잭션과 연계될 때 그 진가가 드러나는 기능이라고 생각합니다.
EventListener와 TransactionalEventListener를 목적에 맞게 선택해 사용하면 서비스 레이어의 복잡도를 크게 줄여볼 수 있습니다.