이번 글에서는 최상용님의 "재고시스템으로 알아보는 동시성이슈 해결방법"을 정리해 보려고 합니다.
@Entity
public class Stock {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
...
public void decrease(long quantity) {
if (this.quantity - quantity < 0) {
throw new IllegalArgumentException("재고는 0개 미만이 될 수 없습니다.");
}
this.quantity -= quantity;
}
}
@Transactional
public void decrease(Long id, Long quantity) {
// 재고 조회
Stock stock = stockRepository.findById(id).orElseThrow();
// 재고 감소
stock.decrease(quantity);
}
재고 감소 기능을 만들고, 잘 작동하는지 테스트 해보겠습니다.
@BeforeEach
void setUp() {
stockRepository.saveAndFlush(new Stock(1L, 100L));
}
@Test
void 동시에_100개의_요청() throws InterruptedException {
int threadCount = 100;
// ExecutorService는 비동기로 실행하는 작업을 단순화하여 사용할 수 있게 도와주는 Java API
ExecutorService executorService = Executors.newFixedThreadPool(32);
/**
* 100개의 요청이 모두 끝날 때까지 기다려야 하므로 CountDownLatch 활용
* CountDownLatch는 다른 쓰레드에서 수행 중인 작업이 완료될 때까지 대기할 수 있도록 도와주는 클래스
*/
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < 100; i++) {
executorService.submit(() -> {
try {
stockService.decrease(1L, 1L);
} finally {
latch.countDown();
}
});
}
latch.await();
Stock stock = stockRepository.findById(1L).orElseThrow();
assertEquals(0, stock.getQuantity());
}
재고 100개를 저장하고, 100개의 요청을 보내 재고가 0이 되는지 확인하는 테스트 코드입니다.
Executors
, CountDownLatch
를 사용해 비동기로 동작하도록 했습니다.
성공할 줄 알았지만, 예상과 반대로 재고가 남아 테스트에 실패했습니다.
레이스 컨디션이 발생했기 때문에 실패했습니다.
레이스 컨디션이란?
멀티스레드 환경에서 레이스 컨디션은 둘 이상의Thread
가 공유 데이터에 엑세스할 수 있고, 동시에 변경을 하려고 할 때 발생하는 문제입니다.
Thread-1이 데이터를 가져가 갱신을 하기 이전에 Thread-2가 가져가서 데이터 갱신이 누락되는 문제가 발생했습니다.
하나의 스레드에서 작업이 완료된 이후에 다른 스레드가 데이터에 접근할 수 있도록 해야 합니다.
Java & Spring
에서는 다음과 같은 기술로 레이스 컨디션을 해결할 수 있습니다.
public synchronized void decrease(Long id, Long quantity) {
// Stokc 조회
Stock stock = stockRepository.findById(id).orElseThrow();
// 재고 감소
stock.decrease(quantity);
// 갱신된 값 저장
stockRepository.save(stock);
}
synchronnized
를 활용하면 한 개의 스레드만 접근이 가능하도록 할 수 있습니다.
synchronnized
를 사용한 함수에서 @Transactional
을 사용하면 레이스 컨디션이 발생합니다.
@Transactional
은 트랜잭션 종료 시점에 데이터베이스 업데이트를 하게 됩니다.
실제 데이터베이스가 업데이트 되기 전에 다른 Thread가 decrease()
를 호출하면
갱신되기 전에 값을 가져가서 레이스 컨디션이 또 다시 발생하게 됩니다.
그리고 synchronnized
는 하나의 프로세스 안에서만 보장됩니다.
만약 서버가 2대 이상이라면 데이터 접근을 여러 대에서 할 수 있기 때문에
이렇게 되면 또 다시 레이스 컨디션이 발생하게 됩니다.
그래서 실제 운영 중인 서비스에서는
synchronnized
를 사용하지 않는다고 합니다.