티스토리 뷰

주문 요청이 전달되면 주문 정보를 저장하고, 알림 메시지를 발행해야 한다. 이 부가 로직을 주문 생성 메서드에 직접 넣으면 어떻게 될까? 핵심 로직과 부가 로직이 뒤섞이고, 부가 기능이 추가될 때마다 주문 생성 메서드가 계속 비대해진다.

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를 받는 리스너만 실행된다.


동작 흐름

  1. Publisher가 ApplicationEventPublisher.publishEvent(event) 호출
  2. Spring의 ApplicationContext가 등록된 리스너를 찾음
  3. 해당 이벤트 타입을 처리할 수 있는 리스너를 동기적으로 순서대로 호출

여기서 하나 짚고 넘어갈 게 있다. 기본 동작에서는 publishEvent()를 호출한 스레드가 리스너 메서드까지 직접 실행한다. 같은 콜스택 안에서 순차 호출되는 것이므로, 리스너가 느리면 발행자의 후속 로직도 그만큼 늦어진다.

이메일 발송이나 푸시 알림처럼 응답 시간에 영향을 주면 안 되는 작업이라면 이건 문제가 된다. 이때 @Async를 붙이면 리스너를 별도 스레드에서 실행하고, Publisher 스레드는 즉시 다음 코드로 넘어간다.

그래서 이메일·푸시·Kafka 발행처럼 응답 시간에 영향을 주면 안 되는 작업은 @Async를 함께 쓰는 게 일반적이다.


TransactionalEventListener

이벤트로 분리했다고 끝이 아니다. 한 가지 더 생각해볼 게 있다.

일반 @EventListener는 트랜잭션의 성공 여부와 관계없이 이벤트가 발행된다. 예를 들어 주문 생성 로직에서 이벤트를 발행한 뒤, 이후 로직에서 예외가 터져 트랜잭션이 롤백되었다고 하자. 주문은 저장되지 않았는데 "주문 완료" 이메일이 나가버린다.

이런 문제를 방지하기 위해 @TransactionalEventListener가 만들어졌다. 이름 그대로 트랜잭션 시점에 따라 이벤트 처리를 제어하는 기능이다. 트랜잭션의 어느 시점에 리스너를 실행할지 phase 속성으로 지정할 수 있다.

Phase[각주:1] 실행 시점 주요 용도
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를 활용해 이벤트 기반의 흐름을 구성할 수 있다는 걸 알게 되면서, 핵심 로직과 부가 로직을 분리하는 방법이 하나 더 생겼다. 코드의 결합도를 낮추고 품질을 한 단계 더 끌어올릴 수 있는 도구라고 생각한다.

  1. 트랜잭션 생명주기 중 어느 단계인지를 나타내는 값 [본문으로]
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함