ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [JPA] 낙관적 락(Optimistic Lock)과 예제 코드
    Backend/jpa 2024. 10. 14. 15:44

     

    1. 낙관적 락(Optimistic Lock)이란?

    낙관적 락은 동시성 문제가 발생할 가능성이 낮다고 가정하고, 여러 트랜잭션이 동시에 데이터에 접근할 수 있도록 허용한다. 그러나 실제로 데이터를 변경할 때 충돌이 발생하면, 해당 트랜잭션은 롤백되고 예외를 발생시킨다. 

     

    JPA에서는 @Version 필드를 사용하여, 해당 엔티티가 변경될 때마다 버전 값이 증가한다. 트랜잭션이 종료되기 전에 다른 트랜잭션이 이 엔티티를 변경하여 버전이 증가한 것을 감지하면, OptimisticLockException이 발생하고 해당 트랜잭션은 롤백된다. 또한 데이터베이스의 물리적 락 기능을 사용하지 않고, 엔티티의 버전을 통해 동시성을 제어한다.

    2. OptimisticLockingFailureException 예외란?

    OptimisticLockingFailureException은 SpringDataJPA에서 낙관적 락(Optimistic Lock)을 사용할 때 발생하는 예외이다.

    2-1. Entity.class

    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    @Entity
    public class Stock {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private String name;
    
        private int amount;
    
        @Version
        private Long version;
    
        @Builder
        private Stock(String name, int amount) {
            this.name = name;
            this.amount = amount;
        }
    
        public static Stock create(String stockName, int amount) {
            return new Stock(stockName, amount);
        }
    
        public void decrease(int amount) {
            isEnoughAmount(amount);
            this.amount -= amount;
        }
    
        // 주문 개수가 현재 재고량 보다 많은가?
        private void isEnoughAmount(int orderAmount) {
            if (this.amount < orderAmount) {
                throw new IllegalArgumentException("재고량이 부족합니다.");
            }
        }
    
    }

    2-2. 낙관적 락(Optimistic Lock) 동작 원리

    • 1. 데이터 조회 : 트랜잭션이 데이터베이스에서 엔티티를 조회할 때, 엔티티의 현재 version 값을 함께 조회한다.
    select
        s1_0.id,
        s1_0.amount,
        s1_0.name,
        s1_0.version      
    from
        stock s1_0      
    where
        s1_0.id=1
    • 2. 데이터 수정 : 트랜잭션이 데이터를 수정할 때, 해당 트랜잭션이 처음에 조회한 version 값과 데이터베이스에 저장된 version 값을 비교한다.
    update
        stock      
    set
        amount=0,
        name=NULL,
        version=1      
    where
        id=1          
        and version=0
    • 3. version 비교
      • 트랜잭션이 가지고 있는 version 값과 데이터베이스에 저장된 version 값이 동일하면, 수정에 성공하며 version 값이 증가한다.
      • 만약 version 값이 다르다면, 다른 트랜잭션에서 해당 데이터를 수정했다는 의미이다. 이때, JPA는 OptimisticLockException을 발생시킨다.

     

    3. 낙관적 락, LockModeType

    JPA에서 @Lock 애너테이션을 사용하여 락 모드를 설정할 수 있다. 낙관적 락은 3가지 모드를 제공하는데, 낙관적 락의 경우 데이터베이스에서 제공하는 물리적 락을 사용하는 것이 아닌, 엔티티의 버전을 통해 동시성을 제어한다.

    순수 JPA와 SpringDataJPA에서 발생하는 낙관적 락 예외는 다르다.
    순수 JPA : OptimisticLockException
    SpringDataJPA : OptimisticLockingFailureException → SpringDataJpa을 사용하는 경우 OptimisticLockException를 감싼 OptimisticLockingFailureException를 던진다.
    본 글에서는 버전 정보가 달라 충돌이 발생할 때 발생하는 예외를 OptimisticLockException로 통합하여 설명한다.

     

    3-1. LockModeType 종류

    • LockModeType.NONE
      • 기본적으로 사용되는 모드이다.
      • 조회한 엔티티를 수정할 때, 조회 시점의 버전과 데이터베이스의 버전을 비교하여 다른 트랜잭션에 의해 변경되었는지 확인한다.
      • 버전 정보가 서로 다르다는 것은 다른 트랜잭션에서 데이터 변경이 일어났다는 뜻이므로, OptimisticLockException 예외가 발생하며 트랜잭션이 롤백된다.
      • 결론: 엔티티를 변경하려고 할 때만 버전 비교가 발생한다.
    • LockModeType.OPTIMISTIC
      • 엔티티를 조회하는 시점에 버전 값을 조회한다.
      • 트랜잭션이 끝날 때까지 처음 조회한 버전 값을 유지하며, 커밋 시점에 조회한 버전과 데이터베이스의 버전을 비교한다. 즉, 트랜잭션 도중에 엔티티를 수정하더라도 즉시 버전이 올라가는 것이 아니며, 커밋되는 시점에 버전이 증가한다.
      • 결론 : 트랜잭션 커밋 시점에 버전 비교가 발생한다.
    • LockModeType.OPTIMISTIC_FORCE_INCREMENT
      • 엔티티를 조회하는 시점에 버전 값이 강제로 증가한다.
      • 트랜잭션 내에서 엔티티를 조회만 하더라도 버전이 증가하며, 이는 수정 작업이 없더라도 발생한다. (단, 같은 트랜잭션 내에서는 동일한 엔티티를 반복 조회해도 최초 1회에 한해 버전이 증가한다.)
      • 먼저 엔티티를 조회한 트랜잭션 A와 이후에 동일한 엔티티를 조회한 트랜잭션 B가 있을 때, 트랜잭션 B에 의해 버전 값이 증가하였으므로, 트랜잭션 A가 커밋하는 시점에 버전 값 충돌로 인하여 롤백이 발생한다.
      • 결론 : 엔티티를 처음 조회할 때만 버전이 강제로 증가하고, 이를 통해 다른 트랜잭션이 동일한 엔티티의 데이터를 수정하지 못하도록 방지한다.

     

    4. 테스트 코드

    • Given:
      • 재고가 1개 남은 Stock 엔티티를 생성하고 이를 데이터베이스에 저장한다.
      • 3개의 스레드를 생성한 후, 각 스레드가 stockService.decreaseAmount() 메서드를 실행하도록 설정한다.
    • When:
      • 각 스레드는 decreaseAmount() 메서드를 동시에 호출하여, Stock 엔티티의 재고를 1씩 감소시킨다.
      • future.get() 메서드를 사용해 스레드의 실행 결과를 기다리고, 실행 중 ExecutionException이 발생하면 해당 예외를 thrownException에 저장한다.
    • Then:
      • ExecutionException이 발생한 경우, 그 원인이 OptimisticLockingFailureException인지 검증한다.
    @RequiredArgsConstructor
    @Service
    public class StockService {
    
        private final StockRepository stockRepository;
    
        @Transactional
        public StockResponse createStock(StockServiceRequest request) {
            Stock stock = request.toEntity();
            Stock savedStock = stockRepository.save(stock);
            return new StockResponse(savedStock.getName(), savedStock.getAmount());
        }
    
        @Transactional
        public StockDecreaseResponse decreaseAmount(StockDecreaseServiceRequest request) {
            Stock stock = stockRepository.findById(request.getStockId())
                .orElseThrow(() -> new IllegalArgumentException("상품을 조회할 수 없습니다."));
            stock.decrease(request.getDecreaseAmount());
            return new StockDecreaseResponse(stock.getId(), stock.getName(), stock.getAmount());
        }
    
    }
    @SpringBootTest
    class StockServiceTest {
    
        @Autowired
        private StockService stockService;
    
        @Autowired
        private StockRepository stockRepository;
    
    
        @Test
        @DisplayName("Optimistic Lock Test")
        void decreaseAmount() {
            // given
            Stock stock = createStock(1);
            stockRepository.save(stock);
    
            ExecutorService executorService = Executors.newFixedThreadPool(3);
            Future<?> future1 = executorService.submit(
                () -> stockService.decreaseAmount(new StockDecreaseServiceRequest(stock.getId(), 1))
            );
            Future<?> future2 = executorService.submit(
                () -> stockService.decreaseAmount(new StockDecreaseServiceRequest(stock.getId(), 1))
            );
            Future<?> future3 = executorService.submit(
                () -> stockService.decreaseAmount(new StockDecreaseServiceRequest(stock.getId(), 1))
            );
    
            // when
            Exception thrownException = null;
            try {
                future1.get();
                future2.get();
                future3.get();
            } catch (ExecutionException ex) {
                thrownException = (Exception) ex.getCause();
            } catch (InterruptedException ex) {
            }
    
            // then
            assertTrue(thrownException instanceof ObjectOptimisticLockingFailureException);
        }
    
        private Stock createStock(int amount) {
            return Stock.builder()
                .amount(amount)
                .build();
        }
    
    }

     

    • 가장 먼저 엔티티를 수정하는 경우, 조회 시점의 버전과 데이터베이스의 버전이 일치하므로 트랜잭션 커밋이 된다.

    • 트랜잭션 1, 2, 3이 모두 동일한 버전 값을 가지고 있었는데, 트랜잭션 1이 UPDATE에 성공하면서 버전 값이 증가하였다. 따라서 트랜잭션 2와 3은 UPDATE 시점에 데이터베이스와 버전 값이 일치하지 않아 ROLLBACK이 발생한다.

     

     

     

Designed by Tistory.