파사드에 이벤트키토김밥 말아주기

묘니·2025년 8월 29일
0

주문-결제 시스템에서 강한 결합을 이벤트로 풀어낸 이야기

문제 상황: 파사드의 뚱뚱함

현재 이커머스 프로젝트에 다양한 기능이 추가되고, 외부 PG 연동도 하면서 주문과 결제의 트랜잭션은 분리되었지만 여전히 주문 파사드에서는 다양한 연관 도메인들의 작업이 하나의 트랜잭션으로 묶여있는 상황이었다.

@Transactional
public void completeOrder(OrderCriteria.Complete criteria) {
    // 포인트 차감 - 핵심 기능
    pointService.deduct(criteria.toPointDeductCommand(orderInfo.pointAmount()));
    
    // 쿠폰 사용 - 외부 시스템 호출로 인한 지연 발생
    couponService.useCoupons(criteria.userId(), orderInfo.couponIds());
    
    // 재고 차감 - 핵심 기능
    stockService.validateAndReduceStocks(criteria.toStockReduceCommands(orderInfo.items()));
    
    // 주문 상태 변경 - 핵심 기능
    orderService.completeOrder(criteria.orderId());
    
    // 외부 데이터 플랫폼 전송 - 부가 기능인데 실패하면?
    dataPlatformService.sendOrderData(orderInfo);
}

발견한 문제들

  1. 트랜잭션이 너무 길다: 쿠폰 시스템이 느려지면 주문 트랜잭션도 같이 느려짐
  2. 도메인 간 강한 결합: PaymentSyncFacade에서 OrderFacade를 직접 호출
  3. 실패의 연쇄 반응: 데이터 플랫폼 전송이 실패하면 주문 자체가 롤백
  4. 확장의 어려움: 새로운 후속 처리를 추가할 때마다 핵심 로직을 수정 필요

특히 PaymentSyncFacade의 코드에서 OrderFacade를 직접 호출하는 구조는 도메인 분리 관점에서도 수정이 필요했다.

// PaymentSyncFacade.java
@Transactional
public void processCompletedPayment(String paymentId) {
    Payment payment = paymentRepository.findByPaymentUuid(paymentId)
            .orElseThrow(() -> new IllegalArgumentException("Payment not found: " + paymentId));
    
    // Payment 도메인에서 Order 도메인을 직접 호출
    OrderCriteria.Complete criteria = new OrderCriteria.Complete(
        payment.getOrderId(),
        payment.getUserId(), 
        orderInfo.couponIds()
    );
    orderFacade.completeOrder(criteria); // 강한 결합!
}

왜 하필 쿠폰이었을까?

이번 주차의 과제에서 주어진 것은 쿠폰의 이벤트를 분리하라는 것이었다.
기왕 이벤트로 분리한다면 다 이벤트 기반으로 분리하는 것이 좋지만 그 중에서도 하나의 도메인을 선정했는데, 왜 하필 쿠폰이었을지 궁금했다.
다른 모든 도메인을 제치고 쿠폰이 선택된 이유를 나름 분석해봤다.

포인트: 단일 트랜잭션의 효율성

  • 사용자 데이터에 직접 종속
  • 예측 가능한 트래픽 패턴, 충돌 가능성 낮음

'주문 트랜잭션 내에서 포인트 사용을 함께 처리'하는 것이 효율적
복잡성을 늘리지 않으면서도 즉각적인 데이터 정합성을 보장

재고: 분리의 필요성 및 복잡성

  • 실제 자원과 직결되어 있어 분리가 필수적
  • 동시에 가장 높은 복잡성
  • 재고가 없으면 주문이 성립될 수 없으므로, 재고 차감 로직은 주문 생성과 밀접하게 연관

커맨드성

쿠폰: 이벤트 기반 아키텍처의 최적의 대상

  • 예측 불가능한 트래픽(플래시 세일, 선착순), 순간 부하 가능
  • 쿠폰 적용 실패가 주문 전체의 실패로 이어지지 않아야 비즈니스 연속성 유지
  • 쿠폰이 없어도 주문 성립 가능성 있음, 쿠폰은 주문과 별도의 비즈니스 로직을 가짐

⇒ 분리시 주문 로직의 안정성을 확보 가능
쿠폰 시스템의 장애가 발생해도 주문 자체는 유효한 점진적 실패(graceful degradation)가 가능하도록!

주문 트랜잭션과 쿠폰 사용 처리를 분리 - 쿠폰 실패가 주문을 막으면 안 됨
결제 결과에 따른 주문 처리를 분리 - 도메인 간 직접 호출 제거
데이터 플랫폼 전송을 후속처리로 - 분석용 데이터는 비동기로

이벤트 설계

SpringEventConfig로 config를 설정하는 방식도 있었지만, 현재 프로젝트의 규모상 기본 설정으로도 충분할 것 같아서 도입하지 않았다.

EventPublisher는 공통으로, DomainEvent라는 베이스 클래스를 만들고 각 이벤트 클래스가 확장할 수 있도록 했다. 또한 각 도메인 이벤트와 리스너는 도메인 레이어에 위치하도록 구현했다.

최종 설계 방향

  1. 주문 트랜잭션과 쿠폰 사용 처리를 분리 - 쿠폰 실패가 주문을 막으면 안 됨
  2. 결제 결과에 따른 주문 처리를 분리 - 도메인 간 직접 호출 제거
  3. 데이터 플랫폼 전송을 후속처리로 - 분석용 데이터는 비동기로

각 이벤트는 트랜잭션 커밋 후 실행되도록 @TransactionalEventListener를 적용했다.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)

이벤트 흐름도

주문 완료 요청
    ↓
[OrderFacade.completeOrder]
    ├─ 포인트 차감 (동기)
    ├─ 재고 차감 (동기)
    ├─ 주문 상태 변경 (동기)
    └─ OrderCompletedEvent 발행
            ↓
    트랜잭션 커밋
            ↓
    ┌───────┴────────────────┐
    ↓                        ↓
[CouponEventListener]  [DataPlatformListener]
(비동기, AFTER_COMMIT)  (비동기, AFTER_COMMIT)

0개의 댓글