ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 스프링 이벤트 에러에 독립적으로 만들기 (트랜잭션 분리. @TransactionalEventListener, @Transactional)
    프로젝트/BLUE DELIVERY 2021. 8. 22. 17:43

    안녕하세요. 

    제가 프로젝트에서 '주문 후 가게에 알림'을 구현하는 과정에서 고민했던 내용을 정리해보려는데, 내용이 길어질거같아 나눠서 올리려고 합니다.

    주로 하게된 고민은 다음과 같습니다.

    • 주문이 발생하야 알림이 발생할 수 있는데, 하나의 요청안에서 '주문'과 '알림'을 어떻게 독립적으로 처리할 수 있을지에 대한 고민
    • 어떻게하면 에러가 발생해도 이벤트 메세지를 유실시키지 않고 한번 이상 유저에게 전달하는 것을 보장할 수 있을지

     

    이번 글에서는 알림 서비스 개요@TransactionalEventListener, @Transactional 을 사용한 트랜잭션 분리에 관련된 내용을 해보겠습니다.

     

    알림 서비스의 특징

    알림 서비스를 구현하기 앞서서 알림 서비스에 필요한 내용을 정리해보겠습니다.

    구현 목표

    • 알림을 보내는 것 자체는 '제3자 서비스'를 이용해야 한다. (문자 발송, 이메일 등..)
    • 이번 구현에서는 실제 서비스 대신 mock 객체를 이용하도록 한다.
    • 그러므로 제 3자 서비스에 요청을 보내고 받는 것 까지 구현을 목표로 한다.

     

    요구사항

    • 주문 자체는 알림의 성공/실패에 독립적이어야 한다. (트랜잭션 분리?)
    • 주문에 성공하면 주문 내용이 '늦더라도 언젠가'(eventual consistency) 알림으로 전송되어야 한다.
    • 알림은 반드시 1번 이상(at most once) 가게에 전달되어야 한다.

     

    가장 쉬운 방법

    고객이 주문을 완료하면 가게에서 이를 확인할 수 있도록 알림을 보내주어야 합니다.

    이를 구현하기 위해 가장 쉬운 방법은 직접적으로 알림 서비스를 호출하는 방법이 있겠습니다. (또는 알림API)

    주문쪽에서 이벤트를 발행하면 이벤트를 받아 제 3자 서비스에 요청을 보내는 구조입니다.

    주문과 이벤트 핸들러를 코드로 구현하면 간단하게 이렇게 될 것입니다.

    // 주문
    public class OrderService {
            private OrderRepository orderRepository;
            private final ApplicationEventPublisher publisher;
    
            @Transactional
            public Order takeOrder(OrderForm form) {
                    // 주문 생성
                    Order order = createOrder(form);
    
                    // 주문 성공 (저장)
                    orderRepository.save(order); // order를 저장하였고, 주문 책임을 다함
    
                    // 알림 이벤트 발행
                    publisher.publishEvent(new OrderedNotificationEvent(order));
                    return order;
            }
    }
    
    // 주문 이벤트 핸들러
    public class OrderedEventHandler {
            private NotificationService notificationService;
    
            @EventListener
            private boolean notifyOrder(OrderedNotificationEvent event) {
                    // 알림 구현 필요 ..
            }
    }

    문제점

    매우 간단하지만 문제가 있습니다.

    주문 서비스와 알림 서비스가 하나의 트랜잭션으로 묶여있다는 점입니다.

    • 주문이 완료되어도 알림 서비스가 실패하면 주문 기록까지 roll back 되어버립니다.
    • 또는 주문은 이미 완료되었음에도 알림 전송이 완료될 때 까지 기다려야 합니다.
    • (알림 전송은 외부 서비스에 의존해야하기 때문에 처리 시간이 오래 걸릴 수 있다.)

     

    Commit 시점과 이벤트 발행 시점

    코드를 보면 이벤트를 발행해서 처리했기 때문에 마치 주문과 알림이 분리된 것 처럼 보입니다.

    • '코드'는 분리가 되었습니다. 코드상으로 결합되지 않습니다.

     

    그러나 문제는 'commit 시점' 입니다.

    지금 이뤄지는 일련의 작업은 하나의 쓰레드에서 벌어지는 작업이므로 순차적으로 처리됩니다.

    이벤트 핸들러가 작업을 마쳐야 → 발행자인 takeOrder() 메소드로 돌아와서 → 메소드가 끝나는 시점에 트랜잭션이 commit 될 수 있습니다.

    데이터베이스에서 write 작업은 트랜잭션이 commit 되었을 때 완료된다는 점을 기억해야 합니다! 스프링에서는 @Transactional 이 메소드 시작에 트랜잭션을 열고 메소드가 끝나는 시점에 commit을 도와줍니다.

    이벤트 발행 시점 변경하기

    이 문제를 해결하기 위해 비동기 처리를 떠올릴 수 있지만, 현재 문제에 딱 맞는 해결은 아닌 듯 합니다.

    문제를 해결할 순 있지만, 다른 문제를 불러올 수도 있고 근본적인 해결책은 아니라고 생각했습니다. - 멀티 쓰레드를 활용해서 해결할 수도 있지만, 만약 하나의 쓰레드만 사용해야 한다면?

    근본적인 문제점은 위에도 언급했듯, 'commit하는 시점' 입니다. 더 정확하게 얘기하자면 이벤트 발행 시점과 commit 시점의 순서의 문제입니다.

    발행한 이벤트가 끝나야 commit이 된다는 점이 문제인데, 만약 commit을 먼저 하고 이벤트를 발행할 수 있다면 문제가 해결되지 않을까요?

     

    @TransactionalEventListener

    @TransactionalEventListener는 이벤트 발행자의 트랜잭션을 기준으로 EventListener 실행 시점을 조절할 수 있게 도와줍니다.

    phase 옵션을 통해 4가지 시점을 선택할 수 있습니다.

    • AFTER_COMMIT : 호출자의 트랜잭션이 commit된 후 이벤트를 발생시킵니다. (DEFAULT)
    • BEFORE_COMMIT : 호출자의 트랜잭션이 commit되기 직전에 이벤트를 발생시킵니다.
    • AFTER_COMPLETION : 호출자의 트랜잭션 commit의 성공여부와 관계없이 끝나면 실행시킵니다.
    • AFTER_ROLLBACK : 호출자의 트랜잭션이 rollback 된 후 실행시킵니다.

     

    저희는 디폴트 설정인 AFTER_COMMIT이면 충분할 것 같습니다.

    위와 같은 순서가 되었습니다.

    주의해야할 점은 여전히 하나의 트랜잭션으로 묶여있다는 점입니다.

    이게 무슨말이냐? 이미 주문을 저장소에 저장하고 commit 까지 마치지 않았냐? 어떻게 주문이 이벤트 핸들러와 독립적으로 저장된 것이냐?

    라고 생각하실 수 있습니다.

    OrderService의 takeOrder() 에서 만들어진 트랜잭션은 commit 되었지만, 트랜잭션이 사라진건 아니고, 이벤트 핸들러에서 이미 commit된 트랜잭션에 참여했기 때문입니다.

    기억해야할 점은 commit된 트랜잭션을 다시 재 commit하는 것은 불가능하지만, 트랜잭션에 참여하여 조회 등의 처리를 하는 것은 여전히 가능합니다.

    실제로 그런지 확인해보겠습니다.

    OrderService는 위의 코드와 유사하고, 이벤트 핸들러에서 order를 조회하고 상태를 변경해보았습니다.

    Spring Data JPA를 사용하고 있고, 영속성 컨텍스트가 더티 체킹을 해주기 때문에 별도의 save()나 업데이트 코드는 필요하지 않습니다.

    'Order' 엔티티

     

    이벤트 핸들러

    결과를 해보니 예상한대로 ORDER_STATUS는 변경되지 않았습니다.

     

    Write가 필요하면..?

    그런데 만약에 이벤트 핸들러 내부에서 write가 필요하다면 어떡할까요? 트랜잭션을 새로 만들면 됩니다!

    스프링에서는 @Transactional 을 사용해서 트랜잭션 처리를 해주게 되는데요, 하지만 우리의 이벤트 핸들러는 이미 호출자인 takeOrder()의 트랜잭션에 참여하고 있습니다. (위에와 같은 그림을 참고용으로 재탕)

    이럴땐 @Transactional의 propagation 수준을 REQUIRES_NEW 로 변경하면 됩니다.

    REQUIRES_NEW는 해당 메소드가 이전 트랜잭션을 이어받지 않고 새로운 트랜잭션에 참여하도록 하는 수준입니다. 디폴트인 REQUIRED는 외부에서 호출되면 새로운 트랜잭션을 만들고, 트랜잭션 내부에서 호출되면 기존의 트랜잭션을 이어받습니다.

     

    위의 코드와 다른점은 @Transactional(Transactional.Tx.Type.QEUIRES_NEW) 단 한줄입니다.

     

    실행해보면

    결과는 예상대로 변경된 값인 NOTIFIED가 되었습니다.

     

     

    위의 상황을 다시 그림으로 정리해보면 아래와 같습니다.

     

     

    이상 스프링에서 이벤트의 트랜잭션을 분리하는 방법이었습니다.

     

    감사합니다.

     


     

     

    Reference

    https://dzone.com/articles/transaction-synchronization-and-spring-application

    댓글

Designed by Tistory.