H2 데이터베이스 배타락 데이터 가시성 문제

jhkim31·2024년 7월 8일
0

JSHOP 프로젝트

목록 보기
6/8

동시에 두개의 트랜잭션이 하나의 값을 변경하려고 할때 락을 얻은 트랜잭션이 작업을 끝내고 커밋을 하고 락을 반납할때 락을 기다리던 트랜잭션에서 이전 트랜잭션이 변경한 값을 보지 못하는 문제다.

이번 프로젝트를 진행하며 재고 관리 기능을 구현했다.

재고의 수량을 더하거나 뺄때 동시성 문제가 발생할 수 있을것 같아 이 문제를 해결하기 위해 비관적 락을 사용하고 테스트를 진행했다.

테스트를 진행하며 의도하지 않은 동작이 발생했고, 원인을 찾아보고 이를 해결하는 과정에 대해 정리해봤다.

문제 발생 상황

문제가 발생한 DB는 다음과 같다. 상품 테이블인 product 아래에는 여러개의 실제 상품 테이블인 product_detail 이 1:N 으로 존재하고, 각 product_detail 은 재고 테이블인 inventory 를 1:1 로 가진다.

여기서 상품의 재고를 업데이트 하기 위해 product_detail (이하 상세 상품) id와 업데이트할 수량을 받는다.

그리고 상세 상품을 조회하고, 상세 상품인벤토리를 연관 엔티티로 가져와 수량을 갱신한다.

	@Transactional
    public void updateProductDetailStock(Long detailId, int quantity) {
        ProductDetail productDetail = getProductDetail(detailId);       
        productDetail.getInventory().addStock(quantity);
	}

여기서 성능 개선을 위해 @EntityGraph 로 연관된 인벤토리 를 같이 가져오게 했다.
그리고, 동시에 여러 요청이 들어와도 하나의 요청만 처리할 수 있도록 비관적 락을 적용했다.

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @EntityGraph(attributePaths = {"inventory"})
    Optional<ProductDetail> findProductDetailByIdWithInventory(Long id);

테스트

테스트는 하나의 상세 상품인벤토리를 만들어 두고 ExecutorServices 로 여러개의 스레드로 동시에 요청을 날리는 식으로 진행했다.

그리고 모든 작업이 끝나면 상품의 수량이 정상적으로 증가했는지 확인했다.

	ExecutorService executors = Executors.newFixedThreadPool(10);

        for (int i = 0; i < 10; i++) {
            executors.submit(() -> {
                productService.updateProductDetailStock(productDetailId, 10);
            });
        }
        executors.shutdown();
        executors.awaitTermination(1, TimeUnit.MINUTES);

        ProductDetail productDetail = productService.getProductDetail(productDetailId);
        assertThat(productDetail.getInventory().getQuantity()).isEqualTo(100);

그림으로 표현하면 다음과 같은 상황인거다.

모든 스레드는 상세 상품for update 로 락을 얻기 위해 대기하고, 하나의 스레드만 락을 얻어 인벤토리를 수정하게 되는 것이다.
그리고 인벤토리 업데이트가 끝나고 락을 반환하면 다음 스레드가 락을 얻어 작업을 수행하는 방식이다.

문제 발생

하지만 수량은 정상적으로 증가하지 않았다.

트러블 슈팅

문제 해결을 위해 다방면으로 트러블 슈팅을 시도했다.

1. 쿼리 로그 분석

   select
        pd1_0.product_detail_id,
        pd1_0.attribute,
        pd1_0.created_at,
        i1_0.inventory_id,
        i1_0.inventory_change_type,
        i1_0.created_at,
        i1_0.minQuantity,
        i1_0.quantity,
        i1_0.updated_at,
        pd1_0.is_deleted,
        pd1_0.price,
        p1_0.product_id,
        p1_0.attributes,
        p1_0.category_id,
        p1_0.created_at,
        p1_0.description,
        p1_0.manufacturer,
        p1_0.name,
        p1_0.owner_id,
        p1_0.updated_at,
        pd1_0.updated_at      
    from
        product_detail pd1_0      
    left join
        inventory i1_0              
            on i1_0.inventory_id=pd1_0.inventory_id      
    left join
        product p1_0              
            on p1_0.product_id=pd1_0.product_id      
    where
        pd1_0.product_detail_id=1 for update 
2024-07-08 14:36:01.236 [pool-2-thread-2] [INFO] p6spy - [statement] | 1 ms | 
    update
        inventory      
    set
        inventory_change_type='INCREASE',
        created_at='2024-07-08T14:36:01.165+0900',
        minQuantity=0,
        quantity=10,
        updated_at='2024-07-08T14:36:01.229+0900'      
    where
        inventory_id=1 
2024-07-08 14:36:01.239 [pool-2-thread-2] [INFO] p6spy - [commit] | 1 ms |  
2024-07-08 14:36:01.239 [pool-2-thread-5] [INFO] p6spy - [statement] | 43 ms | 
    select
        pd1_0.product_detail_id,
        pd1_0.attribute,
        pd1_0.created_at,
        i1_0.inventory_id,
        i1_0.inventory_change_type,
        i1_0.created_at,
        i1_0.minQuantity,
        i1_0.quantity,
        i1_0.updated_at,
        pd1_0.is_deleted,
        pd1_0.price,
        p1_0.product_id,
        p1_0.attributes,
        p1_0.category_id,
        p1_0.created_at,
        p1_0.description,
        p1_0.manufacturer,
        p1_0.name,
        p1_0.owner_id,
        p1_0.updated_at,
        pd1_0.updated_at      
    from
        product_detail pd1_0      
    left join
        inventory i1_0              
            on i1_0.inventory_id=pd1_0.inventory_id      
    left join
        product p1_0              
            on p1_0.product_id=pd1_0.product_id      
    where
        pd1_0.product_detail_id=1 for update 

쿼리 로그를 봐서는 for update 로 배타 락을 걸고 다른 스레드의 접근을 잘 차단했다.
그리고 재고를 수정하고, 커밋과 함께 다음 스레드가 락을 얻어 작업을 수행하는것처럼 보였다.

하지만 커밋 이후 락을 얻어 수행하는 스레드가 가져온 재고 값에는 이전 스레드가 수정한 재고 값이 반영되지 않은 상태였다.

2. JPQL 작성

@EntityGraph 가 아닌 JPQL을 작성해서 쿼리를 날려봤다.

	@Lock(LockModeType.PESSIMISTIC_WRITE)   
    @Query("select pd from ProductDetail pd join fetch pd.inventory i join fetch pd.product p where pd.id = :id")
    Optional<ProductDetail> findById(@Param("id") Long id);

그리고 동일한 테스트를 수행해봤다.

테스트가 정상적으로 통과되는 모습이다...!!

쿼리 분석

두 방식의 차이를 분석하기 위해 쿼리 로그를 비교해봤다.

    select
        pd1_0.product_detail_id,
        pd1_0.attribute,
        pd1_0.created_at,
        i1_0.inventory_id,
        i1_0.inventory_change_type,
        i1_0.created_at,
        i1_0.minQuantity,
        i1_0.quantity,
        i1_0.updated_at,
        pd1_0.is_deleted,
        pd1_0.price,
        p1_0.product_id,
        p1_0.attributes,
        p1_0.category_id,
        p1_0.created_at,
        p1_0.description,
        p1_0.manufacturer,
        p1_0.name,
        p1_0.owner_id,
        p1_0.updated_at,
        pd1_0.updated_at      
    from
        product_detail pd1_0      
    join
        inventory i1_0              
            on i1_0.inventory_id=pd1_0.inventory_id      
    join
        product p1_0              
            on p1_0.product_id=pd1_0.product_id      
    where
        pd1_0.product_detail_id=1 for update;

@EntityGraph 와 JPQL의 눈에 띄는 차이점이라면 연관 엔티티 테이블 조인시 @EntityGraphleft join 을 사용하고, JPQLjoin 을 사용한다는 것이였다.

3. 데이터 베이스 변경 (MySQL)

현재 테스트를 진행하는 데이터베이스는 h2 데이터 베이스였고 혹시 데이터 베이스의 차이인가 싶어 데이터 베이스를 MySQL로 변경해봤다.

그리고 @EntityGraphJPQL 방식 모두 테스트 해봤다.

결과는 두 방식 모두 테스트에 통과했다.

4. H2 웹 콘솔에서 실험

4.1 @EntityGraph (left join) 방식 실험

동일한 환경이지만 mysql에서는 문제가 발생하지 않고 h2 에서만 문제가 발생한다.
그리고 비슷한 방법인 Fetch Join(join) 으론 문제가 발생하지 않지만 @EntityGraph(join) 에서는 문제가 발생한다.

문제의 원인을 좁혀가기 위해 웹 콘솔에서 동일한 환경을 세팅해 실험을 진행해봤다.

  1. 두개의 웹 콘솔을 준비하고 트랜잭션을 시작한다.

  1. 한쪽에서 for update 로 값을 읽어오고 값을 99로 수정하고, 다른 쪽에서 동일하게 for update 로 값을 읽었다.

먼저 실행한 왼쪽은 값을 수정했지만 오른쪽은 락을 얻지 못해 값에 접근하지 못하고 대기하는 상황이다.

  1. 한쪽에서 커밋을 해줘 다른쪽이 읽도록 해줬다.

값을 99로 수정했지만, 오른쪽 트랜잭션에선 변경된 값을 보지 못하고 이전 값을 읽었다!

하지만 곧바로 동일한 쿼리를 날리니 변경된 값을 읽어오는것을 확인할 수 있었다.

4.2 JPQL (inner join) 방식 실험

inner join 에서는 어떤 결과가 나타나는지 궁금해 동일한 방법으로 실험을 진행했다.

놀랍게도 락을 얻자마자 변경된 값을 확인할 수 있었다!

4.3 조인 없이 직접 변경 실험

이번에는 조인 없이 변경을 진행할 레코드에 직접 락을 걸고 변경을 진행해봤다.

begin;

	select 
    	* 
    from

    	inventory 
    where 
    	inventory_id = 1 for update;

    update
        inventory      
    set
        inventory_change_type='INCREASE',
        minQuantity=0,
        quantity=999
    where
        inventory_id=1 

역시 락을 얻자마자 업데이트된 값을 바로 읽는 것을 확인할 수 있었다.

정리

재고 변경의 동시성 문제를 해결하기 위해 비관적 락을 사용했다.

상품 - 인벤토리 의 관계를 가지고 있었고 비즈니스 로직은 상품의 id 를 통해 재고를 업데이트 하는 방식이었기 때문에 상품을 읽을때 락을 거는 방식으로 구현했다.

하지만 @EntityGraph 를 사용할때는 동시성 문제가 해결되지 않았고 Fetch Join 을 사용할때는 문제가 해결되는 것을 확인할 수 있었다.

또한 MySQL에서는 @EntityGraphFetch Join 방법 상관없이 동시성 문제가 해결되는 모습을 보였다.

웹 콘솔에서 @EntityGraph 의 방식인 left joinFetch Join 의 방식인 inner join 으로 비교해본 결과
left join 으로 락을 걸고, 조인에 참여한 테이블의 값을 변경하게 된다면 커밋 이후 락을 획득하기 위해 대기중이던 다른 트랜잭션에서 곧바로 값이 보이지 않는 문제를 발견할 수 있었다.
또한 inner join 으로 락을 걸거나, 변경을 진행할 레코드에 직접 락을 걸게 된다면 이러한 문제가 발생하지 않다는 것 또한 발견할 수 있었다.

결론

락을 얻지 못한 트랜잭션이 대기하고 있는동안 다른 트랜잭션이 값을 업데이트한 상황에서, 커밋을 하게되고 락을 얻은 트랜잭션이 보는 값은 업데이트 이후의 값이 맞다.

h2를 제외한 데이터 베이스에서도 그렇게 동작하고, h2 에서도 잠시 데이터 가시성에 문제가 있을 뿐이지 위에서 언급한대로 업데이트한 값을 보게 된다.

left join 으로 배타락을 걸고 연관된 테이블을 수정할때 변경된 값의 가시성에 문제가 생기는건 현재 h2 의 문제인것 같다.

  • t1 : 트랜잭션 1 커밋
  • t2 : 트랜잭션 2 락 획득, 읽기
  • t3 : 트랜잭션 2 읽기 재시도

현재 위와 같은 상황에서 t2, t3 의 값이 다르게 보이는 상황인데 이건 h2 의 문제가 맞는것 같다.

그리고 락을 걸때는 변경하려는 테이블(Inventory)에 락을 거는것이 맞는것 같다. 현재는 변경하려는 테이블과 연관된 테이블(Product_Detail)에 락을 걸었는데, 이렇게 락을 걸게 될경우 의도치 않게 너무 넓은 범위에 락이 걸리게 된다.

profile
김재현입니다.

0개의 댓글