[SpringBoot] 결제 로직(2) - 동시성 제어

포테이토웅·2024년 7월 19일
0

springboot-결제로직

목록 보기
2/6

1. 상품 재고량 감소

아래는 가장 간단한 상품 재고량 감소 메소드입니다. 이 메소드는 상품의 재고량을 조회하여 구매 수량과 비교한 후, 재고량을 수정합니다.

// UserProductService
/**
 * 상품 재고량 감소
 */
@Transactional
public void decreaseProductQuantityWithLock(final long productId, final int quantity) {
    Product product = productRepository.findById(productId)
            .orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND));

    // 재고량 검증
    if (quantity > product.getStockQuantity()) {
        throw new CustomException(ErrorCode.PRODUCT_STOCK_NOT_ENOUGH);
    }

    product.decreaseStockQuantity(quantity);
    productRepository.save(product);
}

테스트 결과, 정상적으로 재고량이 감소되는 것을 확인하였습니다. 하지만 20명의 사용자가 동시에 접근하는 경우에는 어떻게 될까요?

아래는 25개의 재고가 있는 상황에서 20명의 사용자가 동시에 구매하는 경우를 테스트하는 코드입니다.

@Test
@DisplayName("상품 재고량 감소 + 베타락")
void 상품_재고량_감소_베타락() throws InterruptedException {
    // given
    Product product = productRepository.findAll().get(0);

    // when
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch countDownLatch = new CountDownLatch(20);

    for (int i = 0; i < 20; i++) {
        final long productId = product.getProductId();
        executorService.submit(() -> {
            try {
                userProductService.decreaseProductQuantityWithLock(productId, 1);
            } catch (CustomException e) {
                if (e.getErrorCode() != ErrorCode.PRODUCT_STOCK_NOT_ENOUGH) {
                    log.error("CustomException :: ", e);
                }
            } catch (Exception e) {
                log.error("Exception :: ", e);
            } finally {
                countDownLatch.countDown();
            }
        });
    }
    countDownLatch.await();

    // then
    product = productRepository.findAll().get(0);
    assertThat(product.getStockQuantity()).isEqualTo(5);
}

예상되는 남은 재고량은 5이지만, 실제 결과는 22입니다. 이유가 뭘까요?


2. 동시성이란?

동시성 문제란 여러 쓰레드가 동시에 같은 인스턴스의 필드의 값을 변경하면서 발생하는 문제입니다. 동시성 문제는 보통 트래픽이 많을 때 발생하게 됩니다.

  1. 사용자 A가 상품을 구매하려고 재고량을 조회한 결과 25개의 재고량이 조회됩니다.
  2. 아직 사용자 A의 UPDATE 구문이 실행되기 전에 사용자 B가 재고량을 조회합니다. 그 결과 25개의 재고량이 조회됩니다.
  3. 사용자 A,B 모두 재고량을 24(25 - 1)개로 수정하는 쿼리를 DB에 날립니다.
  4. 그 결과 2명이 구매를 했음에도 불구하고 24개의 재고량이 남아있는 동시성 문제가 발생합니다.

경쟁 조건
여러 프로세스 및 쓰레드가 동시에 동일한 데이터를 조작할 때 접근 순서 혹은 타이밍에 따라 예상했던 결과가 달라질 수 있는 상황을 의미합니다.


3. 낙관적 락 VS 비관적 락

3-1. 낙관적 락(optimistic lock)

낙관적 락은 자원에 락을 걸지 않고, 동시성 문제가 발생하면 처리합니다. 즉, 충돌이 거의 발생하지 않을 것이라고 가정하고, 충돌이 발생한 경우에 대비하는 방식입니다.

  • 수정 시 수정했다고 명시하여 다른 트랜잭션이 동일한 조건으로 값을 수정할 수 없게 합니다.
  • version과 같은 별도의 컬럼을 추가하여 충돌 발생을 막습니다.
  • 충돌이 발생한 경우, DB가 아닌 애플리케이션 단에서 처리합니다.

3-2. 비관적 락(pessimistic lock)

비관적 락은 충돌이 발생할 확률이 높다고 가정하여, 실제로 데이터에 액세스하기 전에 먼저 락을 걸어 충돌을 예방하는 방식입니다.

공유락(Shared Lock)

  • 다른 트랜잭션이 잠긴 객체를 읽고 다른 공유락을 생성하는 것은 허용하지만, 쓰기나 베타락을 생성하는 것은 허용하지 않습니다.
  • 여러 트랜잭션이 동일한 행에 공유락을 생성할 수 있습니다. 즉, 다른 트랜잭션이 읽고 있는 행을 읽을 수 있습니다.
  • 공유락이 걸려있는 행에 베타락을 걸 수 없습니다.

베타락(Exclusive Lock)

  • 동일한 행에 다른 트랜잭션을 생성하는 것을 허용하지 않습니다.
  • 다른 트랜잭션에서 베타락을 생성할 수 없기 때문에 쓰기가 불가능합니다.
  • 다른 트랜잭션에서 공유락을 생성할 수 없습니다. 단, 락을 쓰지 않는 읽기는 가능합니다.

3-3. 낙관적 락을 사용하면 좋은 경우

  • 데이터 충돌이 자주 일어나지 않을 것이라고 예상되는 경우
  • 조회 작업이 많아 동시 접근 성능이 중요한 경우

3-4. 비관적 락을 사용하면 좋은 경우

  • 데이터의 무결성이 중요한 경우
  • 데이터 충돌이 많이 발생할 것으로 예상되는 경우

결제 로직에서는 데이터의 무결성이 더 중요하다고 판단되어 비관적 락(베타락)을 사용하게 되었습니다.


4. 베타락 적용

JPA에서 베타락을 사용할 수 있는 여러 방법 중 QueryDSL을 이용한 방법을 사용하기로 했습니다.
LockedModeType.PESSIMISTIC_WRITE 를 이용해 베타락을 사용할 수 있습니다.

// ProductRepositoryCustomImpl
@Override
public Optional<Product> findByIdWithLock(final long productId) {
    return Optional.ofNullable(jpaQueryFactory.from(product)
        .where(product.productId.eq(productId))
        .select(product)
        .setLockMode(LockModeType.PESSIMISTIC_WRITE)
        .fetchOne());
}
// UserProductService
@Transactional
public void decreaseProductQuantityWithLock(final long productId, final int quantity) {
    Product product = productRepository.findByIdWithLock(productId)
        .orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND));

    // 재고량 검증
    if (quantity > product.getStockQuantity()) {
        throw new CustomException(ErrorCode.PRODUCT_STOCK_NOT_ENOUGH);
    }

    product.decreaseStockQuantity(quantity);
    productRepository.save(product);
}

쿼리가 실행될 때 아래와 같이 for update가 추가된 것을 확인할 수 있습니다.


5. 테스트 결과

위에서 진행한 테스트를 동일하게 실행한 결과, 테스트가 정상적으로 통과합니다!


📗 참고 자료

profile
주경야독

0개의 댓글