트러블슈팅

[Spring] 재고처리 동시성 이슈 해결하기 (1/2)

jny0 2023. 7. 22. 02:42

이커머스 프로젝트를 진행했는데 트러블슈팅 내역을 정리해보려고 한다.

먼저 상품의 재고처리에 대한 동시성 이슈이다.

현재 상품 주문과 주문취소 시 상품의 재고변경로직이 동작하는데 여기서 동시성 이슈가 발생한다.

 

1. ❓동시성 이슈란

  • 여러 스레드가 동시에 같은 인스턴스의 필드 값을 변경하면서 발생하는 문제
  • 여러 스레드가 하나의 자원을 공유하고 있기 때문에 같은 자원을 두고 경쟁상태(Race Condition)가 됨

 

2. 코드와 테스트

코드는 간단히 아래와 같이 구성되어 있다.

// Product.java
@Entity
public class Product extends BaseEntity {

	// ... 기타 필드

    @Column(nullable = false)
    private Long stock;
    
    private Long sales;
    
    public void updateStockAndSalesByOrder(Long quantity) {
        this.stock -= quantity;
        this.sales += quantity;
    }

	// .. 기타 로직
}
//ProductService.java
@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;
    
    // ... 기타 로직

    @Transactional
    public void updateStockAndSalesByOrder(Product product, Long quantity){
        product.updateStockAndSalesByOrder(quantity);
    }
    
    // ... 기타 로직

}

 

정말 멀티스레드 환경에서 재고변경로직을 테스트해보기 위한 테스트코드이다.

@Test
@DisplayName("주문에 의한 재고 업데이트 동시성 이슈")
void testUpdateStockAndSalesByOrderSuccess() throws InterruptedException{
    int threadCount = 100;
    ExecutorService executorService = Executors.newFixedThreadPool(32); // ThreadPool 구성
    CountDownLatch latch = new CountDownLatch(threadCount); // 다른 스레드에서 작업이 완료될 때까지 대기

    for (int i = 0; i < threadCount; i++) {
        executorService.submit(() -> {
                    try {
                        productService.updateStockAndSalesByOrder(product.getId(), 1L);
                    }
                    finally {
                        latch.countDown(); //countDown()호출 시 Latch의 숫자가 1씩 감소
                    }
                }
        );
    }
    latch.await(); // Latch의 숫자가 0이 될때까지 기다림

    Product newProduct = productService.findById(product.getId()).orElse(null);
    assertThat(newProduct).isNotNull();
    assertThat(newProduct.getStock()).isZero();
    assertThat(newProduct.getSales()).isEqualTo(100L);
}
  • ExecutorService : 스레드풀 구성, Executors 를 사용하여 ExecutorService 객체를 생성
  • CountDownLatch :  멀티스레드가 100번 작업이 모두 완료한 후, 테스트를 하도록 기다리게 함.

 

테스트 실패

멀티스레드 환경을 구현한 테스트케이스에서 100개의 재고를 가진 상품에 대해 테스트했을 때 100번의 재고 감소 작업이 이루어진 후에 재고가 0이 되어야하지만 아래와 같이 60개로 조회되는 문제가 발생한다. 레이스 컨디션 때문이다..!

 

 

3. 멀티스레드 환경에서 레이스 컨디션 일어나는 이유

스레드A와 스레드B에서 동시에 재고변경로직이 동작할 때 기대하는 동작은 왼쪽과 같이 순차적으로 로직이 동작하는 것이다.

하지만 실제로는 같은 데이터에 대해 동시에 변경로직이 작동하며 오른쪽과 같이 동작한다.

예상 동작과 실제 동작

즉, 레이스 컨디션을 해결하기 위해서는 하나의 스레드만 접근 가능하도록 해야한다 ❗

 

 

해결방법 1. Synchronized 이용

  • 어플리케이션 레벨에서 메소드에 synchronized를 명시해주어 해결하는 방법이다
  • synchronized는 현재 데이터를 사용하고 있는 스레드를 제외한 나머지 스레드들의 접근을 막아 순차적으로 데이터에 접근할 수 있도록 해준다.
@Transactional
public synchronized void updateStockAndSalesByOrder(Product product, Long quantity){
    product.updateStockAndSalesByOrder(quantity);
    productRepository.saveAndFlush(product);
}

 

문제점

  • Sychronized는 하나의 프로세스 안에서만 보장되기 때문에 단일서버에서만 적용 가능하다
  • 서버가 여러개라면 문제가 발생한다
    • synchronized는 각 인스턴스 안에서만 thread-safe이 보장되기 때문에 분할 서버일 때는 레이스 컨디션 발생
  • 실제 서비스는 거의 분할 서버이기 때문에 synchronized만으로는 동시성 이슈를 해결할 수 없다

 

데이터베이스 락을 이용하여 분할서버에서도 동시성 이슈를 제어할 수 있다. 

다음 글에 이어서!

 

참고

재고시스템으로 알아보는 동시성이슈 해결방법

[Spring & Java] 🚀 재고시스템으로 알아보는 동시성이슈 해결방법

[재고시스템으로 알아보는 동시성이슈 해결방법] 2. Application Level에서의 해결법