[SPRING, DATABASE] 상품 주문 시, Pessimistic Lock으로 동시성 제어
안녕하세요. 오늘은 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번만 수행되어야 했지만, 비관적 락을 해제했을때는 여러번 주문이 수행되는 것을 확인 할 수 있고, 테스트에도 실패하는 것을 볼 수 있었습니다.