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개씩 동시에 주문을 하도록 코드를 짰다.

스크린샷 2025-06-17 오후 5.51.57.png

실행 결과는 당연히 예상과 다르게 재고가 남게 되었고 데드락까지 걸리게 되었다. 이렇게 동시에 여러 주문이 들어오게 된다면 예상치 못한 동시성 문제가 발생할 수 있다. 이 문제를 비관락을 사용하여 문제를 해결해 보겠다.

@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() 로 모든 스레드를 동기화하고난 뒤 검증을 해서 테스트를 통과했다.

스크린샷 2025-06-18 오후 1.58.55.png

왜 비관락인가?

다양한 Lock이 있지만 인기 제품의 경우 갑자기 주문이 몰릴 수 있어 동시성 문제가 자주 발생할 수 있고, 데이터의 무결성이 매우 중요한 재고 관련 로직에는 비관락을 사용하여 동시성을 제어함. 낙관락이나 다른 Cache를 사용하여 제어할 수도 있지만, 낙관락의 경우 위와 같은 동시성 문제가 자주 발생할 수 있고, 무결성이 매우 중요하기 때문에 낙관락보다 속도가 다소 느려지더라도 비관락을 사용했고 지금의 리소스로는 별도의 cache서버를 둘 이유가 없기 때문에 사용하지 않았다.