본문 바로가기

Spring

@TransactionalEventListener

이벤트가 무엇인지 모른다면 DDD - 이벤트의 장점, 용도 그리고 구조를 참고해주세요. 이외에도 ApplicationListener

가 무엇인지는 알아야 해당 글을 이해하는데 도움이 될 것 같습니다. 

 

목차

1. @EventListener의 아쉬운점

2. @TransactionalEventListener 를 이용해 요구사항 검증하기

@EventListener의 아쉬운점


아래는 이벤트를 생성하고 퍼블리셔를 통해 리스너로 이벤트를 옮기는 객체입니다.

 

// OrderService
@Transactional
public void create(OrderForm form) {
    OrderCreatedEvent event = new OrderCreatedEvent(form.ordererId()); 
    eventPublisher.publishEvent(event); // (1) 이벤트 처리

    // (2) 비즈니스 로직
    if ("마라탕".equals(form.menuName())) {
        throw new MenuSoldOutException("해당 메뉴는 품절되었습니다.");
    }
    orderRepository.save(new Order(form.ordererId()));
    log.info("[주문 생성 - 주문 ID : {} 메뉴 : {} 주문자 ID : {}]", 
    form.orderNo(), form.menuName(), form.ordererId());
}

@Component
@RequiredArgsConstructor
public class NotificationEventHandler {
    private final NotificationRepository notificationRepository;

    @EventListener(classes = OrderCreatedEvent.class)
    public void handle(OrderCreatedEvent event) { // (3)
        Notification notification = new Notification(event.ordererId(), "주문 완료 메시지");
        notificationRepository.save(notification);
        // 외부 API 호출을 통해 알림 전송
}

이 메서드는 sync/blocking 으로 처리되기 때문에 아래와 같이 처리됩니다.

 

 

1. 스레드는 먼저 publishEvent를 통해 이벤트를 푸시합니다. 

2. 이후 (2) 비즈니스 로직을 처리하는 것이 아닌 아래 이벤트 핸들러의 handle 메소드 (3)을 처리합니다.

3. (3)의 처리가 끝나면 (2) 비즈니스 로직 부분을 처리하고 외부 API를 호출해서 알림을 전송합니다.

 

만약에 3번 과정에서 언체크 예외가 발생했다면 어떻게 될까요?

(2), (3) 작업이 모두 롤백됩니다. 왜냐하면 하나의 트랜잭션에서 이뤄지는 작업이기 때문입니다.  이 내용이 이해가 되지 않는다면 스프링 트랜잭션 전파를 참고해주세요.

 

이 경우에는 의도치 않은 결과를 발생시킬 수 있습니다. 트랜잭션은 롤백됐지만 외부 API는 호출되어 알림이 전송될 수도 있습니다. 

 

이러한 문제를 해결하기 위해 로직의 순서를 바꾸었습니다.

@Transactional
public void create(OrderForm form) {
   // 비즈니스 로직 (1)
   if ("마라탕".equals(form.menuName())) {
        throw new MenuSoldOutException("해당 메뉴는 품절되었습니다.");
    }
    orderRepository.save(new Order(form.ordererId()));
    log.info("[주문 생성 - 주문 ID : {} 메뉴 : {} 주문자 ID : {}]", 
    form.orderNo(), form.menuName(), form.ordererId());
    // 이벤트 처리 (2)
    OrderCreatedEvent event = new OrderCreatedEvent(form.ordererId()); 
    eventPublisher.publishEvent(event);
}

 

이제는 비즈니스 로직에서 예외가 발생하면 이벤트를 처리하는 (2)가 실행되지 않을 것입니다. 물론 코드의 위치를 바꾸는게 예시에서는 쉬운작업이었지만 프로젝트의 복잡도가 높아지고 만약 이벤트가 체이닝되거나 로직이 복잡하다면 코드의 위치를 변경하는 것으로는 문제를 해결하기 어려울 수 있습니다.

 

이 때 활용할 수 있는 애노테이션이 @TransactionalEventListener입니다. 이 애노테이션은 스프링 트랜잭션 상태에 따라 이벤트 핸들러를 실행 여부를 결정합니다.

 

@TransactionalEventListener(classes = OrderCanceledEvent.class, phase = TransactionPhase.AFTER_COMMIT)

 

phase 인자에 들어가는 옵션에 따라 이벤트 핸들러를 처리합니다. 위 코드는 트랜잭션이 커밋된 이후에 이벤트 핸들러를 호출하도록 하는 예시입니다.

 

 

@TransactionalEventListener 를 이용해 요구사항 검증하기


이제 create 메서드를 트랜잭션, 이벤트를 고려해서 어떻게 실행되어야 할지 정리하겠습니다.

 

1. 이벤트는 주문 로직이 커밋될 경우에만 호출되도록 한다.

2. 알림 로직에서 롤백이 일어날 경우에는 알림만 롤백하고 주문은 정상적으로 수행되도록 한다.

 

 

비즈니스 로직

@Transactional
public void create(OrderForm form) {
    log.info("주문 생성 로직 START");
    // 비즈니스 로직 시작
    if ("마라탕".equals(form.menuName())) {
        throw new MenuSoldOutException("해당 메뉴는 품절되었습니다.");
    }
    orderRepository.save(new Order(form.ordererId()));
    log.info("[주문 생성 - 주문 ID : {} 메뉴 : {} 주문자 ID : {}]", form.orderNo(), form.menuName(), form.ordererId());
    // 이벤트 Publish
    OrderCreatedEvent event = new OrderCreatedEvent(form.ordererId());
    eventPublisher.publishEvent(event);
    log.info("주문 생성 로직 END");
}

흐름상 비즈니스 로직을 작성하고 이벤트를 publish 하는게 가독성이 높아보이지만 테스트를 위해 위 순서로 작성합니다.

 

 

이벤트 핸들러

@TransactionalEventListener(classes = OrderCreatedEvent.class, phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handle(OrderCreatedEvent event) {
    if (event.ordererId() == 9999l) {
        log.info("알림 발송 중 예외가 발생했습니다.");
        throw new IllegalArgumentException("존재하지 않는 회원 식별자입니다");
    }
    Notification notification = new Notification(event.ordererId(), "주문 완료 메시지");
    notificationRepository.save(notification);

    log.info("[알림 수신자 ID : {} 요청하신 주문이 완료되었습니다.]", event.ordererId());
}

주문과 알림 트랜잭션을 분리하기 위해 트랜잭션 전파 인자를 통해 새로운 트랜잭션(REQUIRES_NEW)을 사용하였습니다.

 

 

 

검증: 1번 요구사항 : 이벤트는 주문 로직이 커밋될 경우에만 호출되도록 한다.


이를 위해 menuName 에 마라탕을 넣어보겠습니다.

 

 

 

 

이벤트 핸들러가 호출되지 않은 것을 확인할 수 있습니다. 당연하게 트랜잭션은 롤백되었기 때문에 데이터베이스 상 변화는 없습니다.

 

 

 

검증 : 2. 알림 로직에서 롤백이 일어날 경우에는 알림만 롤백하고 주문은 정상 수행


알림 로직에서 롤백이 일어나도록 하기 위해 주문자 식별자로 9999를 줘보겠습니다.

 

 

 

 

알림 발송 중 예외가 발생했습니다.

 

 

주문은 정상적으로 수행되었고 알림에서만 롤백이 발생한 것을 볼 수 있습니다.

 

이렇게 @TransactionalEventListener를 이용해서 비즈니스 로직을 처리해보았습니다. 참고로 이 방법만이 아니라 비동기 처리를 통해서 문제를 해결할 수도 있습니다.