본문 바로가기

개발/루퍼스(Loopers)

ApplicationEvent 를 활용해 Facade 경량화 하기

반응형

나는 E-commerce 개발을 하면서 Application Layer에 점점 비대해진 Facade를 마주하게 되었다.

특히 CreateOrder와 같은 기능은 주문 생성이라는 하나의 유스케이스 안에서 여러 책임을 동시에 처리하고 있었다. 

createOrder()
 ├── 재고 차감
 ├── 포인트 차감
 ├── 결제 요청
 └── 주문 저장

 

아래와 같이 주문 생성 과정에는 재고, 포인트, 결제, 주문 저장까지 서로 성격이 다른 작업들이 한 번에 엮여 있었다.

 

이러한 구조에서는 내부 도메인 로직뿐만 아니라 PG와 같은 외부 모듈 호출까지 하나의 트랜잭션 안에서 함께 처리되었다.

 

그 결과, 트랜잭션의 경계를 명확히 나누기 어려웠고, PG 장애와 같은 외부 요인으로 인해 결제 요청이 실패하면 이미 검증이 끝난 주문 데이터조차 저장되지 않는 문제가 발생했다.

 

이 문제를 해결하기 위한 핵심 전략은 트랜잭션을 분리하기로 하였다.

 

모든 처리를 하나의 흐름으로 묶는 대신,
지금 반드시 성공해야 하는 작업
조금 늦게 처리되어도 되는 작업을 나누기로 했다.

  • 주문 생성, 결제 금액 계산, 유효성 검증 → 핵심 트랜잭션
  • 결제 요청, 포인트 적립, 외부 시스템 연동 → 후속 트랜잭션

이렇게 흐름을 분리하면서
Application Layer는 모든 책임을 직접 수행하지 않고,
도메인 이벤트를 발행하는 역할에 집중하도록 설계할 수 있었다.

@Slf4j
@Service
@RequiredArgsConstructor
public class OrderFacade {

    @Transactional
    public Order createOrder(String userId, List<OrderItem> orderItems){

        BigDecimal totalAmount = orderItems.stream()
                .map(OrderItem::calculateTotalPrice)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
					//1. 상품 조회
                    //2. 재고 부족 시 예외 처리
                    //3. 재고 차감
        			//4. 포인트 차감
			        //5. 주문 생성
        			//6. 주문 생성 이벤트 발행
        OrderCreatedEvent event = OrderCreatedEvent.from(order);
        eventPublisher.publishEvent(event);
        log.info("주문 생성 이벤트 발행: {}", order.getId());
        return order;
    }
}

 

코드 단에서 PG 호출을 직접하는 대신 나는 OrderCreatedEvent를 발행하였고 

@Slf4j
@Component
@RequiredArgsConstructor
public class OrderEventHandler {

    private final ApplicationEventPublisher eventPublisher;

    @TransactionalEventListener(phase = AFTER_COMMIT)
    @Async
    void handleOrderCreatedEvent(OrderCreatedEvent orderEvent) {
        log.info("주문 생성 이벤트 처리 - orderId: {}, eventId: {}",
                orderEvent.orderId(), orderEvent.eventId());

        try {
            // 1. 결제 요청 이벤트 생성
            PaymentRequestedEvent paymentEvent = PaymentRequestedEvent.from(orderEvent);

            // 2. 이벤트 발행
            eventPublisher.publishEvent(paymentEvent);

            log.info("결제 요청 이벤트 발행 - orderId: {}", orderEvent.orderId());

        } catch (Exception e) {
            log.error("주문 생성 이벤트 처리 실패 - orderId: {}, error: {}",
                    orderEvent.orderId(), e.getMessage(), e);
        }
    }
}

OrderEventHandler에서는 CreateOrder가 완료된 이후,
결제에 필요한 정보를 담아 결제 요청 이벤트(PaymentRequestedEvent) 를 생성하도록 했다.

//결제 요청 이벤트
public record PaymentRequestedEvent(
        String eventId,
        String orderId,
        String userId,
        BigDecimal amount,
        String cardType,
        String cardNo

) {
    public static PaymentRequestedEvent from(OrderCreatedEvent event) {
        return new PaymentRequestedEvent(
                UUID.randomUUID().toString(),
                event.orderId(),
                event.userId(),
                event.finalAmount(),
                "VISA",
                "4111-1111-1111-1111"
        );
    }
}

 

여기 부분에서 고민했던 것이 바로 결제 요청 이벤트(PaymentRequestedEvent)에 어떤 정보를 담을 것인가였다. 처음에는 Payment API에서 전달받는 카드 정보(카드 타입, 카드 번호 등)를 그대로 이벤트 흐름에 포함시키려 했다. 하지만 Order 도메인이 Payment 도메인의 내부 구조를 알게 되면, 주문 생성이라는 본래의 책임을 벗어나 결제의 세부사항에까지 관여하게 되게 DDD 원칙을 위배한다고 생각했다. 

 

그리하여 코드와 같이 Stub 역할을 하는 데이터를 넣어놓았다. 

 

추가적으로 나는 이벤트 기반으로 결제 결과에 따른 주문 처리를 분리하여 결제가 성공했는지 실패했는지 사실만 이벤트로 발행하게 하고, 주문 상태 업데이트와 같은 작업은 별도의 이벤트 핸들러를 통해 처리하도록 분리하였다. 

    @Transactional
    public void updatePaymentStatus(String transactionKey, String name, String reason) {
        PaymentStatus status = PaymentStatus.valueOf(name);

        Payment payment = paymentRepository.findByTransactionKey(transactionKey)
                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제 정보를 찾을 수 없습니다."));

        payment.updateStatus(status, reason);

        // ---- 이벤트 발행 ----
        if (status == PaymentStatus.SUCCESS) {
            PaymentSuccessEvent event = PaymentSuccessEvent.from(payment);
            eventPublisher.publishEvent(event);
        } else if (status == PaymentStatus.FAILED) {
            PaymentFailedEvent event = PaymentFailedEvent.from(payment);
            eventPublisher.publishEvent(event);
        }
    }
@Slf4j
@Component
@RequiredArgsConstructor
public class PaymentEventHandler {

    private final OrderRepository orderRepository;

    /**
     * 결제 성공 이벤트 처리
     */
    @TransactionalEventListener(phase = AFTER_COMMIT)
    @Async("eventTaskExecutor")
    public void handlePaymentSuccess(PaymentSuccessEvent event) {
        log.info("결제 성공 이벤트 처리 시작 - orderId: {}", event.orderId());
        try {
            Long orderId = Long.valueOf(event.orderId());

            Order order = orderRepository.findById(orderId)
                    .orElseThrow(() -> new IllegalStateException("Order not found: " + event.orderId()));

            order.markAsConfirmed();
            orderRepository.save(order);

            log.info("결제 성공 처리 완료 - orderId: {}", orderId);

        } catch (Exception e) {
            log.error("결제 성공 후속 처리 실패 - orderId: {}, error: {}",
                    event.orderId(), e.getMessage(), e);
        }
    }

    /**
     * 결제 실패 이벤트 처리
     */
    @TransactionalEventListener(phase = AFTER_COMMIT)
    @Async("eventTaskExecutor")
    public void handlePaymentFailed(PaymentFailedEvent event) {
        log.info("결제 실패 이벤트 처리 시작 - orderId: {}, reason: {}", event.orderId(), event.failureReason());
        try {
            Long orderId = Long.valueOf(event.orderId());

            Order order = orderRepository.findById(orderId)
                    .orElseThrow(() -> new IllegalStateException("Order not found: " + event.orderId()));

            order.markedAsCancelled(event.failureReason());
            orderRepository.save(order);

            log.info("결제 실패 처리 완료 - orderId: {}", orderId);

        } catch (Exception e) {
            log.error("결제 실패 후속 처리 실패 - orderId: {}, error: {}",
                    event.orderId(), e.getMessage(), e);
        }
    }
}

 

    public void markAsConfirmed() {
        this.status = OrderStatus.CONFIRMED;
    }

    public void markedAsCancelled(String s) {
        this.status = OrderStatus.CANCELLED;
    }

 

이를 통해 주문이 무엇인지, 어떤 상태로 바꿔야 하는지에 대해 Payment가 관여하지 않게 만들어주었다. 이는 디커플링 관점에서 매우 유리하게 만들어주었다.

 


/**
 * 결제 성공 이벤트
 * - 결제가 성공적으로 승인되었을 때 발행
 */
public record PaymentSuccessEvent(
        String eventId,
        Long paymentId,
        String transactionKey,
        Long orderId,
        String userId,
        BigDecimal amount,
        LocalDateTime completedAt
) {

    public static PaymentSuccessEvent from(Payment payment) {
        return new PaymentSuccessEvent(
                UUID.randomUUID().toString(),
                payment.getId(),
                payment.getTransactionKey(),
                parsedOrderId,
                payment.getUserId(),
                payment.getAmount(),
                LocalDateTime.now()
        );
    }
}

추가적으로 PaymentSuccessEvent / PaymentFailedEvent 를 생성할 때, UUID.randomUUID() 와 LocalDateTime.now() 를 함께 전달하도록 하였는데 이를 통해 잘못된 중복 처리로 인해 주문 상태가 여러 번 바뀌는 문제를 막을 수 있어 멱등성을 확보하였고, 이벤트 발생 시간을 기록하여 추적을 남길 수 있었다. 

 

이벤트 기반 구조로 재설계한 이후, 여러 책임이 뒤섞여 있던 Facade는 훨씬 가벼워졌고, 결제/후속 처리 실패가 주문 트랜잭션에 영향을 주지 않는 안정적인 구조로 바뀌었다.

반응형