본문 바로가기
Back-End/강의

[재고시스템으로 알아보는 동시성이슈 해결방법] 1. 들어가며

by 7533ymh 2022. 8. 24.

재고 감소 로직 구현

  • 재고시스템으로 동시성 이슈 해결법을 알아보자

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

댓글