본문 바로가기

IT/데이터베이스

JPA 낙관적 잠금 (Optimistic locking)을 알아보자

반응형

0. 목차

* 이 글은 MySQL을 기본으로 예제 및 시나리오를 작성합니다.

 

목차

    1. 낙관적 잠금이란?

    1.1 MySQL 엔진 level과 스토리지 엔진 level

    • MySQL에서 사용되는 잠금은 크게 스토리지 엔진 레벨과 MySQL 엔진 레벨로 나눠볼 수 있다.
    • MySQL 엔진 레벨의 잠금은 모든 스토리지 엔진에 영향을 미치게 되지만 스토리지 엔진 레벨의 잠금은 스토리지 엔진 간 상호 영향을 미치지는 않는다.
    • 낙관적 잠금은 InnoDB 스토리지 엔진 잠금의 한 종류이다.

    1.2 비관적 잠금과 낙관적 잠금

    1.2.1 비관적 잠금

    • 현재 트랜잭션에서 변경하고자 하는 레코드에 대해 잠금을 획득하고 변경 작업을 처리하는 방식
    • '변경하고자 하는 레코드를 다른 트랜잭션에서도 변경할 수 있다'라는 비관적인 가정을 하기 때문에 먼저 잠금을 획득
    • InnoDB는 기본적으로 사용하고 있다.

    1.2.2 낙관적 잠금

    • 각 트랜잭션이 같은 레코드를 변경할 가능성은 상당히 희박할 것이라고(낙관적으로) 가정한다.
    • 우선 변경 작업을 수행하고 마지막에 잠금 충돌이 있었는지 확인해 무제가 있었다면 ROLLBACK 처리한다.
    • InnoDB 엔진 자체에서 MVCC를 제공

    2. 시나리오

     

    2.1 해당 시나리오의 문제점

    • 기본적인 트랜잭션 잠금 level이 REPETABLE READ로 설정이 되어 있다고 해도 LOST UPDATE 문제가 발생할 수 있다.

    3. LOCK를 적용하지 않은 낙관적 잠금 예제

    • sell과 buy api는 동일한 기능을 함 -> 재고(stock)를 한 개씩 감소
    // ProductController.kt
    
    @RestController
    @RequestMapping("/product")
    class ProductController(
        private val service: ProductService
    ) {
    
        @GetMapping("/test")
        fun test(): String {
            return "Test"
        }
    
        @GetMapping("/buy/{id}")
        fun buyProduct(@PathVariable id: Long) {
            service.buy(id)
        }
    
        @GetMapping("/sell/{id}")
        fun sellProduct(@PathVariable id: Long) {
            service.sell(id)
        }
    
        @GetMapping("/{amount}")
        fun addProduct(@PathVariable amount: Int) {
            service.add(amount)
        }
    }
    // ProductService.kt
    
    @Service
    class ProductService(
        private val repository: ProductRepository
    ) {
        @Transactional
        fun buy(id: Long) {
            val product = this.repository.findByIdOrNull(id)
            product?.decreaseStock(1)
        }
    
        @Transactional
        fun sell(id: Long) {
            val product = this.repository.findByIdOrNull(id)
            product?.decreaseStock(1)
        }
    
        @Transactional
        fun add(amount: Int) {
            repository.save(Product(stock = amount))
        }
    }
    // Product.kt
    
    @Entity
    class Product(
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        val id: Long? = null,
        var stock: Int
    ) {
        fun decreaseStock(amount: Int) {
            this.stock -= amount
        }
    
        override fun toString(): String {
            return "Product(id=$id, stock=$stock)"
        }
    }
    ProductRepository.kt
    
    interface ProductRepository : JpaRepository<Product, Long>

     

    3.1 실행 결과

    • 최초 1000개의 재고(stock)가 존재

    • 조금 나이스하지 않은 방법이지만 단 sell과 buy를 각각 5번씩 api call을 진행했다.

    • 기대했던 결과는 sell 5번 buy 5번을 통해  총 10개의 재고가 없어지길 기대했다.
    • 하지만 결과값은 기대했던 결과와는 많이 다른 모습을 보여준다.

    • 이러한 원인은 위에서도 말한 것과 같이 LOST UPDATE 이슈가 발생했기 때문이다.

    4 낙관적 LOCK을 사용한 예제

    • 위 예제에서 entity 부분에 version annotation을 적용한 field를 추가하고 table에도 version column을 추가하면 된다.
    // Product.kt
    
    @Entity
    class Product(
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        val id: Long? = null,
        var stock: Int,
        @Version
        var version: Int
    ) {
        fun decreaseStock(amount: Int) {
            this.stock -= amount
        }
    
        override fun toString(): String {
            return "Product(id=$id, stock=$stock)"
        }
    }

     

    • 동일하게 여러 api call을 시도하면 아래와 같이 ObjectOptimisticLockingFailureException 이 발생하게 된다.

    [Request processing failed; nested exception is org.springframework.orm.ObjectOptimisticLockingFailureException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: update product set stock=?, version=? where id=? and version=?; nested exception is org.hibernate.StaleStateException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: update product set stock=?, version=? where id=? and version=?] with root cause

     

    5. 참고 링크

     

    What Is a Lost Update in Database Systems? - DZone Database

     

    dzone.com

     

    How does MVCC (Multi-Version Concurrency Control) work - Vlad Mihalcea

    Learn how the MVCC (Multi-Version Concurrency Control) mechanism works using INSERT, UPDATE, and DELETE examples from PostgreSQL.

    vladmihalcea.com

     

    반응형

    'IT > 데이터베이스' 카테고리의 다른 글

    [JPA] failed to lazily initialize a collection  (1) 2022.10.03
    [JPA] Null value was assigned to a property exception  (0) 2022.09.18
    JPA 공부 - 6  (0) 2021.02.25
    JPA 공부 - 3  (0) 2021.02.25
    JPA 공부 - 5  (0) 2021.02.07