재고 감소 로직 구현
Domain
@Entity
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
protected Stock() {
}
public Stock(Long productId, Long quantity) {
this.productId = productId;
this.quantity = quantity;
}
public void decrease(Long quantity) {
if (this.quantity < quantity) {
throw new IllegalArgumentException("재고가 부족합니다.");
}
this.quantity = this.quantity - quantity;
}
public Long getQuantity() {
return quantity;
}
}
public interface StockRepository extends JpaRepository<Stock, Long> {
}
Service
@Service
public class StockService {
private final StockRepository stockRepository;
public StockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
/**
* Stock 조회
* 재고 감소
* 저장
*/
@Transactional
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("재고가 존재하지 않습니다."));
stock.decrease(quantity);
stockRepository.saveAndFlush(stock); // saveAndFlush를 하는 이유가 뭘까?? 더티 체킹으로 하면 안되나??
}
}
Test
@SuppressWarnings("NonAsciiCharacters") // 한글 경고 무시
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) // _부분 공백으로 처리
@SpringBootTest
class StockServiceTest {
@Autowired
private StockService stockService;
@Autowired
private StockRepository stockRepository;
@BeforeEach
void setUp() {
Stock stock = new Stock(1L, 100L);
stockRepository.saveAndFlush(stock);
}
@AfterEach
void tearDown() {
stockRepository.deleteAllInBatch();
}
@Test
void 재고를_감소한다() {
// given
// when
stockService.decrease(1L, 1L);
// then
Stock stock = stockRepository.findById(1L).get();
assertThat(stock.getQuantity()).isEqualTo(99L);
}
}
- 100의 재고에서 1개의 재고를 뺀다면 99개가 될 것이다.
문제점
- 지금의 테스트 케이스는 요청이 하나씩 들어올때이다.
- 만약 동시에 여러개가 들어온다면?
@Test
void 동시에_100건의_요청() throws InterruptedException {
// given
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
// when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
stockService.decrease(1L, 1L);
} finally {
latch.countDown();
}
});
}
latch.await();
// then
Stock stock = stockRepository.findById(1L).get();
assertThat(stock.getQuantity()).isZero();
}
- 동시에 재고를 1개 감소시키는 요청이 100건이 들어온다고 하면 재고는 0이 되어야 할것이다. 정말 그럴까??

- 테스트는 실패하게 되고 재고는 89개로 남아있게 된다.
왜 그럴까?
나의 생각

- 예측해보면, 기본적인 스프릥의 트랙잭션 격리수준이
READ_COMMITTED
이기때문에 한 스레드에서 재고를 SELECT(100)하고 감소(99)까지 하였지만 해당 트랙잭션이 아직 커밋되지 않은 상황에서 다른 스레드에서 재고를 SELECT(100)하였기 때문에 해당 감소로직이 중복되는 문제일 것 같다.
- 즉, A라는 Thread와 B라는 Thread가 있을 시 A에서 Stock을 조회하고 감소시키고 커밋하기 전에 이미 B에서 조회를 했다면 그 Stock은 100개의 수량을 가지고 있어 결국, 두번의 1 감소 로직을 거쳤지만 99의 결과를 받게 되는 것이다.
문제점
- 결론은 레이스 컨디션(Race Condition)때문이다.
- 둘 이상의 스레드가 공유 데이터에 엑세스할 수 있고 동시에 변경하려고 할 때 발생하는 문제

결론
- 트랜잭션 격리수준보단 스레드가 공유 데이터에 동시에 접근한다는 관점에서 문제점이 발생하는 것 같다.
- 해당 문제점을 보면서 싱글톤의 동시성 문제점과 동일하게 느껴졌다.
- 동시성 문제를 어떻게 해결할까에 대한 궁금증이 많았는 데 해당 강의를 통해 궁금증을 어느정도 해소했으면 좋겠다.
Reference
댓글