[트러블슈팅] TrendPick 프로젝트 주문 생성 시 상품 재고 처리 동시성 이슈

2023. 10. 4. 18:40트러블슈팅

[상황]

쇼핑몰 서버 구현 시 주문-결제 시스템이 필요하다.

주문-결제에 성공하면 상품의 재고가 줄어들게 되는데, 멀티 스레드 환경에서 상품을 주문하게 되면 경쟁 상태가 발생하게 되고 상품의 재고에 이상현상이 발생한다.

따라서 동시성 문제를 해결하기 위한 방법을 적용시켜야 한다.


[적용 및 이슈 해결 과정]

@Test
    @DisplayName("재고 차감 동시성 테스트")
    void concurrencyTest() throws InterruptedException {

        Product testProduct = productRepository.save(createProduct());
        Member testMember = memberRepository.save(createMember());

        int threadCount = 100;
        //멀티스레드 이용 ExecutorService : 비동기를 단순하게 처리할 수 있또록 해주는 java api
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);

        //다른 스레드에서 수행이 완료될 때 까지 대기할 수 있도록 도와주는 API - 요청이 끝날때 까지 기다림
        CountDownLatch latch = new CountDownLatch(threadCount);


        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                            orderService.productToOrder(testMember, testProduct.getId(), 1, "L", "RED");
                            latch.countDown();
                    }
            );
        }

        latch.await();

        Product findProduct = productRepository.findById(testProduct.getId()).get();
        Assertions.assertThat(findProduct.getStock()).isEqualTo(0);

    }

 

[문제 발생 1]

스레드 1과 스레드 2가 동시에 동일한 데이터베이스 레코드(아이템 재고 수량)을 수정하려고 하기 떄문에 서로 블록될 수 있는 데드락 발생하였다.

1. Thread1 , Thread2 가 동시에 주문 생성

2. 두 스레드가 orderService.createOrder 메서드에 진입하여 메서드 내에서 각 스레드는 주문에서 요청된 아이템을 기반으로 OrderItem 생성

3. OrderItem 객체를 만들 때 각 스레드는 동일한 Product 객체와 상호 작용하는 경우 데드락이 발생

@Transactional
    public RsData<Long> productToOrder(Member member, Long productId, int quantity, String size, String color) {
        List<OrderItem> orderItems = createOrderItems(productId, quantity, size, color);
        Order order = orderRepository.save(
                Order.createOrder(member, new Delivery(member.getAddress()), orderItems));
        return RsData.of("S-1", "주문을 시작합니다.", order.getId());
    }


    private List<OrderItem> createOrderItems(Long productId, int quantity, String size, String color) {
        Product product = productService.findByIdWithPessimisticLock(productId);
        validateProductQuantity(quantity, product);
        product.decreaseStock(quantity);
        return List.of(OrderItem.of(product, quantity, size, color));
    }

 

[문제 해결 1]

Optimistic Rock(낙관적 락) 도입

낙관적 락(Optimistic Lock)

- 낙관적 락은 충돌이 거의 발생하지 않을 것이라고 가정하고, 충돌이 발생한 경우에 대비하는 방식

- 낙관적 락은 JPA에서 버전(Version) 속성을 이용하여 구현

- 낙관적 락의 특징으로는 충돌 발생확률이 낮고, 지속적인 락으로 인한 성능저하를 막을 수 있다.

​- @Version JPA는 @Version 어노테이션을 통해 엔티티의 버전을 관리한다.

- @Version 적용이 가능한 타입은 Long(long), Integer(int), Short(short), Timestamp 이다.

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long productId;

    @Version
    private Long version;
    
    ......

LockModeType의 OPTIMISTIC_FORCE_INCREMENT을 사용하여 동시성 문제를 해결하려고 했다.

 

[사용 방법]

 

@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)

public interface ProductRepository extends JpaRepository<Product, Long> {

    @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
    @Query("select p from Product p where p.productId = :productId")
    Optional<Product> findByproductId(@Param("productId") Long productId);

해당 로직에서 상품과 주문아이템의 엔티티가 1:N 관계일때 상품 재고수량이 변경 되는 경우에는 상품 엔티티의 물리적 변경은 일어나지 않았지만 논리적인 변경이 일어났기 때문에 버전이 올라간다.

private List<OrderItem> createOrderItems(Long productId, int quantity, String size, String color) {
        Product product = productService.findByIdWithPessimisticLock(productId);
        validateProductQuantity(quantity, product);
        product.decreaseStock(quantity);
        return List.of(OrderItem.of(product, quantity, size, color));
    }

 

[문제 발생 2]

그러나 낙관적 락의 OPTIMISTIC_FORCE_INCREMENT를 사용해도 버전이 바뀌지 않는 것을 확인 하며 여전히 DeadLock이 발생했다.

 

데이터 베이스에서의 Lock을 사용하지 않았는데 데드락이 발생한게 의아했다.

 

그래서 MySql의 InnoDB에서 락을 걸고 있는 것으로 판단하고 데이터베이스 콘솔에 show engine innodb status;로

 

LATEST DETECTED DEADLOCK (최근 감지된 데드락) 을 확인해보았다.

 

S locks - 공유 락 (Shared) - 다른 트랜잭션이 읽을 수는 있지만 쓸 수는 없다

X locks - 배타 락 (Exclusive) - 다른 트랜잭션은 읽을 수도 쓸 수도 없다.

두 트랜잭션은 Product 테이블의 레코드에 대한 락을 기다리고 있으며, "S" (Shared) 락 모드로 락을 보유하고 있는 걸 확인할 수 있었다.

두 트랜잭션 모두 락을 요청하고 있으며, 락을 기다리는 상태로 데드락이 발생하고 있었다.

 

DB 수준의 락을 걸지 않았는데 왜 DB에서 락이 걸린건지는 MySQL 공식문서에서 확인할 수 있었다.

S-Lock

If a FOREIGN KEY constraint is defined on a table, any insert, update, or delete that requires the constraint condition to be checked sets shared record-level locks on the records that it looks at to check the constraint. InnoDB also sets these locks in the case where the constraint fails.

FOREIGN KEY 제약 조건이 테이블에 정의되어 있으면 제약 조건을 확인해야하는 insert, update, delete는 제약 조건을 확인하기 위해 레코드에 s-lock을 설정한다. InnoDB는 제약 조건이 실패하는 경우에도 s-lock을 설정한다.

즉 MySQL은 FOREIGN KEY 가 존재하는 테이블에서 FK를 포함한 데이터를 삽입, 수정, 삭제 하는 경우 제약조건을 확인하기 위해 S-lock을 설정한다고 합니다.

즉 OrderItem에 insert 될 때 FK인 product를 참조 하기 때문에 S-lock을 건다는 것이다.

X-Lock

For locking reads (SELECT with FOR UPDATE or FOR SHARE), UPDATE, and DELETE statements, the locks that are taken depend on whether the statement uses a unique index with a unique search condition, or a range-type search condition.

MySQL InnoDB는 record(데이터)를 수정할 때에는 항상 x-lock을 건다.

 

즉, product의 재고를 변경하는 update 쿼리가 발생하면서 자동으로 x-Lock을 건 것이다.

정리하자면 MySQL 데이터베이스는 데이터의 일관성을 지키기 위해 개발자의 의도와는 상관없이 자동으로 Lock을 건다.

트랜디픽 주문 로직은 다음과 같은 과정에 따라 주문을 생성한다.

1. 주문아이템에 추가할 상품 조회

2. 재고수량 유효성 검사에 통과하면 주문아이템(OrderItem) 생성

3. 상품 재고를 줄이기 위한 업데이트 쿼리 발생

 

위의 로그를 확인해본 결과 t1 트렌젝션에서 orderItem을 생성할 때 제약조건을 확인하기 위해 해당 상품에 s락을 건다.

그리고 t2 트렌젝션에서 마찬가지로 orderItem을 생성할 때 또 상품에 s락을 건다.

이후 t1이 x락을 걸어야 하는데 트렌젝션 격리 수준에서 t2가 s락을 해제 하지 않고 있기 때문에 x락을 흭득하지 못해 대기 하는 데드락 상황이 발생하는 것이다.

따라서 MySQL에서는 FK 제약조건이 있는 테이블을 insert 하려고 한 후 참조 테이블을 업데이트 하는 경우에는 낙관적 락을 사용할 수가 없는 것을 확인할 수 있다.

동시에 접근하게 되면 FK 제약조건이 있는 테이블을 insert 하는 과정에서 S-lock이 걸려 있기 때문에 X-lock을 흭득하지 못하는 경우가 발생하고, 그로 인해 version을 업데이트 시키지 못하고 데드락이 발생하기 때문이다.

[문제 해결 2]

Pessimistic Rock(락)

 

- 데이터베이스의 락을 직접 사용하여 동시성을 제어하는 방법

- 쿼리에 SELECT ... FOR UPDATE (DBMS의 락 기능)구문을 사용

 

JPA에서 비관적 락을 구현하는 LockModType의 종류는 다음과 같다.

PESSIMISTIC_READ 다른 트랜잭션에서 읽기만 가능

PESSIMISTIC_WRITE 다른 트랜잭션에서 읽기도 못하고 쓰기도 못함

PESSIMISTIC_FORCE_INCREMENT 다른 트랜잭션에서 읽기도 못하고 쓰기도 못함 + 추가적으로 버저닝을 수행

@Lock(LockModeType.PESSIMISTIC_WRITE)

public interface ProductRepository extends JpaRepository<Product, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select p from Product p where i.productId = :productId")
    Optional<Product> findByProductId(@Param("productId") Long productId);
}

 

동시에 같은 상품에 대한 주문이 들어오는 경우 다른 트랜젝션에서 읽기도 못하고 쓰기도 못하는 PESSIMISTIC_WRITE 옵션을 사용하였다.

드디어 테스트 통과


[회고]

이번 프로젝트를 진행하면서 동시성 문제를 해결하는 여러 방법에 대해서 알 수 있었다.

더 나아가 MySQL의 특징과 @Transactional의 격리 수준 등에 대해서도 살펴볼 수 있는 기회였다

 

하지만 단순히 동시성 문제를 해결한 것으로 끝내선 안되고 더 고민해볼 사항이 존재한다.

 

비관적락은 데이터베이스를 잠구고 진행하는데 성능을 보장할 수 있는가 ?

 

만약 분산 데이터베이스를 사용한다면 ?

 

이러한 고민과 함께 동시성을 해결할 수 있는 방법으로 추가적으로 생각해보았다.

 

1. 분산 락

2. 메시지 큐로 요청의 순차성 보장

 

프로젝트를 확장하게 된다면 위 두 가지 방법을 고민하여 적용해보는 과정을 블로깅해야겠다.