IT 개발 관련/[프로젝트]

[예약구매] 재고 감소 동시성 테스트

Baileyton 2024. 7. 21. 17:15
728x90

여러 사용자가 동시에 재고를 감소시킬 때, 재고 수량이 올바르게 감소하지 않거나, 재고가 음수가 되는 등의 문제가 발생할 수 있습니다. 이번 포스팅에서는 스프링 부트와 JPA를 사용하여 재고 감소 기능을 구현하고, 동시성 문제를 해결하는 방법과 이를 테스트하는 방법을 소개합니다.

 

1. 재고(Stock) 엔티티

@Entity
@Getter
public class Stock {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long productId;

    private Long quantity;

    public Stock(Long productId, Long quantity) {
        this.productId = productId;
        this.quantity = quantity;
    }

    public Stock() {

    }

    public void decrease(Long quantity) {
        if (this.quantity - quantity < 0) {
            throw new RuntimeException("재고는 0개 미만이 될 수 없습니다.");
        }

        this.quantity -= quantity;
    }
}

 

2. 재고 서비스

@Service
@RequiredArgsConstructor
public class StockService {

    private final StockRepository stockRepository;

    public void decrease(Long id, Long quantity) {
        // Stock조회
        // 재고를 감소 진행
        // 감소된 값을 저장
        Stock stock = stockRepository.findById(id).orElseThrow();
        stock.decrease(quantity);
        stockRepository.saveAndFlush(stock);
    }
}

 

3. 재고 레포지토리

@Repository
public interface StockRepository extends JpaRepository<Stock, Long> {

}

 

4. 동시성 테스트

재고 감소 기능이 여러 스레드에서 동시에 호출될 때 동작을 확인하는 테스트 코드를 작성합니다.

@SpringBootTest
class StockServiceTest {

    @Autowired
    private StockService stockService;

    @Autowired
    private StockRepository stockRepository;

    // 각 테스트 코드는 독립적으로 존재
    // 다른 테스트 영향을 주면 X
    // 독립적으로
    @BeforeEach
    public void createStock() {
        stockRepository.saveAndFlush(new Stock(1L, 100L));
    }

    @AfterEach
    public void deleteDb() {
        stockRepository.deleteAll();
    }

    @Test
    void 재고감소() {
        // given BeforeEach 100개를 생성
        // when
        stockService.decrease(1L, 1L);

        Stock stock = stockRepository.findById(1L).orElseThrow();
        Assertions.assertThat(stock.getQuantity()).isEqualTo(99);
        // then
    }

    @Test
    void 동시에_100개의_요청() throws InterruptedException {
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch countDownLatch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    stockService.decrease(1L, 1L);
                } finally {
                    countDownLatch.countDown();
                }
            });
        }
        countDownLatch.await();

        Stock stock = stockRepository.findById(1L).orElseThrow();

        Assertions.assertThat(stock.getQuantity()).isEqualTo(0);
    }

}

 

 

차이가 발생한 걸 확인 할 수 있다.

 

5. 동시성 문제 해결

여기서 데이터베이스의 Pessimistic Lock을 사용하는 방법을 소개합니다.

 

Pessimistic Lock 적용

 

재고 레파지토리

@Repository
public interface StockRepository extends JpaRepository<Stock, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT s FROM Stock s WHERE s.id = :id")
    Stock findByIdWithPessimisticLock(Long id);
}

 

PessimisticLockStockService

@Service
@RequiredArgsConstructor
public class PessimisticLockStockService {

    private final StockRepository stockRepository;

    @Transactional
    public void decrease(Long id, Long quantity) {
        Stock stock = stockRepository.findByIdWithPessimisticLock(id);

        stock.decrease(quantity);

        stockRepository.save(stock);
    }
}

 

추가 작성한 뒤 Test 코드에서 StockService -> PessimisticLockStockService 로 변경하여 주고

 

다시 테스트를 실행하면, 동시성 문제 없이 재고 감소가 올바르게 작동하는 것을 확인할 수 있습니다.

 

728x90