본문 바로가기

이커머스 프로젝트

상품 주문 로직 구현해보기(with 동시성 제어 테스트 및 트러블 슈팅)

동시성 문제란?

주문 로직에 대해 살펴보기 전, 간단히 동시성 문제가 무엇인지 예시와 함께 알아보도록 하겠습니다 :)

 

동시성 문제여러 스레드가 동시에 데이터에 접근하고 수정할 때 발생할 수 있는 문제입니다. 예를 들어, 여러 사용자가 동시에 같은 판매자의 수익을 업데이트하는 시나리오를 생각해 보며 개념에 대해 좀 더 깊이 이해해 보겠습니다!

 

  1. 판매자 1의 현재 수익은 0원입니다.
  2. 스레드 A가 판매자 1의 수익을 100원 올리려 합니다.
  3. 동시에 스레드 B가 판매자 1의 수익을 200원 올리려 합니다.
  4. 스레드 A와 B 둘 다 판매자 1의 현재 수익을 읽습니다. 이 시점에 수익은 여전히 0원입니다.
  5. 스레드 A가 수익을 100원으로 업데이트하려고 하지만 아직 커밋하지 않았습니다.
  6. 스레드 B가 수익을 200원으로 업데이트하고 커밋합니다.
  7. 스레드 A가 이제서야 수익을 100원으로 업데이트하고 커밋합니다.(처음 수익인 0원 + 100원)
  8. 최종적으로 수익은 100원입니다. 하지만 스레드 A B 업데이트가 모두 반영되었다면, 수익은 100원과 200, 300원이 되어야 하는 것이 정확합니다.

이 예시는 여러 스레드가 동시에 데이터를 수정할 때 발생할 수 있는 "경쟁 상태(Race Condition)"를 잘 보여줍니다.

 

경쟁 상태두 개 이상의 스레드가 데이터의 일관성을 해치는 방식으로 동시에 데이터에 접근하려고 할 때 발생하는 동시성 문제입니다. 위 예시에서 스레드 A와 B가 동시에 판매자 1의 수익을 업데이트하려고 했으나, 서로의 변경 사항을 고려하지 못해 최종 수익이 올바르게 반영되지 않았습니다.

 

여러 트랜잭션이 동시에 실행될 때 이러한 경쟁 상태가 발생하면 데이터 일관성이 깨질 수 있으며, 이를 해결하기 위한 다양한 락킹 메커니즘과 동시성 제어 기법이 필요합니다.

주문 로직 살펴보기

저는 돈과 관련된 3개(구매자의 잔고, 판매자의 판매 수익, 재고)의 데이터에 대해 락을 사용하였습니다. 

이러한 데이터들은 돈과 관련이 있습니다. 따라서 동시성이 떨어질 수 있음에도 데이터의 정합성이 상당히 중요하다 생각하여 락을 적용하였습니다.

 

public interface BuyerBalanceRepository extends JpaRepository<BuyerBalance, Long> {

  @Lock(LockModeType.PESSIMISTIC_WRITE)
  @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value="10000")})
  Optional<BuyerBalance> findByMemberId(Long memberId);
}

public interface SellerRevenueRepository extends JpaRepository<SellerRevenue, Long> {
  @Lock(LockModeType.PESSIMISTIC_WRITE)
  @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value="10000")})
  Optional<SellerRevenue> findByMemberId(Long memberId);
}

public interface StockRepository extends JpaRepository<Stock, Long> {

  Optional<Stock> findById(Long id);

  @Lock(LockModeType.PESSIMISTIC_WRITE)
  @Query("select s from Stock s where s.id = :id")
  @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value="10000")})
  Optional<Stock> findByIdForUpdate(@Param("id") Long stockId);

}

 

주문 로직 코드를 보기 전 동작 순서에 대해 간단히 살펴보겠습니다.

 

  1. 주문하려는 상품 정보 조회
  2. 주문 상태 검증 : SOLD_OUT이나 SELL_STOPPED 상태인 상품이 주문에 포함되었는지 확인
  3. 재고 확인 : 상품의 재고가 충분한지 확인, 이 과정에서 재고 락을 설정
  4. 총 구매 금액 계산
  5. 구매자의 잔고 확인 및 차감 : 이 과정에서 구매자 계좌에 락을 설정하여 잔고의 동시 변경 제어
  6. 재고 차감 
  7. 판매자의 수익 계산 및 증가 : 이 과정에서 판매자 계좌에 락을 설정하여 수익의 동시 변경 제어
  8. 주문 정보 생성 및 저장
  9. 응답 생성

코드는 다음과 같습니다.

 

  @Transactional
  public OrderResponseDto orderItems(Long buyerId, OrderRequestDto orderRequestDto) {
    
    // 주문 하려는 상품 정보 조회
    List<Item> itemList = retrieveItemList(orderRequestDto);
    
    List<ItemOrderDto> itemOrders = orderRequestDto.getItemOrders();

    // 주문하려는 상품 중 SOLD_OUT or SELL_STOPPED 상태의 상품이 있는지 조회
    validateOrderStatus(itemList);

    // 주문하려는 상품의 재고가 충분한지 확인, 이 과정에서 재고에 락 설정
    validateStockAvailability(itemList, itemOrders);

    // 총 구매 금액 계산
    long totalPrice = calculateTotalPrice(itemList, itemOrders);

    // 구매자의 잔고가 충분한지 확인, 이때 잔고에 락 설정
    validateBuyerBalance(buyerId, totalPrice);

    // 구매 하고자 하는 상품의 재고를 감소시킴
    reduceStock(itemList, itemOrders);

    // 각 판매자의 대한 수익을 계산한 후, 이를 판매자의 계좌 반영(이때 수익에 락 설정)
    increaseSellerRevenues(getSellersRevenue(itemList, itemOrders));

    // 주문 정보를 생성 후 저장
    Order order = createAndSaveOrder(buyerId, itemList, itemOrders, totalPrice);

    // Response 형식에 맞게 Convert
    return OrderResponseDto.of(order);
  }

 

Request와 Response 형식은 다음과 같습니다.

 

이때 ItemId가 1인 상품의 가격은 10000원(5개 주문), 2인 상품의 가격은 20000원(10개 주문)으로 등록하였습니다.

이때 각 판매자의 수익이 잘 반영된 것을 확인할 수 있습니다.

 

또한 초기의 잔고가 백만원이였지만 

구매 이후 총 금액(25만원)을 뺀 75만 원이 된 것을 볼 수 있습니다.

 

두 상품의 초기 재고를 20개로 등록해놓았고, 주문 이후 두 상품 모두 주문 개수만큼 재고가 차감되었음을 확인하였습니다.


동시성 제어 테스트 코드 작성

저는 2개 남은 상품에 대해 10명의 사용자가 동시에 주문 요청을 보냈을때, 2명의 사용자의 주문 요청만이 성공하는지에 대해 테스트해보았습니다.(10명의 사용자 모두 1개씩 주문을 요청합니다)

위 상황에 대한 동시성 제어 테스트 코드를 작성하기 위해, ExecutorService, CountDownLatch, Future를 사용하였습니다.

 

ExecutorService는 쓰레드 풀을 관리하는 서비스로, 병렬 작업을 수행할 때 사용합니다. 여기서는 10개의 쓰레드를 동시에 실행하기 위해 사용합니다.

 

CountDownLatch는 다른 쓰레드들이 특정 작업을 완료하길 기다리게 하는 동기화 도구입니다. 이 예시에서는 10개의 쓰레드가 모두 완료될 때까지 기다리게 하는 데 사용됩니다.

 

Future는 비동기 연산의 결과를 나타냅니다. 여기서는 각 쓰레드에서의 주문 요청 결과를 저장하는 데 사용합니다.

 

  @Test
  @DisplayName("2개 남은 상품에 대해 10명의 사용자가 동시에 주문 요청을 보내는 경우 테스트")
  public void test_orderItems_concurrency() throws InterruptedException{

    // 고정된 쓰레드의 숫자만큼 쓰레드 풀을 생성
    ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT);

    // 일정 개수(여기선 10개) 쓰레드가 끝난 후에 다음 쓰레드가 실행될 수 있도록 대기하고
    // 끝나면 다음 쓰레드가 실행되도록 함
    CountDownLatch latch = new CountDownLatch(THREAD_COUNT);

    // submit으로 리턴 받은 비동기 수행 결과값을 저장할때 사용(Future)
    List<Future<OrderResponseDto>> futures = new ArrayList<>();

    for(int i = 0; i < THREAD_COUNT; i++){
      Long buyerId = (long) i+2; // 구매자 아이디 2부터 11까지

      // executorService.submit은 작업을 쓰레드 풀의 큐에 추가하는 것으로
      // 여러 쓰레드가 동시에 동작하도록 요청을 쓰레드 풀에 분산시키는 것
      // 이를 통해 실제 서비스 환경에서 사용자들이 동시에 요청을 보낼 떄와 유사한 상황을 재현할 수 있으므로 동시성 문제 테스트에 적합
      futures.add(executorService.submit(() -> {
        try{
          System.out.println("고객" + buyerId + "예약 시작");
          return buyerService.orderItems(buyerId, orderRequestDto);
        } finally{
          // 쓰레드가 끝날때마다 카운트를 감소시킴
          latch.countDown();
        }
      }));
    }

    // 카운트가 0이 되면 대기가 풀리고, 이후 스레드가 실행됨
    latch.await(); // 모든 쓰레드 완료 시 까지 대기한다는 의미

    long successCount = futures.stream()
        .filter(future -> {
          try {
            return future.get() != null;
          } catch (Exception e) {
            return false;
          }
        })
        .count();

    assertEquals(2, successCount); // 성공한 주문 수가 2여야 함
  }

 

위와 같이 테스트 코드를 작성 후 실행해 보았습니다.

 

두개의 주문만이 성공될 것이라는 생각과 달리.... 10개의 주문이 모두 통과하는 실패를 맛보았습니다 ㅠㅠ

 


동시성 테스트 코드 오류 해결

저는 해당 오류가 Eager Loading에 의해 발생했다는 것을 확인했습니다. 오류 해결 과정에 대해 간단히 설명드리고자 합니다.

 

  @Transactional
  public OrderResponseDto orderItems(Long buyerId, OrderRequestDto orderRequestDto) {
    
    // 주문 하려는 상품 정보 조회
    List<Item> itemList = retrieveItemList(orderRequestDto);
    
    ...
  }

orderRequestDto에는 주문하고자 하는 상품들의 Id 리스트가 담겨 있고, retrieveItemList는 이를 바탕으로 해당 Item 리스트를 가져오는 역할을 합니다.

 

하지만 스레드들이 다 초기의 재고(2개) 값을 가져오는 것이 디버깅을 통해 확인되었습니다.

 

따라서 이후 재고 값을 줄이는 과정에서

private void reduceStock(List<Item> itemList, List<ItemOrderDto> itemOrders) {
    IntStream.range(0, itemList.size())
        .forEach(i -> {
          Stock stock = itemList.get(i).getStock();
          int currentQuantity = stock.getQuantity();
          int orderQuantity = itemOrders.get(i).getQuantity();

          stock.setQuantity(currentQuantity - orderQuantity);
        });
  }

currentQuantity는 2, orderQuantity는 1 

즉 최종적으로 stock을 계속 1로 덮어 씌우면서 주문이 진행되었던 것입니다. 

 

따라서 주문 10개가 모두 진행되었음에도 재고가 1개가 남아있는 것으로 표시되었습니다.

이러한 문제가 나타난 원인은 Eager Loading에 있었습니다.

 

@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Item extends BaseTimeEntity {

  ...
  
  @OneToOne(cascade = CascadeType.PERSIST) //-> 이후 Lazy Loding 적용
  @JoinColumn(name = "stock_id")
  private Stock stock;

  ...
}

JPA에서 @OneToOne 관계의 기본 fetch 전략은 EAGER입니다. 즉 이는 OneToOne 관계가 설정된 엔티티를 조회할 때 관련된 엔티티도 함께 즉시 로딩을 한다는 의미입니다.

 

따라서 retrieveItemList() 메서드를 통해서 Item List를 가져올 때, 재고 값들이 다 2로 세팅되어 있었던 것입니다. Eager Loading 전략에 의해 각 쓰레드가 동시에 동일한 초기 상태의 재고 값을 읽어오게 되어 발생한 문제였습니다.

 

 

즉, 각 쓰레드가 서로의 변경 사항을 고려하지 않고 동일한 초기 상태의 데이터를 바탕으로 연산을 수행하여 재고가 올바르게 감소되지 않았던 것입니다.

 

이러한 문제를 저는 Lazy Loading 전략을 사용하여 해결하였습니다.

 

  @Transactional
  public OrderResponseDto orderItems(Long buyerId, OrderRequestDto orderRequestDto) {
    
    // 주문 하려는 상품 정보 조회
    List<Item> itemList = retrieveItemList(orderRequestDto);
    
    ...

    // 주문하려는 상품의 재고가 충분한지 확인, 이 과정에서 재고에 락 설정
    validateStockAvailability(itemList, itemOrders);

    ...
    
    // 구매 하고자 하는 상품의 재고를 감소시킴
    reduceStock(itemList, itemOrders);

    ...
  }
  
  
  private void reduceStock(List<Item> itemList, List<ItemOrderDto> itemOrders) {
    IntStream.range(0, itemList.size())
        .forEach(i -> {
          Stock stock = itemList.get(i).getStock();
          int currentQuantity = stock.getQuantity();
          int orderQuantity = itemOrders.get(i).getQuantity();

          stock.setQuantity(currentQuantity - orderQuantity);
        });
  }

Eager Loading 전략을 사용한 경우, retrieveItemList()에서 Item 로딩 시 Stock도 함께 로딩되었습니다. 반면 Lazy Loading의 경우 Item만 로딩하고 Stock은 로딩하지 않습니다.

Stock이 필요한 시점 까지 로딩을 지연 시키므로, 여러 쓰레드가 동일한 Stock 정보(2개)를 공유하지 않게 됩니다.

 

이후 validateStockAvailability 메서드에서는 findByForUpdate를 통해 Stock을 조회하고 락을 걸게 됩니다. Lazy Loading의 경우 이때 Stock 객체가 로딩되므로, 최신 재고 값을 가져오게 되는 것입니다.

 

Lazy Loading 적용 이후 두개의 주문만이 성공적으로 처리되었습니다 :)


긴글 읽어 감사합니다 ^^

틀린 내용이나 혹시 조언해주실 내용이 있으시다면 댓글 부탁드립니다!!