Ollsy 프로젝트에 상품 주문 api의 동시성을 제어해 보았다.
@SpringBootTest
class OrderServiceTest {
@Autowired
private OrderService orderService;
@Autowired
private UserRepository userRepository;
@Autowired
private ItemRepository itemRepository;
@Autowired
private OrderRepository orderRepository;
private List<User> testUserList = new ArrayList<>();
private final static int REQUEST_NUM = 100;
@BeforeEach
void setUser() {
IntStream.range(0, REQUEST_NUM).forEach(i -> {
User user = User.builder()
.name("user"+i)
.nickname(NicknameGenerator.generateNickname())
.email("user"+i)
.role(Role.USER)
.provider("naver")
.providerId("user" + i)
.build();
testUserList.add(user);
});
userRepository.saveAll(testUserList);
}
@Test
public void 동시에_100건_요청() {
ExecutorService executorService = Executors.newFixedThreadPool(32); // 멀티 스레드 환경 구현
CountDownLatch latch = new CountDownLatch(REQUEST_NUM); //총 100건의 요청
for (int i = 0; i < REQUEST_NUM; i++) {
int userIndex = i;
executorService.submit(() -> {
try {
String currentProviderId = testUserList.get(userIndex).getProviderId();
orderService.createOrder(currentProviderId, new OrderRequest(Collections.singletonList(new OrderItemRequest(1L, 2))));
} finally {
latch.countDown();
}
});
}
Item itemStock = itemRepository.findById(1L).orElseThrow();
assertEquals(0, itemStock.getStock());
}
}
위와 같이 멀티 스레드 환경을 임의로 만들어 테스트를 진행했다. Item.id 1의 재고는 100개이고 100명의 유저가 2개씩 동시에 주문을 하도록 코드를 짰다.
실행 결과는 당연히 예상과 다르게 재고가 남게 되었고 데드락까지 걸리게 되었다. 이렇게 동시에 여러 주문이 들어오게 된다면 예상치 못한 동시성 문제가 발생할 수 있다. 이 문제를 비관락을 사용하여 문제를 해결해 보겠다.
@DisplayName("비관락을 사용하여 createOrder 메서드 동시성 제어")
@Test
public void CreateOrderConcurrencyTestWithPessimistic() throws InterruptedException { // 메서드명 변경
// 이 테스트는 Item.id 1, 초기 재고가 100개
// 각 요청은 Item 1L에 대해 quantity 2를 주문
// 따라서 50개 요청이 성공하고, 51번째 요청부터는 재고 부족 예외가 발생
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(REQUEST_NUM);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failCount = new AtomicInteger(0); // 실패 카운트 추가
for (int i = 0; i < REQUEST_NUM; i++) {
final int userIndex = i;
String currentProviderId = testUserList.get(userIndex).getProviderId();
executorService.submit(() -> {
try {
orderService.createOrder(
currentProviderId,
new OrderRequest(Collections.singletonList(new OrderItemRequest(1L, 2))) // Item ID 1L, quantity 2
);
successCount.incrementAndGet();
} catch (CustomException e) {
// 디버그 라인 추가
System.out.println("DEBUG: GlobalExceptionCode.ITEM_NOT_ENOUGH_STOCK.getErrorCode() = '" + GlobalExceptionCode.ITEM_NOT_ENOUGH_STOCK.getErrorCode() + "'");
failCount.incrementAndGet();
}
finally {
latch.countDown();
}
});
}
Item finalItem = itemRepository.findById(1L).orElseThrow();
System.out.println("--- 동시성 테스트 결과 (비관적 락 적용, 재고 100, 요청당 2개) ---");
System.out.println("초기 재고: 100");
System.out.println("총 주문 시도 요청 수: " + REQUEST_NUM);
System.out.println("각 요청당 주문 수량: 2");
System.out.println("성공한 주문 수: " + successCount.get());
System.out.println("재고 부족으로 실패한 주문 수: " + failCount.get());
System.out.println("최종 재고: " + finalItem.getStock());
assertThat(finalItem.getStock()).isEqualTo(0);
}
이렇게 동시성뿐만 아니라 예외 처리도 직접 확인해보고 싶고 재고가 없어 실패한 주문도 확인해보고자 AtomicInteger를 사용하여 멀티스레드 환경에서도 카운트를 할 수 있게 코드를 작성했다. 비관락을 걸어 동시성 문제는 제어했지만 failCount의 개수가 늘지 않는 문제를 직면했다. 원래라면 50개의 성공한 주문이 있고, 50개의 실패한 주문 수가 있어야 하지만 테스트 코드를 실행할 때 마다 다른 값이 나왔다.
해결!! for문 뒤에 latch.await();
작성!
전체코드
@DisplayName("비관락을 사용하여 createOrder 메서드 동시성 제어")
@Test
public void CreateOrderConcurrencyTestWithPessimistic() throws InterruptedException { // 메서드명 변경
// 이 테스트는 Item.id 1, 초기 재고가 100개
// 각 요청은 Item 1L에 대해 quantity 2를 주문
// 따라서 50개 요청이 성공하고, 51번째 요청부터는 재고 부족 예외가 발생
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(REQUEST_NUM);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failCount = new AtomicInteger(0); // 실패 카운트 추가
for (int i = 0; i < REQUEST_NUM; i++) {
final int userIndex = i;
String currentProviderId = testUserList.get(userIndex).getProviderId();
executorService.submit(() -> {
try {
orderService.createOrder(
currentProviderId,
new OrderRequest(Collections.singletonList(new OrderItemRequest(1L, 2))) // Item ID 1L, quantity 2
);
successCount.incrementAndGet();
} catch (Exception e) {
System.out.println("재고 없음 '" + GlobalExceptionCode.ITEM_NOT_ENOUGH_STOCK.getErrorCode() + "'");
failCount.incrementAndGet();
}
finally {
latch.countDown();
}
});
}
latch.await();
Item finalItem = itemRepository.findById(1L).orElseThrow();
System.out.println("--- 동시성 테스트 결과 (비관적 락 적용, 재고 100, 요청당 2개) ---");
System.out.println("초기 재고: 100");
System.out.println("총 주문 시도 요청 수: " + REQUEST_NUM);
System.out.println("각 요청당 주문 수량: 2");
System.out.println("성공한 주문 수: " + successCount.get());
System.out.println("재고 부족으로 실패한 주문 수: " + failCount.get());
System.out.println("최종 재고: " + finalItem.getStock());
assertThat(finalItem.getStock()).isEqualTo(0);
}
위의 코드를 살펴보면 ExecutorService와 Latch를 사용하여 멀티스레드를 구현했다. 이는 각각의 스레드가 비동기로 동작하며 for문이 끝나면 바로 *assertThat
* 으로 검증을 한다. 하지만 이 과정에서 아직 끝나지 않은 스레드들이 있기에 결과 값이 항상 다르게 나온 것이었다. latch.await()
로 모든 스레드를 동기화하고난 뒤 검증을 해서 테스트를 통과했다.
다양한 Lock이 있지만 인기 제품의 경우 갑자기 주문이 몰릴 수 있어 동시성 문제가 자주 발생할 수 있고, 데이터의 무결성이 매우 중요한 재고 관련 로직에는 비관락을 사용하여 동시성을 제어함. 낙관락이나 다른 Cache를 사용하여 제어할 수도 있지만, 낙관락의 경우 위와 같은 동시성 문제가 자주 발생할 수 있고, 무결성이 매우 중요하기 때문에 낙관락보다 속도가 다소 느려지더라도 비관락을 사용했고 지금의 리소스로는 별도의 cache서버를 둘 이유가 없기 때문에 사용하지 않았다.