-
재고 시스템에서 동시성 문제백엔드 관련 강의 공부/동시성 이슈 - 재고시스템 2024. 2. 3. 15:42
Stock 엔티티
@Entity @Getter public class Stock { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private Long productId; private Long quantity; public Stock() { } public Stock(Long productId, Long quantity) { this.productId = productId; this.quantity = quantity; } public void decrease(Long quantity) { if (this.quantity - quantity < 0) { throw new RuntimeException("재고는 0개 미만이 될 수 없습니다."); } this.quantity -= quantity; } }
StockService.class
@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.save(stock); } }
간단한 주문 로직을 만들었다. 매번 사용하는 스프링 코드이기 때문에 설명은 생략하겠다.
StockServiceTest.class
@Test public void 재고감소() { stockService.decrease(1L, 1L); // 100 - 1 = 99 Stock stock = stockRepository.findById(1L).orElseThrow(); assertEquals(99, stock.getQuantity()); }
이렇게 간단하게 하나만 삭제하는 것은 테스트가 잘 통과한다.
@Test public void 동시에_100개의_요청() throws InterruptedException { int threadCount = 100; ExecutorService executorService = Executors.newFixedThreadPool(32); // Excutors는 비동기로 실행하는 작업을 단순화해서 사용할 수 있게 해주는 자바의 API이다. CountDownLatch latch = new CountDownLatch(threadCount); // CountDownLatch는 다른 thread에서 수행중인 작업이 완료될 때까지 도와주는 클래스 for (int i = 0; i < threadCount; i++) { executorService.submit(() -> { try { stockService.decrease(1L, 1L); } finally { latch.countDown(); } }); } latch.await(); Stock stock = stockRepository.findById(1L).orElseThrow(); // 100 - (1 * 100) = 0; assertEquals(0, stock.getQuantity()); }
하지만 이렇게 100개를 구매하는 로직은 제대로 수행되지 않는다.
그 이유는 race condition이 일어났기 때문이다. race condition이란 둘 이상의 Thread가 공유 데이터에 access하여 동시에 변경하려고 할 때 발생하는 문제이다.
왼쪽의 사진처럼 Thread-1이 update한 객체를 Thread-2가 select하고 update할 것이라고 예상되지만 실제로는 오른쪽 사진처럼 Thread-1이 update를 하기 전에 Thread-2가 select를 해서 갱신되기 전의 값을 조회하는 것이다. 2개의 Thread가 갱신을 하지만 둘 다 재고가 5인 상태에서 1을 감소하는 update를 하기 때문에 하나는 무시된다.
이러한 문제점을 해결하기 위해서는 하나의 Thread가 모두 종료된 이후에 나머지 Thread가 접근하게 하면 된다.
Synchronized
@Transactional public synchronized void decrease(Long id, Long quantity) { // 메서드 선언부에 synchronized를 붙여주면 해당 메서드는 한 개의 Thread에서만 접근 가능 Stock stock = stockRepository.findById(id).orElseThrow(); stock.decrease(quantity); stockRepository.saveAndFlush(stock); }
자바에서 제공하는 Synchronized를 선언부에 넣어주면 해당 메서드는 한 개의 Thread에서만 접근이 가능하다.
하지만 이 방법에도 에러가 발생하게 되는데, 그 이유는 스프링에서 트랜잭션의 동작 방식 때문이다.
@Transactional 어노테이션을 이용하면 우리가 만든 클래스를 매핑한 새로운 클래스를 만들어서 사용을 하는데, decrease() 메서드가 호출되었고, 실제 db에 반영되기 전에 다른 Thread가 decreaes() 메서드를 호출하기 때문이다.
다른 Thread는 갱신되기 전의 값을 가져가기 때문에 이전과 같은 에러가 발생하는 것이다.
@Transactional 어노테이션을 주석처리 하면 정상적으로 테스트 케이스가 성공되지만 좋은 방법이 아니다.
Synchronized의 추가 문제점
자바의 Synchronized는 하나의 프로세스 안에서만 보장이 된다. 서버가 하나일 때에는 괜찮지만 서버가 여러 개로 분할된다면 데이터의 접근을 여러 곳에서 할 수 있으므로 문제점이 발생한다.
서버가 여러 대면 여러 Thread에서 동시에 접근할 수 있기 때문에 race condition 문제가 발생한다.
'백엔드 관련 강의 공부 > 동시성 이슈 - 재고시스템' 카테고리의 다른 글
redis를 활용한 동시성 문제 해결 - 2. Redisson (0) 2024.02.04 redis를 활용한 동시성 문제 해결 - 1. Lettuce (0) 2024.02.04 mysql을 활용한 동시성 문제 해결 - 3. Named Lock (0) 2024.02.03 mysql을 활용한 동시성 문제 해결 - 2. Optimistic Lock (0) 2024.02.03 mysql을 활용한 동시성 문제 해결 - 1. Pessimistic Lock (0) 2024.02.03