동시성 환경 테스트하기

Hyunta·2023년 6월 18일
4

학습동기

멀티쓰레드 환경에서 작성한 코드가 문제없이 동작하는지 확인하기 위해 테스트를 작성해야했습니다. 머릿속으로는 어떤 환경을 만들어야겠다고 생각이 들었지만 구현해본 경험이 없어서 관련해서 조사를 해봤습니다.

상황

물건을 주문할 때 마다 재고를 줄입니다. 만약 재고가 10개인 경우 10개보다 많은 주문이 발생했을 때 예외를 발생시켜서 주문이 이뤄지지 않도록 구현을 했습니다.

관련해서 작성한 상품 주문 코드입니다.

  1. 동시에 여러개의 상품을 주문할 수 있기 때문에 List로 각 항목의 주문 결과를 수집합니다.
  2. JPA를 사용해서 비관적 락을 획득하고 재고 수를 줄입니다.
  3. 모든 상품에 대해서 진행한 후에 저장하고 결과를 반환합니다.

동시성 처리에 대한 내용보다는 테스트에 관련된 내용을 다루고 있으므로 로직에 대한 설명은 간략하게 소개하겠습니다.

동시성 테스트

구글링을 통해서 두가지 키워드를 얻었습니다. CountDownLatchExecutorService 를 통해서 동시성 테스트를 할 수 있다는 것을 알고 먼저 공식문서를 참고하면서 구현을 시작했습니다.

각 클래스에 대해서 구체적으로 알아보기 전에 제가 작성한 코드를 살펴보겠습니다.

    @Test
    @DisplayName("재고보다 많은 주문이 동시에 들어올 경우, 늦게 들어온 주문은 예외를 던진다.")
    void concurrencyOrder() throws InterruptedException {
        //given
        int numThreads = 10;
        Long initialStock = 10L;
        Long requestQuantity = 2L;
        Product product = PRODUCT_A.getWithStock(initialStock);
        productRepository.save(product);

        CountDownLatch doneSignal = new CountDownLatch(numThreads);
        ExecutorService executorService = Executors.newFixedThreadPool(numThreads);

        AtomicInteger successCount = new AtomicInteger();
        AtomicInteger failCount = new AtomicInteger();

        // when
        for (int i = 0; i < numThreads; i++) {
            executorService.execute(() -> {
                try {
                    orderService.order(
                            new OrderRequest(List.of(new OrderItemRequest(product.getId(), requestQuantity))));
                    successCount.getAndIncrement();
                } catch (SoldOutException e) {
                    failCount.getAndIncrement();
                } finally {
                    doneSignal.countDown();
                }
            });
        }
        doneSignal.await();
        executorService.shutdown();

        //then
        assertAll(
                () -> assertThat(successCount.get()).isEqualTo(5),
                () -> assertThat(failCount.get()).isEqualTo(5)
        );
    }

먼저 테스트의 구성과 예상 결과는 다음과 같습니다.

<주어진 상황>
재고가 10개인 상품A를 10명이 동시에 2개씩 구매합니다.

주어진 상황에 맞춰서 구성하기 위해서 쓰레드를 10개 준비합니다. 상품A를 생성하고 재고를 10개로 설정했습니다. 이때 ExecutorService 를 이용해서 쓰레드풀을 생성하여 동시에 요청을 보내는 흐름을 생성했습니다. 주문이 성공적으로 완료됐을 경우 AtomicIntegersuccessCount 를 1 증가시키고 실패했을 경우 failCount 를 1 증가시킵니다. 작업이 모두 끝났을 경우 생성해둔 CountDownLatch 객체 doneSignal 의 count를 하나 줄입니다.

<예상 결과>
구매 수량은 총 20개이지만, 재고는 10개이므로 5명은 구매에 성공을 하고 5명은 구매에 실패를 해야합니다.

생성해둔 doneSignal 의 갯수만큼 countDown이 진행되면 작업이 종료되고 검증이 이뤄집니다. successCountfailCount 의 갯수를 비교해서 각각 5건인지 확인하는 것을 통해 검증을 마칩니다.


각 클래스에 대한 감이 오시나요?

ExecutorService 는 Java에서 제공하는 비동기 실행 환경을 구성해주는 클래스입니다. 저는 10명의 사용자를 만들기 위해서 FixedThreadPool 을 생성하여 고정된 값의 쓰레드를 사용했습니다. 이 밖에도 여러 Executor를 사용할 수 있고 Future 클래스를 활용해서 비동기 처리도 지원합니다.

CountDownLatch 는 여러개의 쓰레드를 하나의 블럭으로 묶어주는 역할을 합니다. 내부에는 Counter가 존재하고 await() 를 호출하면 해당 Counter가 0이 될 때 까지 기다립니다.countDown() 메서드를 호출하면 1씩 줄어들기 때문에 10으로 설정해두면 쓰레드 10개가 모두 실행되어야 await 이후의 작업이 진행될 수 있습니다.

정리

Java에서 제공하는 동시성 관련 클래스 2개(ExecutorService, CountDownLatch)를 이용하면 멀티쓰레드 환경을 구축하고 조율해서 원하는 테스트를 진행할 수 있습니다.

이 밖에도 공식문서를 참고하면 각 클래스의 다양한 기능을 사용해볼 수 있습니다.

Reference

https://www.baeldung.com/java-executor-service-tutorial
https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/CountDownLatch.html
https://www.baeldung.com/java-countdown-latch
https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/CountDownLatch.html

profile
세상을 아름답게!

2개의 댓글

comment-user-thumbnail
2024년 4월 28일

잘봤습니다!

답글 달기
comment-user-thumbnail
2024년 8월 1일

좋은 정보 감사합니다.

답글 달기