ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 재고차감 로직으로 알아보는 데이터베이스의 동시성 제어 방법
    카테고리 없음 2024. 12. 23. 18:57

     

    특정 시간에 무료로 상품을 나눠주는 이벤트 상황에 상품의 재고관리 시스템을 설계한다고 가정하고 어떻게 동시성 문제 없이 재고수량을 관리할 수 있을까? 

     

    이벤트 상황이라면 동시에 많은 사용자가 짧은 시간동안에 상품구매 페이지에 접속하고 동시에 여러명이 구매를 시도할것이다. 이런 상황에서 낙관적 락을 사용하면 충돌이 매우 많이 발생하고 재시도가 빈번하게 일어나므로 성능이 더 나빠질 수 있다.  따라서 비관적 락 방식을 사용하는게 적절하다. (만약 이벤트 상황이 아니여서 충돌이 많이 발생하지 않는게 보장된다면 라면 낙관적 락을 사용하는게 상품 조회시 유리하므로 적절하다고 생각함)

     

    재고차감 로직을 통해서 데이터베이스에서 동시성 제어를 하는 방법을 알아보자. 아래와 같은 테이블이 있다고 가정해보자. 

    @Table(name = "product")
    @Entity
    class Product(
        id: Long = 0,
        name: String,
        price: Long,
        stock: Long,
        createdAt: Instant = Instant.now(),
        updatedAt: Instant = Instant.now(),
    ) {
    
        init {
            if (name.isEmpty()) {
                throw ApiException("상품명을 입력하지 않았습니다.", ErrorType.INVALID_PARAMETER, HttpStatus.BAD_REQUEST)
            }
        }
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        var id: Long = id
            private set
    
        @Column(name = "name")
        var name: String = name
            private set
    
        @Column(name = "price")
        var price: Long = price
            private set
    
        @Column(name = "stock")
        var stock: Long = stock
            private set
    
        @CreationTimestamp
        @Column(name = "created_at", updatable = false)
        var createdAt: Instant = createdAt
            private set
    
        @UpdateTimestamp
        @Column(name = "updated_at")
        var updatedAt: Instant = updatedAt
            private set
    
        fun decreaseStock(amount: Long = 1) {
            if (stock <= 0) {
                throw IllegalArgumentException("No stock for product $id")
            }
            if (stock < amount) {
                throw IllegalArgumentException("Not enough stock for product $id")
            }
            stock -= amount
        }
    
        fun reviseProduct(name: String, price: Long, stock: Long) {
            this.name = name
            this.price = price
            this.stock = stock
        }
    }

     

     

     

    1. 트랜잭션 격리수준 조절 (SERIALIZABLE)

    SERIALIZABLE 레벨에서는 동시요청이 들어오더라도 데이터베이스 서버에서 순차적을 처리한다. 즉, 동시처리량이 가장 떨어지는 방식인데 재고차감시 동시성 문제는 발생하지 않는다. 모든 읽기 및 쓰기 작업에 Lock을 사용하는 격리수준이다. REPEATABLE READ와 동일하게 동작하지만 모든 조회 쿼리에 Shared Lock을 걸어서 데드락이 발생하고, 데드락 발생을 DB에서 주기적으로 감지하고 한개 이상의 트랜잭션을 종료시켜서 Lost Update를 방지한다.

     

    재고가 10개 남은 상황에서 트랜잭션 A는 3개를 차감하려고하고 트랜잭션 B는 5개를 차감하려는 상황을 가정하고 SERIALIZABLE에서 처리하는 방식을 알아보자.

     

    • 초기 상태: 상품 재고는 10장
    • 트랜잭션 A 시작: 트랜잭션 A는 티셔츠 재고를 조회하는데 이때  Serializable 격리 수준에서는 읽기 작업에도  잠금이(Shared Lock) 걸린다. 이 시점에서 다른 트랜잭션은 해당 재고 데이터를 조회만 가능하다.
    • 트랜잭션 B 시작: 트랜잭션 B도 티셔츠 재고를 읽으려고 시도하지만 트랜잭션 A가 이미 잠금을 획득했지만 Shared Lock이므로 B도 조회는 가능하다.
    • 트랜잭션 A 진행: 트랜잭션 A는 3장의 티셔츠를 차감하여 재고를 7장으로 업데이트하고 커밋하려고 하는데 트랜잭션 B가 Shared Lock을 가지고 있으므로 데드락 발생
    • 트랜잭션 B 진행: 트랜잭션 B는 5장의 티셔츠를 차감하여 재고를 5장으로 업데이트하고 커밋하려고 하지만 트랜잭션 A가 가지고 있는 Shared Lock때문에 데드락 발생
    • RDB내부에서 데드락 처리 :  내부적으로 잠금이 데드락에 빠지지 않았는지 체크하기 위해 잠금 대기 목록을 관리하는데  별도의 데드락 감지 쓰레드가 주기적으로 잠금 대기 목록을 검사해 데드락이 걸린 트랜잭션들을 찾아서 그 중 하나(또는 여러개)를 강제로 종료한다. 여기서 어느 트랜잭션을 먼저 강제 종료할 것인지를 판단하는 기준은 트랜잭션의 언두 로그 양인데, 적은 언두 로그 양을 가진 트랜잭션을 우선적으로 롤백시킨다.

     

    결론적으로, 트랜잭션 격리수준을 Serializable 로 했을 때, 데드락을 발생시키고 다른 트랜잭션을 롤백시킴으로써 Lost Update 를 방지할 수는 있게 된다.

     

     

     

     

    @Service
    class ProductService(
        private val productRepository: ProductRepository
    ) {
    
        @Transactional(isolation = Isolation.SERIALIZABLE)
        fun decreaseStock(productId: Long, amount: Long) {
            // 상품 조회
            val product = productRepository.findById(productId)
                .orElseThrow { IllegalArgumentException("Product not found") }
    
            // 재고 확인 및 차감
            if (product.stock < amount) {
                throw IllegalArgumentException("Not enough stock for product $productId")
            }
    
            product.decreaseStock(amount)
    
            // 변경사항 저장
            productRepository.save(product)
        }
    }

     

    1.1 성능저하

    특정 레코드에 Shared Lock 건 트랜잭션이 종료될 때까지 다른 트랜잭션에서는 해당 레코드를 변경하지 못한다. 데드락이 빈번하게 발생하고 데드락 자동감지 옵션이 켜져있으면 데드락 감지 스레드가 데드락을 풀어주겠지만,  각트랜잭션이 가진 잠금의 수가 많아지게되고 데드락 감지 스레드가 느려진다.

     

     

    일반적인 상황에서는 데드락 감지 스레드의 부담이 크지 않다.. 하지만 다음과 같은 상황에서는 문제가 발생할 수 있다.

    • 동시 처리 스레드가 매우 많을 때: 동시에 많은 트랜잭션이 실행되면 잠금의 개수가 많아지고, 데드락 감지 스레드가 검사해야 할 목록이 길어진다.
    • 각 트랜잭션이 가진 잠금의 개수가 많을 때: 하나의 트랜잭션이 여러 개의 자원에 잠금을 걸고 있으면, 데드락 감지 스레드의 검사량이 증가한다.

     

    데드락 감지 스레드는 데드락을 찾기 위해 모든 트랜잭션의 잠금 목록을 확인한다. 이때, 잠금 목록이 변경되는 것을 막기 위해 잠금 목록이 저장된 리스트(보통 "잠금 테이블"이라고 함)에 새로운 잠금을 걸고 검사를 수행한다. 이는 다음과 같은 문제를 야기한다.

    1. 잠금 경합(Lock Contention): 데드락 감지 스레드가 잠금 테이블에 잠금을 걸고 있는 동안, 다른 스레드들은 해당 테이블에 접근하지 못하고 대기해야 합니다. 이는 성능 저하를 유발한다.
    2. CPU 자원 소모: 많은 잠금 목록을 검사하는 것은 CPU 자원을 많이 소모하는 작업이다. 특히 동시 처리 스레드가 많을수록 데드락 감지 스레드는 더 많은 CPU 자원을 사용한다.

    이로 인해, Serializable 이 일반적으로 다른 트랜잭션 격리 수준 보다 동시 처리 성능이 떨어진다. 

     

     

    2.  Pessimistic Lock(비관적 락)

    Pessimistic Lock은 데이터를 조회하면서 다른 트랜잭션이 해당 데이터를 조회하지 못하도록 row에 락을 거는 방식이다. JPA를 사용한다면 아래와 같이 @Lock 어노테이션을 이용하면 Pessimistic Lock을 건다. 아래는 사용 가능한 옵션인데, 재고차감을 위해서는 PESSIMISTIC_WRITE옵션을 사용해야한다.

     

    만약, PESSIMISTIC_WRITE이 아닌 PESSIMISTIC_READ를 사용한다면 재고를 조회하는 시점에는 동시에 같은 수량을 조회했으므로 동시성 문제가 발생한다.

     

    • PESSIMISTIC_READ
      • Shared Lock을 사용해서 데이터를 읽는 동안 다른 트랜잭션이 데이터를 수정하거나 삭제하지 못하도록 차단
      • 데이터를 여러 트랜잭션에서 읽을 수 있지만, 수정과 삭제가 불가능하다.
    • PESSIMISTIC_WRITE
      • Exclusive Lock을 사용해서 데이터를 읽거나 수정하는 동안 다른 트랜잭션이 데이터에 접근하지 못하도록 한다.
      • row에 대해서 Exclusive Lock을 획득하지 못하면 읽기도 불가능하다.

     

     

     

     

    interface ProductRepository : JpaRepository<Product, Long> {
    
        @Lock(LockModeType.PESSIMISTIC_WRITE)
        @Query("SELECT p FROM Product p WHERE p.id = :id")
        fun findByIdWithLock(id: Long): Product?
    }

     

     

    비관적 락을 사용하면 조회성능이 떨어지고 데드락 문제가 발생할 수 있으므로 주의해야한다.

     

    2.1 데드락 발생상황

    TODO

     

    2.2 조회성능 영향

     

    TODO

     

    2.3 적절한 타임아웃 설정

    재고차감에 걸리는 소요시간을 확인해서 적절한 타임아웃값을 설정해야한다.

     

     

    3. Optimisitc Lock(낙관적 락)

    비관적 락 방식에서는 읽기에 배타 락을 활용하여 아예 읽기를 수정 이후로 처리해 버려 동시성 문제를 풀었다. 다만 자원과 락을 사용하는 방식에 따라 데드락 문제가 발생할 수 있었고, 자원이 겹칠 경우 조회가 늦어질 수 있다는 단점이 있었다.

     

    Optimistic Lock은 엔티티에 버전 필드를 추가하여 동시성 문제를 해결한다. 트랜잭션이 커밋될 때 버전 번호가 확인되며, 다른 트랜잭션이 동일한 데이터를 변경한 경우 OptimisticLockException이 발생한다.

     

    동시성 문제를 생각하지 않고 트랜잭션을 처리했다가 혹 동시성 문제가 발생한다면 그때 동시성 문제를 애플리케이션에서 처리하는 것으로 데드락과 성능 문제를 풀어보는 것이다. 이를 낙관적 락이라고 한다.

     

    @Entity
    @Table(name = "product")
    class Product(
        id: Long = 0,
        name: String,
        price: Long,
        stock: Long,
        createdAt: Instant = Instant.now(),
        updatedAt: Instant = Instant.now(),
    ) {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        var id: Long = id
            private set
    
        @Column(name = "name")
        var name: String = name
            private set
    
        @Column(name = "price")
        var price: Long = price
            private set
    
        @Column(name = "stock")
        var stock: Long = stock
            private set
    
        @CreationTimestamp
        @Column(name = "created_at", updatable = false)
        var createdAt: Instant = createdAt
            private set
    
        @UpdateTimestamp
        @Column(name = "updated_at")
        var updatedAt: Instant = updatedAt
            private set
    
        @Version
        @Column(name = "version")
        var version: Long = 0
            private set
    
        fun decreaseStock(amount: Long = 1) {
            if (stock < amount) {
                throw IllegalArgumentException("Not enough stock for product $id")
            }
            stock -= amount
        }
    }

     

     

    3.1 retry  전략 고려

    Optimistic Lock은 row에 lock을 걸지않아서 조회성능에 영향은 없지만 동시에 수정하려고 하면 실패하므로 재처리 로직을 고려해야한다. 단일 애플리케이션이라면 재처리 로직을 구현하는게 쉽겠지만, 다중 인스턴스로 애플리케이션을 운영하는 환경이라면 재처리시 순서도 고려해야하므로 message queue같은 인프라 레이어의 추가가 필요하다.

     

    3.2 충돌이 많이 발생하면 cpu사용률 증가

    Optimistic Lock 사용시 충돌이 발생하면 재시도처리를 해야하는데, 너무 많은 충돌이 일어나면 재시도 로직으로 인해서 CPU사용률이 올라간다. 따라서, 적절한 재시도 횟수 찾아서 구현해야한다.

     

Designed by Tistory.