주문 상태 동시성 이슈 해결

hongo·2024년 4월 24일
1

반려 물고기 판매 서비스인 펫쿠아를 개발하면서, 주문 상태를 변경할 때 발생한 동시성 이슈를 해결한 방법을 기록해보려고 한다.

주문에는 결제 완료, 환불 요청과 같이 주문 상태가 존재한다.
주문 상태는 가변적인 필드이고, 주문 상태의 변경 흐름을 추적하기 위해 DB에
다음과 같이 저장하고 있다.

주문이 변경될 때마다 주문 상태 테이블에 새로운 주문 상태를 생성한다.
그리고 주문의 상태를 조회할 때는 가장 최근에 생성된 주문 상태를 조회하는 방식을 사용하고 있다.

사진을 보면 order_id가 1인 주문의 경우, 주문 접수 상태에서 환불 요청 상태로 변경된 것을 볼 수 있다.

동시성 이슈

주문 상태 변경 요청이 동시간대에 들어올 경우 문제가 발생한다.

비즈니스 규칙상 환불 요청이 들어온 주문은 배송을 진행할 수 없다.
order_id가 1인 주문에 대해 동시간에 환불 요청배송 준비 중 요청이 들어왔다고 해보자.

  • 배송 준비 중이 커밋되지 않은 시점이라면, 환불 요청 로직 유효성 검증이 통과된다.
  • 검증을 통과한 환불 요청이 커밋된다.
  • 배송 준비 중이 이어서 커밋된다.

그렇다면 주문 상태는 다음과 같이 저장된다.

유저는 환불 요청을 하고 성공 응답을 받았지만, 배송 준비 중 또한 성공적으로 반영되어 환불된 주문에 대해 배송을 하게 될 수 있다.

해결 방안 - prevId 컬럼을 추가해 동시성 이슈 해결

동시성 이슈를 해결할 방법으로, 주문 상태 테이블에 prevId라는 컬럼을 추가하는 방법을 생각했다.

prevId라는 유니크 컬럼으로 만들고, 주문 상태를 저장할 때마다 해당 주문의 이전 주문 상태 Id를 prevId에 저장한다.

사진을 보면, 해당 주문의 이전 주문 상태가 존재하지 않는 Id 1, 2번 주문 상태의 경우 prevId를 null로 저장한다.
Id가 3인 주문 상태의 경우, 해당 주문(order_id = 1)의 이전 상태를 저장하고 있는 Id가 1인 것을 볼 수 있다. 때문에 prevId를 1로 저장한다.

(prevId = 같은 orderId를 가지는 최근 row의 id)

이 때, prevId가 unique이므로 동시간대에 한 주문에 대해 여러 개의 주문 상태 변경 요청이 들어올 경우 하나의 요청만 성공적으로 insert되고, 나머지 요청은 unique 조건에 의해 실패한다. 한 주문만 성공 처리를 하며 동시성 이슈를 해결하게 된다.

unique 조건에 의해 예외가 발생할 경우 DataIntegrityViolationException이 발생한다. Repository에 해당 예외를 처리하는 saveOrThrow 메서드를 만들어, 주문 상태 저장시에는 해당 메서드를 사용해 저장하고 있다.

fun OrderPaymentRepository.saveOrThrow(
    orderPayment: OrderPayment,
    exceptionSupplier: () -> Exception = { IllegalArgumentException("${OrderPayment::class.java.name} entity 를 저장할 수 없습니다.") },
): OrderPayment {
    try {
        return save(orderPayment)
    } catch (e: DataIntegrityViolationException) {
        throw exceptionSupplier()
    }
}

interface OrderPaymentRepository : JpaRepository<OrderPayment, Long> {

    ...
}

해결 방안? - 주문 상태 변경 요청 시간을 저장

prevId를 저장하는 것보다 더 좋은 방법이 있을까? 싶어서 이것 저것 생각해봤다.

변경 요청 시간을 저장해두는 방식도 생각해보았다. 주문 요청이 응답까지 걸리는 시간을 최대 2분으로 지정하고, 각 주문 상태 변경에 대한 요청 시간을 캐싱해둔다. (ttl - 2분)

그리고 주문 상태 변경 요청이 들어올 때마다, 캐싱된 데이터를 확인하며 동시성 이슈를 판단한다.

@Transactional
class OrderService(
    private val orderStatusRepository: OrderStatusRepository,
  	private val redisTemplate: redisTemplate,
) {
  
  fun updateOrderStatus(request: UpdateOrderStatusRequest) {
    // (1)
    val requestTime = 요청 시각
		redisTemplate.saveOrderStatusRequest(request) // (a) compltedTime = ""
    // (2)
    orderStatusRepository.updateOrderStatus(request.toCommand()) // (b)
    // (3)
    redisTemplate.completeOrderStatusRequest(request, requestTime) // (c) completedTime변경
  } 
}
  • (a) 실행 중, completedTime이 ""인 요청이 이미 레디스에 존재한다면 실패 처리 → 동시간대에 들어와서 아직 커밋이 안된 주문 요청이 존재한다고 판단.
  • (c) 실행 중, 자신의 requestTime보다 더 큰 completedTime을 가지는 데이터가 레디스에 있다면 실패 처리 → 동시간대에 들어와서 먼저 커밋된 요청이 있다고 판단.

prevId보다 더 복잡하고 불확실한 방법이라 생각해 prevId 방식을 적용했다.

0개의 댓글