Spring

[SPRING, DATABASE] 상품 주문 시, Pessimistic Lock으로 동시성 제어

Hyung1 2022. 5. 14. 04:01
728x90
반응형

안녕하세요. 오늘은 Pessimistic Lock으로 동시성 제어를 하는 법에 관해 글을 작성해보려 합니다.

 

저는 상품을 주문하는 API를 개발하고 있었습니다. 여러 사용자가 동시다발적으로 상품을 주문 할 때, 데이터 정합성에 이슈가 발생하는 문제였습니다.

위의 그림처럼 세 명의 사용자가 동시다발적으로 주문을 요청할 시 상품의 총 재고는 70이 되어야하지만, 주문이 동시에 일어나는 바람에 70이 아닌 90이 되어버리는 상황입니다. 총 (10 + 10 + 10)30의 재고가 차감되어야 하지만, 동시에 요청이 일어나서 10의 재고만 차감이 되어버리는 이슈입니다.

이걸 어떻게 해결할 수 있을까요?

이것은 간단하게 JPA의 비관적 락(Pessimistic Lock), 혹은 낙관적 락(Optimistic Lock)으로 해결할 수 있었습니다. 비관적 락과 낙관적 락에 관해 간단하게 말씀을 드리겠습니다.

 

  • 비관적 락(Pessimistic Lock)
    • 트랜잭션 충돌이 발생한다고 가정하고 락을 우선 락을 걸고보는 방식입니다.
    • 데이터베이스에서 제공하는 락 기능을 사용합니다.
    • 데이터를 수정시, 즉시 트랜잭션 충돌을 감지할 수 있습니다.
  • 낙관적 락(Optimistic Lock)
    • 트랜잭션의 충돌이 발생하지 않는다고 가정하에 진행하는 방식입니다.
    • 데이터베이스에서 제공하는 Lock을 사용하지 않고 JPA가 제공하는 버전 관리 기능을 사용합니다.
    • 트랜잭션을 커밋하기 전까지는 트랜잭션의 충돌을 알 수 없다는 단점이 있습니다. 

그럼 비관적 락과 낙관적락 중에 어떤 것을 사용하는게 더 좋을까요?

낙관적락은 트랜잭션을 커밋하기 전까지는 트랜잭션 충돌을 확인하는 작업을 거치지 않기 때문에, 비관적락에 비해 성능이 더 좋습니다. 하지만 충돌이 났을 경우, 트랜잭션 롤백 과정을 개발자가 일일이 다 해주어야하는 큰 단점이 존재합니다. 또한 일일이 다 롤백을 처리해주는 과정에서 update 쿼리가 추가적으로 더 발생할 수도 있습니다. 이 때문에, 데이터의 정합성이 중요한 API에서는 낙관적 락보다는 비관적 락을 사용하는게 맞다고 생각하여 저는 비관적 락으로 동시성 제어를 하였습니다.

@Lock(LockModeType.PESSIMISTIC_WRITE)

비관적 락은 간단하게 @Lock 어노테이션으로 설정 할 수 있습니다.

 

PESSIMISTIC_READ

  • 해당 리소스에 공유락을 겁니다. 타 트랜잭션에서 읽기는 가능하지만 쓰기는 불가능합니다.

PESSIMISTIC_WRITE

  • 해당 리소스에 베타락을 겁니다. 타 트랜잭션에서는 읽기와 쓰기 모두 불가능해집니다.

상품을 주문하고 재고가 차감되는 과정의 쿼리는 아래와 같이 진행됩니다.

 

1. 아이템 조회

2. 해당 아이템의 재고 차감

 

따라서, 

interface ItemRepository : JpaRepository<Item, Long> {

    fun findAllByOrderByItemNumberDesc(): List<Item>

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    fun findByItemNumber(itemNumber: Long): Item?
}

 

따라서 아이템을 조회할 때 비관적락이 작동할 수 있도록 @Lock(LockModeType.PESSIMISTIC_WRITE)를 작성해주었습니다.

이제 테스트로 비관적 락이 잘 작동하는지 확인해보도록 하겠습니다.

class OrderServiceImplTest @Autowired constructor(
    private val itemService: ItemServiceImpl
) {

    @Throws(InterruptedException::class)
    @DisplayName("멀티스레드 환경에서 주문 동시성 제어 테스트")
    @Test
    fun orderWithConcurrency() {

        val numberOfThreads = 100
        val service = Executors.newFixedThreadPool(10)
        val latch = CountDownLatch(numberOfThreads)
        val successCount = AtomicInteger()
        val failCount = AtomicInteger()

        val order = Order(OrderStatusType.ORDER, LocalDateTime.now(), 0)
        val itemNumber = "768848"
        val count = "10"
        var checkSoldOut = false

        for (i in 0 until numberOfThreads) {
            service.execute {
                try {
                    itemService.inputItem(itemNumber, count, order, false)
                    successCount.getAndIncrement()
                    println("주문 성공")
                } catch (soldOutException: SoldOutException) {
                    failCount.getAndIncrement()
                    println("주문한 상품 재고량보다 큽니다.")
                }

                latch.countDown()
            }
        }

        latch.await()

        Assertions.assertEquals(successCount.get(), 4)
        Assertions.assertEquals(failCount.get(), numberOfThreads - 4)
    }

 

현재 아이템(M1 MAC)의 총 수량은 45개가 있습니다.

INSERT INTO item (item_name, item_number, price, stock_quantity)
VALUES ('M1 MAC', 768848, 21000, 45),

 

그리고 10개의 쓰레드에서 동시다발적으로 주문을 수행하도록 테스트코드를 작성하였습니다.

 

아이템의 재고가 45개이고 각 쓰레드는 10개씩 주문을 하기 때문에, 4번만 주문에 성공하고 나머지는 아래처럼 예외가 발생해야합니다.

주문한 상품 재고량보다 큽니다.

 

비관적락이 잘 작동하는 것을 볼 수 있습니다.

 

그럼 이제,

interface ItemRepository : JpaRepository<Item, Long> {

    fun findAllByOrderByItemNumberDesc(): List<Item>

    // @Lock(LockModeType.PESSIMISTIC_WRITE)
    fun findByItemNumber(itemNumber: Long): Item?
}

 

비관적 락을 해제하고 테스트를 진행했을때의 결과도 확인해 보도록 하겠습니다.

 

주문은 총 4번만 수행되어야 했지만, 비관적 락을 해제했을때는 여러번 주문이 수행되는 것을 확인 할 수 있고, 테스트에도 실패하는 것을 볼 수 있었습니다.

728x90
반응형