[E-commerce] 주문 결제를 이벤트 기반 아키텍처로 구축하기

 

주문 결제를 이벤트 기반 아키텍처로 구축하기

이 글에서는 주문 결제 시스템을 이벤트 기반 아키텍처로 전환하면서 겪었던 문제와 그 과정을 공유하고자 합니다. 

 

현재 이커머스 프로젝트의 주문 결제 시스템은 한 트랜잭션 내에서 동기 처리 방식으로 처리하고 있습니다.

 

 

현재 OrderUseCase를 간단히 살펴보면

사용자 조회 -> 상품 조회 -> 상품 재고 조회 -> 주문 -> 결제 -> 재고 차감 순으로 주문 결제 시스템이 이루어져 있습니다.

 

그럼 이 주문 결제 시스템을 이벤트 기반 아키텍처로 구축하면서 겪었던 문제와 과정을 정리해 보겠습니다.

 

이벤트 기반 아키텍처로 변경하는 이유

결합도 감소

현재 주문 결제 시스템은 상품 재고 관련 로직, 결제 로직이 주문 로직에 깊게 관여되어 도메인끼리 강한 결합을 갖고 있다는 문제가 있습니다.
그래서 현재 주문 결제 시스템을 간단하게 이벤트 기반 아키텍처로 수정하면 아래와 같이 동작합니다.

 

주문이 성공적으로 생성되면 이벤트를 발행한다.

결제 시스템은 이 주문 이벤트를 구독하고 있어 주문 이벤트가 도착하면 결제 시스템을 진행한다.

 

주문 시스템은  주문이 생성되었을 때 주문 이벤트를 발생시켰을 뿐 결제 비즈니스에 관여하지 않은 것을 확인할 수 있습니다.

 

즉, 주문과 결제 시스템 간의 결합도를 느슨하게 만들 수 있다는 장점이 존재합니다.

 

독립적인 트랜잭션 관리

이벤트 리스너 단위로 트랜잭션을 관리할 수 있어, 기존 주문 결제 시스템의 트랜잭션을 분리하여 독립적으로 트랜잭션을 관리할 수 있습니다.

즉, 특정 로직에 문제가 생겼을 때 애플리케이션 전체 시스템에 영향을 미치지 않고 해당 부분만을 격리시켜 문제를 해결할 수 있게 합니다.

 

비동기 처리

시스템 전체의 성능을 향상하며 사용자에게 빠른 응답을 제공할 수 있습니다.

예를 들어, 주문을 처리하고 이후의 결제 처리는 비동기적으로 수행하여 사용성을 증가시킬 수 있습니다.

 

 

결론적으로 이벤트 기반 아키텍처를 통해 시스템간의 결합도를 낮추고, 애플리케이션의 확장성을 향상시실 것을 기대합니다.

 

재고 차감은 어느 시점??

우선 이벤트 기반으로 리팩터링 하기 전에 주문 결제 시스템에서 상품의 재고 차감은 어느 시점에 해야 할지 고민을 했습니다.

 

크게 두 가지 케이스를 생각했습니다.
1. 결제 성공 이후 재고 차감
2. 결제 이전 재고 차감

 

첫 번째 케이스 경우 결제 시 에러가 발생해도 재고를 아직 차감하지 않았기 때문에 별도의 재고 보상 트랜잭션 구현이 필요하지 않습니다.


그런데 아래와 같은 문제가 있을 수 있다고 생각했습니다.

 

A 사용자가 재고가 2개인 상품을 1개 주문 이후 결제 진행

B 사용자가 동일 상품을 2개 주문 이후 A 사용자 보다 먼저 결제 완료 (재고 차감)

A 사용자 결제 완료 하지만 재고 부족 에러

 

결제는 완료했는데 재고가 부족하여 에러가 발생했기 때문에, A 사용자의 결제에 대한 보상 트랜잭션을 구현해줘야 합니다.

(만일 여기서 외부 pg사를 연동하여 결제 시 결제 보상에 대한 제어권을 온전히 내가 컨트롤할 수 없을 것 같은 문제가 있을 것 같다고 생각했습니다.)

 

두 번째 케이스 경우 결제 시 에러가 발생하면 차감된 재고에 대해 보상 트랜잭션을 구현해줘야 하는데, 이는 결제 이전 재고 롤백을 온전히 컨트롤할 수 있는 범위이기 때문에 결제 이전에 재고 차감을 하는 방향으로 계획했습니다.

 

이벤트 기반으로 리팩터링

우선 간단하게 이벤트를 발행하면 해당 이벤트를 구독하는 형태로 구현해 봤습니다. (Spring의 ApplicationEventPublisher을 이용하면 간단하게 구현 가능합니다.)

기존 order 메서드에서 결제와 상품 재고 관련 로직과 결합을 제거해 주었습니다.

 

주문 메서드는 결제, 상품 재고 처리는 어떻게 되는지는 모르겠고 주문은 오직 주문만 하고 "주문이 되었다"는 이벤트를 발행하도록 변경을 하였습니다. (applicationEventPublisher.publishEvent() 메서드)

여기서 고민했던 점은 user 조회나 product 조회도 별개의 이벤트로 분리해서 이벤트를 받는 형식으로 하는 게 맞지 않을까?라는 생각을 했었습니다.

하지만 단순히 조회하는 로직을 별도의 이벤트로 분리한다면 오히려 애플리케이션이 복잡해진다는 생각을 했고, 주문할 때 사용자 정보와 상품 정보는 반드시 필요하기 때문에 사용자 조회, 상품 조회를 주문 시 동기적으로 처리하기 위해 별도의 이벤트로 분리하지 않았습니다.

 

주문이 완료된 이벤트 구독하고 있는 상품 재고 처리 이벤트 리스너

 

발행된 주문 이벤트를 받아 상품 재고를 조회하고, 요청한 수만큼 재고를 감소시킨 다음 정상 처리 되었을 경우 재고 차감 이벤트를 발행합니다.

 

재고 차감 이벤트를 구독하고 있는 결제 처리 이벤트 리스너

 

재고 차감 이벤트가 발행되면 결제 시스템 이벤트 리스너가 실행되어 재고 차감 이벤트에서 사용자 정보, 주문, 주문 요청 정보를 바탕으로 결제를 진행하고 결제가 완료되었으면 결제 완료 이벤트를 발행합니다.

 

결제 이벤트를 구독하고 있는 상품 재고 정합성을 위한 이벤트 리스너

 

이후 마지막으로 결제 완료 이벤트를 구독하고 있는 곳에서는 상품 테이블과 상품 재고 테이블의 정합성을 맞춰주기 위해 상품 테이블의 재고도 감소시킵니다.

 

위와 같이 이벤트 리스너를 이용하여 리팩터링을 진행한 후 기존 작성했던 상품 주문 시 사용자의 잔액 부족 에러가 발생했을 때 재고 롤백에 대한 테스트를 진행해 보았습니다.

 

재고 롤백 테스트 성공 이유? 

@Test  
@DisplayName("잔액 부족으로 인한 실패 시 재고 및 잔액 롤백 테스트")  
void when_not_enough_user_point_then_roll_back_product_stock() {  
    // Given  
    Long userId = 1L;  
    Long productId = 1L;  
    Long productOrderQuantity = 1L;  
    Long paymentAmount = 999_999L;  
  
    OrderRequest request = new OrderRequest(  
            ...
    );  
  
    // When && Then  
    assertThrows(IllegalArgumentException.class, () -> {  
        orderUseCase.order(userId, request);  
    });  
  
    User user = userService.getUser(userId);  
    assertThat(user.point()).isEqualTo(5000L);  

    // 기존 1L 아이디 값의 product 재고는 5개
    Product product = productService.getProductDetail(productId);  
    assertThat(product.stockQuantity()).isEqualTo(5);  
}

 

기존에 작성했던 잔액 부족으로 인한 주문 실패 시 결제 전 차감된 재고가 롤백되는지에 대한 테스트입니다.

 

이벤트 리스너 형식으로 변경했을 때 저는 위 테스트가 실패하는 것을 예상했습니다.

그 이유는 order() 메서드에만 @Transactional을 명시해 줬기 때문에 다른 이벤트 리스너들은 트랜잭션 내에서 실행되지 않는다 생각했고, 재고에 대한 보상 트랜잭션을 아직 구현하지 않은 상태이기 때문입니다.

그런데 잔액이 부족하여 에러가 발생해도 롤백이 정상적으로 이루어져 테스트가 통과했습니다.

그 이유가 무엇인지 확인하기 위해 재고 차감 이벤트 리스너에서 현재 트랜잭션이 작동하고 있는지 로깅을 해보았습니다.

 

 

위와 같이 현재 트랜잭션 상태와 트랜잭션 이름의 로그 결과를 보면 현재 트랜잭션이 active 상태이고, 트랜잭션의 이름이 OrderUseCase 클래스의 order() 메서드에서 선언해 준 @Transactinal인 것을 알 수 있습니다.

 

[Test worker] c.h.e.a.e.product.ProductStockListener: Transaction active: true
[Test worker] c.h.e.a.e.product.ProductStockListener: Transaction name: com.hanghae.ecommerce.application.order.OrderUseCase.order

 

@EventListener 메서드에 별도의 @Transactional을 선언하지 않고 실행하면, 주 트랜잭션 (여기서 order() 메서드의 @Transactional) 이 전파된다는 것을 유추할 수 있습니다.

 

좀 더 정확히 보기 위해 JpaTransactionManager의 로깅 레벨을 debug로 변경하고 좀 더 자세히 디버깅해보았습니다.

 

 

위와 같이 재고 차감 이벤트가 시작하면 구분 짓기 위해 로그를 찍어주고 테스트를 다시 실행해 보았습니다.

 

[Test worker] o.s.orm.jpa.JpaTransactionManager: Creating new transaction with name [com.hanghae.ecommerce.application.order.OrderUseCase.order]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT

[Test worker] o.s.orm.jpa.JpaTransactionManager: Opened new EntityManager [SessionImpl(1761937072<open>)] for JPA transaction

[Test worker] o.s.orm.jpa.JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@3829edd5]

 

우선 위 로그를 보면 OrderUseCase의 order() 메서드가 시작될 때 새로운 트랜잭션을 생성하는 것을 알 수 있습니다.

 

 

그리고 로그 내용을 더 살펴봅시다.

 

재고 차감 이벤트가 시작 전과 이후의 로그를 확인해 보면 새로운 트랜잭션을 만들지 않고, EntityManager를 찾고 이미 존재하는 트랜잭션에 participating 한 것을 볼 수 있습니다.

 

그리고 결제 이벤트 시작하기 전 또 동일한 트랜잭션에 참가하여 예외가 발생하여 트랜잭션을 롤백하고 종료하는 것을 확인할 수 있습니다.

즉, 이벤트 리스너에 별도의 트랜잭션을 명시해주지 않으면 기존 트랜잭션에 결합(order() 메서드의 트랜잭션 전파)되어 실행이 되는 것을 알 수 있습니다.

 

독립적인 트랜잭션 단위

현재 구조는 각 서비스 간 결합도는 낮췄지만 하나의 트랜잭션에서 동기적으로 처리하고 있습니다.

이벤트 기반으로 구조를 변경했을 때 얻을 수 있는 이점 중 결합도 감소적인 측면은 달성했다고 말할 수 있지만 비동기 처리와 독립적인 트랜잭션 관리는 아직 달성하지 못했습니다.

이제 이벤트 리스너 단위로 트랜잭션을 독립적으로 관리하도록 리팩터링을 진행하겠습니다.

 

TransactionalEventListener

우선 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)를 사용하면 해당 이벤트를 발행한 트랜잭션이 성공적으로 commit 된 경우 이벤트를 실행하도록 설정할 수 있습니다.

TransactionalEventListener 어노테이션을 살펴보면 4가지 phase 타입이 존재합니다.

  • BEFORE_COMMIT (트랜잭션이 커밋되기 직전에 이벤트 리스너를 실행시킨다.)
  • AFTER_COMMIT (트랜잭션이 커밋된 이후에 이벤트 리스너를 실행시킨다. 이때 리스너에서 에러가 발생해도 이미 커밋이 되었기 때문에 이벤트를 발행한 주체는 롤백이 되지 않는다.)
  • AFTER_ROLLBACK (트랜잭션이 실패하고 롤백된 후에 이벤트 리스너를 실행시킨다.)
  • AFTER_COMPLETION (트랜잭션이 커밋되거나 롤백된 후에 이벤트 리스너를 실행시킨다.)

 

TransactionalEventListener 기본 phase 값은 AFTER_COMMIT으로 트랜잭션이 커밋이 되면 이벤트 리스너를 실행시키도록 합니다.

 

그럼 주문의 이벤트를 받는 재고 차감 이벤트 리스너는 어떤 옵션을 사용해야 할까?

상품 주문이 정상적으로 처리되었을 경우에만 재고 차감 및 결제 이벤트가 실행되는 게 맞다고 생각하여 AFTER_COMMIT으로 설정을 하여 주문이 커밋된 이후 재고 차감 리스너를 실행하도록 하였습니다.

 

 

재고 차감 이벤트의 이벤트 리스너를 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)으로 변경 후 이전 재고 롤백 테스트를 다시 실행하여 로그를 확인해 봅시다.

 

주문 생성 이벤트 발행된 이후에 해당 트랜잭션을 커밋하고 재고 차감 이벤트를 시작하는 것을 확인할 수 있습니다.

 

그런데 재고 차감 이벤트에서 TransactionRequiredException 에러가 발생했습니다.

 

 

에러 내용을 확인해 보면 쿼리 작업을 하기 위한 transactions이 없다는 것을 명시해주고 있습니다.

 

이는 이전 주문 생성 단계에서 커밋을 했기 때문에(Committing JPA transaction on EntityManager) 기존 트랜잭션이 종료되어, 재고 차감 이벤트 시점에는 트랜잭션이 존재하지 않아 발생한 에러로, 어떻게 보면 독립적인 트랜잭션 구성을 위해 올바른 에러라고 볼 수 있습니다.

그럼 재고 차감 이벤트 리스너에 @Transactional을 명시하고 다시 테스트를 실행해 보니 아래와 같은 에러를 볼 수 있습니다.

Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanInitializationException: Failed to process @EventListener annotation on bean with name 'productStockListener': @TransactionalEventListener method must not be annotated with @Transactional unless when declared as REQUIRES_NEW or NOT_SUPPORTED: public void com.hanghae.ecommerce.api.eventlistener.product.ProductStockListener.onProductStockUpdate(com.hanghae.ecommerce.domain.order.event.OrderCreatedEvent)

org.springframework.beans.factory.BeanInitializationException: Failed to process @EventListener annotation on bean with name 'productStockListener': @TransactionalEventListener method must not be annotated with @Transactional unless when declared as REQUIRES_NEW or NOT_SUPPORTED: public void com.hanghae.ecommerce.api.eventlistener.product.ProductStockListener.onProductStockUpdate(com.hanghae.ecommerce.domain.order.event.OrderCreatedEvent)

 

@TransactionalEventListener 어노테이션과 @Transactinal을 같이 사용할 때는 반드시 REQUIRES_NEW 또는 NOT_SUPPORTED를 선언해서 사용하라는 친절한 에러 메시지를 확인할 수 있습니다.

@Transactinal 어노테이션을 선언할 때 Propagation(전파) 방식을 설정할 수 있는데 현재 트랜잭션의 존재 여부와 상태에 따라 트랜잭션이 어떻게 동작할지 결정할 수 있습니다.

 

Transactinal 전파 방식

REQUIRED (기본값): 현재 활성 트랜잭션이 있으면 참여하고, 없으면 새로운 트랜잭션을 시작합니다.
REQUIRES_NEW: 항상 새로운 트랜잭션을 시작하며, 이미 진행 중인 트랜잭션이 있다면 일시 중지합니다.
SUPPORTS: 트랜잭션이 이미 시작되어 있으면 참여하고, 없으면 트랜잭션 없이 실행합니다.
MANDATORY: 반드시 진행 중인 트랜잭션이 있어야 하며, 없다면 예외를 발생시킵니다.
NOT_SUPPORTED: 트랜잭션을 사용하지 않으며, 진행 중인 트랜잭션이 있다면 일시 중지합니다.
NEVER: 트랜잭션을 사용하지 않으며, 진행 중인 트랜잭션이 있다면 예외를 발생시킵니다.
NESTED: 현재 트랜잭션이 있으면 중첩된 트랜잭션을 시작하고, 없으면 새 트랜잭션을 시작합니다.

 

그럼 여기서 REQUIRES_NEW 설정을 통해 새로운 트랜잭션을 시작하도록 만들면 재고 차감 이벤트에 대해 새로운 트랜잭션이 만들어져 해당 트랜잭션 내에서 관리되도록 할 수 있습니다.

 

위와 같이 @Transactional(propagation = Propagation.REQUIRES_NEW)을 명시해 주고 다시 테스트를 실행해 보겠습니다.

 

주문 생성 이후 트랜잭션을 커밋하고, onProductStockUpdate 이벤트 리스너 호출 시 새로운 트랜잭션이 생성된 것을 확인할 수 있습니다.

 

이렇게 각각의 이벤트 리스너가 독립적인 트랜잭션 단위에서 동작하도록 변경할 수 있습니다.

 

정리

주문 시스템은 재고 차감 시스템과 결제 시스템과 decoupling 시키면서 독립적인 트랜잭션에서 관리 할 수 있도록 이벤트 기반으로 리팩터링을 진행해 보았습니다.

 

아직 구현하지 못한 결제 시스템 비동기 처리 및 재고 차감 후 결제 실패 시 재고에 대한 보상 트랜잭션을 구현하여 테스트 까지 진행하는 것을 다음 작업 시 진행해보겠습니다.