티스토리 뷰

반응형

https://golf-dev.tistory.com/91

 

주문 부터 결제까지 사용자 상품을 잘 지켜주기 Part.1 설계

안녕하세요 ! 오랜만에 새로운 글로 찾아온 GOLF 입니다! 내용 요약GPT 발췌1. 문제 상황사용자가 제일 먼저 구매했음에도 결제까지 완료되지 않으면, 다른 사용자가 결제를 진행하여 상품을 선점

golf-dev.tistory.com

이전에 위 글에서 문제를 해결하기 위한 선점 매커니즘을 설계했습니다. 이제 실제로 구현을 해보겠습니다!

구현 하기전에

Spring Event란?

주문 같은 Aggregate는 자기 도메인 로직에만 집중하고, 주문 완료 후 재고 선점과 같은 처리는 Domain Event로 발행합니다.
Spring이 제공하는 ApplicationEventPublisher 와 @EventListener 덕분에 이런 Domain Event를 간단히 발행하고 구독 할 수 있습니다!
덕분에 포인트 적립, 알림 발송, 회계 처리처럼 다른 Aggregate나 외부 시스템과 연결된 작업들을 핵심 트랜잭션과 분리해서 안전하게 처리 할 수 있습니다.

결국, Spring Event는 Domain Event 패턴을 서비스에 자연스럽게 녹여낼 수 있게 도와주는 실질적인 도구 라고 보면 됩니다.

주문 API 구현

@Transactional
override fun order(ticketIds: List<Long>, userId: Long): CreateOrderResponseMessage {
    val tickets: List<Ticket> = ticketRepository.findAllByOrderId(ticketIds)

    validateItemStatus(tickets, ticketIds)

    val orderId = orderIdGenerator.generateOrderId()
    val order = Order.order(orderId, userId, tickets)

    // 선점 이벤트 시작
    eventPublisher.publishEvent(OrderCompleteEvent(orderId, ticketIds))

    val savedOrder: Order = orderRepository.save(order)
    return CreateOrderResponseMessage.from(savedOrder)
}

private fun validateItemStatus(tickets: List<Ticket>, ticketIds: List<Long>) {
    if (stockRepository.alreadyReserveByTicketIds(ticketIds)) {
        throw IllegalArgumentException("이미 선점중인 상품입니다.")
    }
    
    // 기타 검증 로직 ... //
}

 

1. 구매한 상품 중 이미 선점 중인 상품이 있는지 Redis에서 조회합니다. 만약 이미 선점되어 있다면 예외를 던집니다.

2. 선점중인 상품이 없다면 주문 정보를 생성합니다. 이후 주문 완료가 되면 응답 객체를 만들어 반환하고 선점 이벤트를 호출합니다. 

 

여기서 핵심은 선점 이벤트를 호출하여 주문 Transaction과 분리하는 건데요. 이유는 간단합니다. 재고 선점을 실패했을 때 주문도 실패한다면 Redis 장애가 주문 전체에 대한 장애로 이어질 수 있습니다. 

이는, 사용자에게 큰 불편을 느끼게 해줍니다. 재고 선점이 주문 로직에 영향을 주지 않게 하기 위해 Spring Event를 이용해서 주문 로직이 성공한 이후 동작할 수 있도록 수정하였습니다. 그렇다면 이제 Event Listener 로직을 살펴보겠습니다.

@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handleOrderCompleteEvent(event: OrderCompleteEvent) {
    log.info("선점 정보 저장 시작 : 주문 ID : {}", event.orderId)
    val result = kotlin.runCatching { stockRepository.reserveStock(orderId = event.orderId, itemIds = event.ticketIds) }

    result.onFailure {
        log.error("주문 ID : {}, 선점 실패 사유 : {}", event.orderId, it.message)
    }
}

 

발행 된 이벤트를 받아 주문을 선점합니다. 이 때 비동기로 동작하기 때문에 이슈 트래킹을 위해 orderId 를 기준으로 로그를 남깁니다.

결제 API 구현

@Transactional
override fun checkout(message: CheckoutRequestMessage): CheckoutCompleteResponseMessage {
    val order = orderRepository.findByIdAndUserId(message.orderId, message.userId)
    val tickets = ticketRepository.findAllByOrderId(order.orderItem.map { it.itemId })

    kotlin.runCatching { validateTickets(tickets, message) }
        .onFailure { publishFailureEventAndThrow(message, it) }

    eventPublisher.publishEvent(CheckoutSuccessEvent(order.orderId, tickets.map { it.id }))
    
    val payment = order.payment ?: createAndSaveNewPayment(order, message.paymentMethod)
    
    return CheckoutCompleteResponseMessage(order.orderId, order.amount, payment.idempotentKey)
}

 

1. 고객이 주문한 상품외에 다른 고객이 선점한 정보가 있다면 예외를 던집니다. 

2. 자신의 주문이 선점 중이라면선점 시간을 갱신합니다. 선점 중이아니라면 다시 한 번 선점 시도를 합니다.

3. 이후 결제를 완료 시킵니다.

 

이 때도 마찬가지로 선점 관련 로직은 모두 Spring Event로 checkout 트랜잭션과는 따로 로직이 돌 수 있도록 하였습니다. 

@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handleStockReservation(event: CheckoutSuccessEvent) {
    log.info("선점 정보 갱신 시작 : 주문 ID : {}", event.orderId)

    kotlin.runCatching { updateStockReservationProcess(event) }
        .onFailure { log.info("선점 실패 : {}", event.orderId) }
}

private fun updateStockReservationProcess(event: CheckoutSuccessEvent) {
    if (stockRepository.existsReserveByOrderId(event.orderId)) {
        stockRepository.updateTtl(event.orderId)
        return
    }

    retryReserveStock(event.orderId, event.ticketIds)
}

private fun retryReserveStock(orderId: String, ticketIds: List<Long>) {
    val reserveResult = stockRepository.reserveStock(orderId, ticketIds)

    if (!reserveResult) {
        throw IllegalArgumentException("상품 선점에 실패했습니다.")
    }
}

 

결제 정보 정산에 성공하면 결제 전까지 고객이 결제를 진행할 수 있도록 선점 정보를 갱신해주고 만약 주문에서 모종의 이유로 선점을 하지 못한 경우에 대비해 한 번 더 선점 시도 할 수 있도록 합니다. 

@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
fun handleOrderFailEvent(event: OrderFailEvent) {
    log.info("결제/checkout 실패 후 선점 롤백 시작 : {}", event.orderId)
    val result = kotlin.runCatching { stockRepository.cancelReserve(event.orderId) }

    result.onFailure {
        log.info("주문 ID : {} 선점 실패 사유: {}", event.orderId, it.message)
    }
}

 

만약 결제 정산에 실패하면 롤백을 하기 위해 재고 선점을 해제해줍니다. (장시간 선점되어 다른 고객들이 좌석이 있음에도 살 수 없는 현상을 방지하기 위함입니다.)

 

이후 결제 시엔 성공 또는 실패 시 모두 선점을 해제하여 고객들이 좌석을 원할하게 구매할 수 있도록 재고 선점을 지속적으로 관리해주고 있습니다. 

마무리

주문 부터 결제까지 재고를 지킬 수 있도록 선점 로직을 어떻게 구현하는지에 대해서 살펴봤습니다. 독자 여러분께 많은 도움이 되었으면 좋겠습니다. 

 

피드백은 대환영입니다!

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/12   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
글 보관함