Spring Events를 사용해야 하는 이유
주문을 완료하면 영수증 메일 발송, 포인트 적립, 내부/외부 시스템 알림 같은 후처리가 뒤따른다고 가정하자. 이를 한 서비스 메서드에 몰아넣으면 문제가 생긴다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final MailService mailService;
private final PointService pointService;
private final WebhookService webhookService;
@Transactional
public void placeOrder(PlaceOrderCommand cmd) {
// 1) 주문 생성 & 결제 확정
Order order = createAndPay(cmd);
System.out.println("주문 완료: " + order.getId());
// 2) 영수증 메일
mailService.sendReceipt(order);
// 3) 포인트 적립
pointService.earn(order);
// 4) 내부 시스템 알림
webhookService.notifyOrderPlaced(order);
}
}
위와 같이 작성할 수는 있지만, 코드에는 몇 가지 문제점이 있다.
1) 강한 결합 : 핵심 로직과 후처리가 한 메서드에 엉켜 있다.
2) 성능 : 만약 메일 서버가 지연되거나 외부 웹훅 응답 지연이 있으면 주문 완료 응답이 함게 느려진다.
3) 트랜잭션 : 만약 포인트 적립을 하다가 오류가 나면 어떻게 될까 ? 한 트랜잭션으로 묶어있기 때문에 이전에 처리한 로직들까지 전부 롤백이 된다.
이럴 경우에 이벤트를 사용하면 '주문 완료' 라는 사실만 발행하고, 나머지 후처리는 독립된 리스너들이 알아서 처리할 수 있게 만들 수 있다.
이벤트의 실행 단계 (Spring Events 흐름)
- 이벤트 발행 (Publish) : 서비스나 도메인 객체에서 상태 변화가 일어나면 이벤트 객체를 만들고, `ApplicationEventPublisher` 를 통해 발행한다.
- 이벤트 디스패치 (Dispatch) : 스프링 컨테이너가 발행된 이벤트를 받아서, 해다 이벤트 타입을 처리할 수 있는 이벤트 리스터(`@EventListener`) 를 찾아 호출해준다.
- 이벤트 처리 (Handler) : 이벤트 리스너가 이벤트 객체를 전달 받아, 그 안의 데이터를 기반으로 후처리를 실행한다.
적용 방식
1) 이벤트 클래스를 생성한다.
public record OrderPlacedEvent(Long orderId, String buyerEmail, int totalAmount) {}
2) 기존의 OrderService를 수정한다.
이벤트를 보내는 기능을 사용하기 위해 ApplicationEventPublisher를 주입하였다. 주문이 완료되면, pubilshEvent를 사용해 이벤트를 발행해준다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final ApplicationEventPublisher publisher;
@Transactional
public void placeOrder(PlaceOrderCommand cmd) {
Order order = createAndPay(cmd);
System.out.println("주문 완료: " + order.getId());
// 주문 완료 이벤트 발행
publisher.publishEvent(
new OrderPlacedEvent(order.getId(), order.getBuyerEmail(), order.getTotalAmount())
);
}
}
3) 이벤트 핸들러 등록한다.
`@EventListener` 를 사용하면 이벤트 리스너로 등록이 되고, 매개변수에 이벤트 클래스를 정의하면 해당 이벤트가 발생했을 때 수신해서 처리할 수 있다.
@Component
@RequiredArgsConstructor
public class OrderEventHandler {
private final MailService mailService;
private final PointService pointService;
private final WebhookService webhookService;
@EventListener
public void sendReceipt(OrderPlacedEvent event) {
mailService.sendReceipt(event.orderId(), event.buyerEmail(), event.totalAmount());
}
@EventListener
public void earnPoints(OrderPlacedEvent event) {
pointService.earn(event.orderId(), event.totalAmount());
}
@EventListener
public void notifyWebhook(OrderPlacedEvent event) {
webhookService.notifyOrderPlaced(event.orderId());
}
}
비동기 처리로 응답 속도 지키기
예를 들어, 주문 처리를 하고 메일을 보내는데 3초, 포인트 적립 3초, 알림 3초가 순차적으로 실행된다면 최종 응답은 약 9초 뒤에야 사용자에게 전달된다. 이렇게 되면 사용자는 이미 주문 완료를 눌렀는데도 한 참 뒤에서나 완료가 되었다는 응답을 받아볼 수 있다. 그렇기 때문에 핵심 로직은 동기적으로 즉시 완료 시키고 후처리(메일, 포인트, 알림)은 다른 스레드에서 병렬로 실행하도록 해보려고 한다.
1) 전역 설정
@SpringBootApplication
@EnableAsync
public class ShopApplication {}
@Configuration
public class AsyncConfig {
@Bean(name = "eventExecutor")
public Executor eventExecutor() {
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(4);
ex.setMaxPoolSize(8);
ex.setQueueCapacity(1000);
ex.setThreadNamePrefix("event-");
ex.initialize();
return ex;
}
}
2) 리스너 비동기화
@Component
@RequiredArgsConstructor
public class OrderEventHandler {
private final MailService mailService;
private final PointService pointService;
private final WebhookService webhookService;
@Async("eventExecutor")
@EventListener
public void sendReceipt(OrderPlacedEvent e) {
mailService.sendReceipt(e.orderId(), e.buyerEmail(), e.totalAmount());
}
@Async("eventExecutor")
@EventListener
public void earnPoints(OrderPlacedEvent e) {
pointService.earn(e.orderId(), e.totalAmount());
}
@Async("eventExecutor")
@EventListener
public void notifyWebhook(OrderPlacedEvent e) {
webhookService.notifyOrderPlaced(e.orderId());
}
}
@TransactionalEventListener
여기에 더해서 트랜잭션 정합성까지 챙겨보도록 하겠다. 아래 두 가지 메서드가 존재한다. 두 메서드는 유사하지만 차이가 있다.
@Component
@RequiredArgsConstructor
public class OrderEventHandler {
private final MailService mailService;
private final PointService pointService;
@Async("eventExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendReceipt(OrderPlacedEvent e) {
mailService.sendReceipt(e.orderId(), e.buyerEmail(), e.totalAmount());
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void earnPoints(OrderPlacedEvent e) {
pointService.earn(e.orderId(), e.totalAmount());
}
}
1) `@Async + @TransactionalEventListener(AFTER_COMMIT)`
- 실행 시점 : 주문 트랜잭션이 성공적으로 커밋된 이후에 실행된다.
- 실행 스레드 : `@Async` 가 붙으면 스프링이 별도의 스레드 풀(eventExecutor)에서 실행한다.
- 트랜잭션 전파 : 기본 (REQUIRED, 이미 진행 중인 트랜잭션이 있으면 합류, 없으면 새 트랜잭션을 열어라)
- 언제 사용하나 : DB를 건드리지 않는 외부 I/O 성 후처리에서 사용한다. 예를 들어, 메일 발송이나 푸시/알림톡, 외부 웹훅 호출 시에 사용한다. 커밋 후 알림만 보내기만 하면 충분할 경우
- 장점 : 사용자 응답 속도가 빠르다.
2) `@Transactional(propagation = REQUIRES_NEW) + @TransactionalEventListener(AFTER_COMMIT)`
- 실행 시점 : 주문 트랜잭션이 성공적으로 커밋된 이후에 실행된다.
- 실행 스레드 : `@Async` 를 사용하지 않았기 때문에, 별도의 스레드가 아니라 현재 스레드 그대로 실행된다.
- 트랜잭션 전파 : `REQUIRES_NEW` (항상 새로운 트랜잭션을 열어 독립적인 커밋과 롤백을 보장한다.)
- 언제 사용하나 : 리스너에서 DB 작업이 필수이고 결과가 반드시 커밋되어야 할 때 사용한다. 예를 들어, 포인트 적립, 쿠폰 발급 등 데이터 무결성이 중요한 작업
- 장점 : 본 트랜잭션과 완전 분리된 커싱르 보장한다. 리스너 실패가 본 트랜잭션에 영향이 없다.
- 단점 : 동기 실행이라 처리 시간이 길면 호출 지연이 발생한다.
위에 코드에서는 이메일을 보내는 것은 다른 스레드에서 비동기 방식으로 진행되고 트랜잭션 전파 수준은 기본이라 원래 트랜잭션에 합류하려 하지만 AFTER_COMIMT 시점엔 이미 끝난 상태라 사실상 트랜잭션 없이 실행된다. 반대로 포인트 제공같은 경우에는 같은 스레드에서 처리되지만 새로운 트랜잭션을 생성하여 독립적인 커밋을 보장한다.
참고
https://dgjinsu.tistory.com/41
[Event] Spring Event란? 이벤트 발행과 구독 방법
1. 이벤트를 사용하는 이유 Spring Boot에서 이벤트를 적용하는 방법에 대해 들어가기 전에, 이벤트를 왜 써야하는지, 사용하면 좋은 상황에 대해 먼저 알아보자. 회원가입을 하고 나면 가입 축하
dgjinsu.tistory.com
'Spring' 카테고리의 다른 글
| [Spring] 즉시 로딩과 지연 로딩 (0) | 2025.09.21 |
|---|---|
| [Spring] Transactional 사용 시 자기 호출(Self-Invocation) 이슈 (0) | 2025.09.11 |
| [Spring] Setter 사용을 지양해야하는 이유 (0) | 2025.08.29 |
| [Spring] DTO class를 record로 사용하는 이유 (0) | 2025.08.29 |
| [Spring] 연관관계 (0) | 2025.08.29 |