인프런 - '재고 시스템으로 보는 동시성 처리' 강의 시청 후 복습하는 포스팅입니다.
SpringBoot 3.2.1 / JPA / lombok / Mysql
‼️ 이 포스팅을 읽으면 어떤 것을 알 수 있나요?
동시성 처리 '1' 인 만큼 동시성 처리에 문제가 되는 코드를 보여주고 왜 문제가 되는지에 대해 다루려고 한다.
코드소개
Stock 객체를 조회하고, 재고를 감소시킨 뒤 남은 수량으로 값을 갱신하는 간단한 코드
* stock 엔티티
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class Stock {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
@Version
private Long version;
public void decrease(Long quantity) {
if (this.quantity - quantity < 0) {
throw new IllegalArgumentException("재고는 0개 미만이 될 수 없습니다.");
}
this.quantity -= quantity;
}
public Stock(Long productId, Long quantity) {
this.productId = productId;
this.quantity = quantity;
}
}
* stock 레포지토리
import com.example.stock.domain.Stock;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
public interface StockRepository extends JpaRepository<Stock, Long> {
}
* stock 서비스
import com.example.stock.domain.Stock;
import com.example.stock.repository.StockRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class StockService {
private final StockRepository stockRepository;
@Transactional
public void decrease(Long id, Long quantity) {
// Stock 조회
Stock stock = stockRepository.findById(id).orElseThrow();
// 재고를 감소 시킨 뒤
stock.decrease(quantity);
// 갱신된 값 저장
stockRepository.saveAndFlush(stock);
}
}
테스트 코드 작성
재고가 100개가 있는 1번 stock 객체에 대해 수량을 1개씩 감소시키는 로직을 동시에 100번 요청하는 코드
@SpringBootTest
class StockServiceTest {
@Autowired
private StockService stockService;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void before() {
stockRepository.saveAndFlush(new Stock( 1L, 100L)); // 수량이 100개인 id가 1L인 stock 객체 생성
}
@AfterEach
public void after() {
stockRepository.deleteAll();
}
@Test
public void 동시에_100개의_요청() throws InterruptedException {
int threadCount = 100;
// Java에서 동시 및 병렬 프로그래밍을 간소화하기 위해 제공되며 스레드풀의 구현을 위한 인터페이스
// 등록된 작업을 실행하는 책임만 가짐
ExecutorService executorService = Executors.newFixedThreadPool(32);
// 다른 쓰레드들에서 일련의 작업이 완료될 때까지 대기하도록 Sync를 맞춰준다.
CountDownLatch latch = new CountDownLatch(threadCount);
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());
}
}
* 코드 설명 *
● @BeforeEach 를 사용하여 테스트 코드 실행 전에 stock객체를 생성 (id는 1L, 수량은 100L)
● for문을 사용하며 threadCount(100)만큼 반복실행하게 했으며, id가 1L인 stock 객체의 수량을 1L개씩 감소시키는 로직
● 재고 감소 로직을 threadCount만큼 반복 실행 후 stock 객체를 조회하여 기대한 수량(0) 이 맞는지 확인
* 테스트 결과 *
위의 테스트에서 재고를 100개로 설정한 후, 1개를 감소시키는 로직을 100개의 쓰레드가 처리하도록 했다.
1개씩 감소시키는 코드를 100개의 쓰레드가 처리했으니 총 100의 재고가 감소되어 100(원래 수량) - 100 = 0 이라고 기대할 수 있다.
‼️ 하지만 결과를 확인해보면 남은 재고의 수량이 0이 아니라서
assertEquals(0, stock.getQuantity()); 이 부분이 pass가 되지 않아 테스트는 실패한다.
‼️ 왜 실패하나요?
► 레이스 컨디션(Race Condition) 이 발생했기 때문
레이스 컨디션(Race Condition) 이란?
둘 이상의 Thread가 공유 데이터에 액세스할 수 있고 동시에 변경을 하려고 할 때 발생하는 문제
위의 코드의 내용대로 그림을 그려봤다.
위의 코드를 작성했을 때의 기대한 결과는 아래 그림과 같다.
먼저 Thead1이 재고가 100개 상태의 Stock 객체에 접근을 하게되고 1개를 감소시킨다 (100 - 1 = 99개로 업데이트)
그 다음으로 Thead2가 재고가 99개 상태의 Stock 객체에 접근을 하게되고 1개를 감소시킨다(99-1 = 98개로 업데이트)
이런식으로 Thread100까지 진행하여 최종적으로 재고는 0개가 남는 것을 기대했을 것이다.
하지만, 레이스 컨디션이 발생했고, 그때의 상황을 그림으로 그려봤다.
그림이 복잡하지만.. 설명을 해보자면
먼저 Thead1이 재고가 100개 상태의 Stock 객체에 접근을 하게되고 1개를 감소시킨다.
그러나 재고가 99개로 업데이트 되기전에 Thead2가 재고가 100인 Stock객체에 접근을 하게되고 1개를 감소시켜 또 재고는 99개가 된다. 여기부터 이미 기대했던 결과와는 달라진다.
이런식으로 계속 동시에 변경을 하려는 시도가 이루어져 결국 Thead100이 마지막으로 실행이 되었을 때 남은 재고는 0이라고 기대를 할 수가 없다. (그림으로는 Thead100이 접근할때는 재고가 76개라고 했지만 이것도 확정이 아닌 계속 바뀐다)
‼️ 어떻게 해결하나요?
► 이런 문제를 해결하기 위해서는 처음에 예상했던 대로 하나의 Thread가 작업을 완료한 이후에 다른 Thread가 데이터에 접근할 수 있도록 하면 된다.
1. syschronized 사용
2. lock 사용
3. redis 사용
다음 포스팅에서는 위의 3가지 방법을 차례로 소개할 예정이다.
'BE > Spring-Boot' 카테고리의 다른 글
[SpringBoot] 파일 시스템에서 특정 디렉토리 및 파일 모니터링 (3) | 2024.09.03 |
---|---|
[SpringBoot] 스케줄러(@Scheduled)가 간헐적으로 동작 안하는 문제 (0) | 2024.05.07 |
[SpringBoot] Spring Security Config에서 permitAll()에 대한 진실과 오해 (4) | 2024.01.06 |
[SpringBoot] Controller Unit Test에서 발생한 401, 403 에러를 해결해보자! (+ Spring Security) (1) | 2023.12.27 |
[JPA Auditing] 생성자/수정자 자동화 (+생성 일시/수정 일시) (0) | 2023.11.03 |