데이터베이스 동시성 문제 (feat. JPA)

데이터베이스 동시성 문제 (feat. JPA)

시작하기 앞서

JPA를 공부하다 문득 데이터베이스의 동시성 문제가 생각이나서 해당 문제가 발생하는 예시와 낙관적 잠금, 비관적 잠금이 무엇이고 JPA에서 이를 어떻게 적용하는지와 약간의 궁금증을 해소하는 내용을 이번 포스팅에 담아보겠다.

1. 동시성 문제가 발생하는 상황

동시성 문제가 발생하는 상황의 예를 들어보겠다.

케이스 1

select * from product where id = 2;
$row = select반환값;
if($row[stock] > 0) {
update product set stock = '$row[stock] - 1' where id = 2 
}
  1. DB select 결과를 $row변수에 담는다.
  2. if문에서 $row변수에 재고의 크기가 0보다 큰지 비교한다.
  3. 조건이 참이면 update 쿼리를 날린다.

사용자 A, B 동시 요청 경우
현재의 사용자 A 요청이 3번까지 실행되어 종료 되기전에 다른 사용자 B가 끼어들어 1을 실행하는 순간 동시성 문제가 발생한다.
update문에서 재고를 수정하기위해 프로그래밍적으로 재고값을 $row[stock] - 1로 넣었기 때문이다.
$row[stock]은 select쿼리로 받아온 결과값이고 하나의 트랜잭션이 이미 끝난 상태이다.

따라서 DB가 업데이트되어도 $row[stock]은 업데이트 된 stock을 얻을 수 없다.

케이스 2

이번엔 동시성문제를 해결하도록 쿼리를 수정해보도록 하겠다.

select * from product where id = 2;
$row = select반환값;
if($row[stock] > 0) {
update product set stock = stock - 1 where stock > 0 and id = 2 
}

여전히 프로그래밍적으로 검사하며 트랜잭션 없이 수행하긴 하지만 동시성 문제는 일부 해결된다.

if 문에서 재고가 0보다 큰 경우를 만족하며
update문에서 stock = stock - 1 로 항상 커밋전의 데이터를 stock으로 설정하고
where 조건의 stock > 0 로 재고가 음수로 가는 것을 막는다.

2. 낙관적 잠금(Optimistic Lock)

데이터베이스의 데이터를 수정하기 전에 데이터의 버전을 확인하여 다른 트랜잭션에 의해 수정되었는지 검사한다.

주로 version 번호 또는 timestamp를 활용한다.

JPA 낙관적 잠금 사용 (트랜잭션 미사용)

트랜잭션을 사용하지 않을 때 JPA가 DB에 쿼리를 어떻게 날리는지 궁금증 때문에 사용 안했지만 낙관적 잠금 구현할 때 꼭 트랜잭션 사용해서 구현하자

JAVA 낙관적 잠금 코드

product 테이블 초기 데이터
    @Test
    public void test() throws Exception {
        int a = 5;
        CountDownLatch countDownLatch = new CountDownLatch(a);
        for(int i = 0; i < a; i++){
            Runnable runnable = () ->{
                try {
                    System.out.println(orderService.saveOrder(2));
                }catch (Exception e){
                    System.out.println("test 예외 발생");
                }finally {
                    countDownLatch.countDown();
                }

            };
            Thread thread = new Thread(runnable);
            thread.start();
        }
        countDownLatch.await();
    }

test 코드

    public Product reduceStock(Integer id) {
        Product product = getProduct(id);
        if (product.getStock() > 0) {
            product.setStock(product.getStock() - 1);
            productRepository.save(product);
        } else {
            throw new RuntimeException("Product is out of stock");
        }
        return product;
    }

ProductService

    public OrderDTO saveOrder(Integer productId){
        productService.reduceStock(productId);
        Order order = new Order();
        order.setProductId(productId);
        return orderMapper.toOrderDTO(orderRepository.save(order));
    }

OrderService

낙관적 잠금 실행 결과

낙관적 잠금 콘솔 출력 내용
product 테이블
orders 테이블

DB 낙관적 잠금 로그

실제 JPA가 데이터베이스에 날리는 네이티브 쿼리는 뭘까 궁금해서 이용중인 mariadb에 로깅설정을 하여 확인해봤다. 또 트랜잭션을 사용하지 않는데 어떻게 처리되는지 호기심이 생겨 로그를 직접 확인해봤다.

mariadb 낙관적 잠금 사용 로그 (트랜잭션 미사용)

맨 왼쪽에 있는 1777은 현재 세션 번호를 의미한다.

일단 위 로그를 보면 select로 조회하고 update를 하기전에 autocommit=0으로 설정된걸 알 수 있다. (autocommit 옵션은 1 = 자동커밋, 0 = 수동커밋 이다.)
그리고 나서 COMMIT명령을 날린다.

1776세션이 ROLLBACK으로 업데이트를 취소하고 있다.
이미 1777이 업데이트로 버전을 변경했기 때문에 1776 세션이 버전 0을 못찾고 ROLLBACK된다.

그리고 1777 세션이 insert하고 맨 아래 또 다시 COMMIT을 날린다.

지금은 로직이 단순히 조회, 수정, 삽입 정도만 필요하여 트랜잭션이 없어도 잘 작동한다.
그러나 만약 비즈니스 로직이 주문완료 후 다른 테이블의 행을 수정하고 삽입 할 때 만약 동시성에 의해 실패해버리게 되면 트랜잭션이 설정되어있지 않아 곤란해질 수 있다.

3. 비관적 잠금(Pessimistic Lock)

데이터베이스에서 데이터를 수정하기 전에 해당 데이터를 잠금으로 설정하여 다른 다른 트랜잭션에서 접근하지 못하게 한다.

트랜잭션 내에서 FOR UPDATE문을 사용하여 특정 행에 대한 쓰기 잠금을 설정한다.

JPA 비관적 잠금 사용 (트랜잭션 사용)

초기 데이터는 위의 낙관적 잠금 사용의 데이터와 같다.

JAVA 비관적 잠금 코드

public interface ProductRepository extends JpaRepository<Product,Integer> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<Product> findById(Integer id);
}

ProductRepository @Lock어노테이션 추가

    @Transactional
    public OrderDTO saveOrderTransaction(Integer productId)  {
        productService.reduceStock(productId);
        Order order = new Order();
        order.setProductId(productId);
        return orderMapper.toOrderDTO(orderRepository.save(order));
    }

OrderService @Transactional 어노테이션 추가

비관적 잠금 실행결과

비관적 잠금 콘솔 출력 내용

DB 비관적 잠금 로그

mariadb 비관적 잠금 사용 로그

비관적 잠금으로 실행된 쿼리 로그를 살펴보면 자동커밋을 0으로 설정하여
하나의 세션에서 조회, 수정, 삽입이 트랜잭션 단위로 묶어서 실행되어 COMMIT되고 있다.
또 COMMIT은 2개 ROLLBACK은 3개 있는걸 확인할 수 있었다.

4. 각각 잠금의 장단점

  • 비관적 잠금에선 격리레벨을 적절히 사용하여 다른 트랜잭션이 데이터를 읽거나 쓰는 것을 방지하며 락을 갖고있는 트랜잭션이 끝날때까지 기다린다.

낙관적 잠금 장점

  • 데이터베이스의 잠금을 필요로 하지 않아 동시성이 크게 향상된다.
  • 데이터에 대한 잠금을 사용하지 않아 데드락 발생 가능성이 없다.

낙관적 잠금 단점

  • 동시에 접근하는일이 매우 잦으면 비관적 잠금보다 처리속도가 느릴 수 있다.

비관적 잠금 장점

  • 데이터에 잠금을 걸기 때문에 데이터 일관성을 보장할 수 있다.

비관적 잠금 단점

  • 데이터베이스의 동시성이 크게 떨어져 동시 요청이 많은 경우 처리속도가 느려진다.
  • 잠금을 잘못관리하면 데드락에 빠질 수 있다.

마무리

낙관적 잠금에선 5명의 동시요청에 대해 하나의 재고밖에 감소시키지 못했다.
최초의 요청이 성공하고 다른 4명의 동시 요청은 동시에 select한 version을 기반으로 수행했기 때문이다.

반면에 비관적 잠금에선 2개의 요청이 성공했고 나머지 3개의 요청이 실패하여 ROLLBACK 되었다.
잠금이 풀리기를 기다린 후 다른 트랜잭션이 잠금을 얻어 작업을 했기 때문이다.

JPA을 공부하다 마침 동시성문제가 생각나서 찾아보고 프로그래밍적인 쿼리를 어떻게 날려주는지 궁금했고 직접 로그를 확인하면서 좀 더 확실하게 알 수 있었다.