Projects/HubEleven

[동시성 처리] 재고 감소 통합 테스트 코드 6 - Optimistic Lock(낙관적 락) 멀티 스레드 테스트 코드

annovation 2026. 2. 24. 21:17

Optimistic Lock(낙관적 락) 테스트 코드

  • Optimistic Lock(낙관적 락) 을 적용한 동시성 처리 테스트 코드를 분석해보자 !

💡테스트 코드

@Test
@DisplayName("재고 감소 - 100개 동시 요청 시 정확히 차감")
void decreaseStock_when100ConcurrentRequests_thenSuccess() throws Exception {

    // given
    int threadCount = 100;

    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);

    CountDownLatch countDownLatch = new CountDownLatch(threadCount);

    List<Future<?>> futures = new ArrayList<>();

	// when
    for (int i = 0; i < threadCount; i++) {
        futures.add(executorService.submit(() -> {
            try {
                stockRetryFacade.decreaseWithRetry(StockFixture.decreaseRequest(product, 1));
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new RuntimeException(e);
            } finally {
                countDownLatch.countDown();
            }
        }));
    }

    countDownLatch.await();

    for (Future<?> future : futures) {
        future.get(); // 스레드 내부 예외를 테스트 실패로 반영
    }

    executorService.shutdown();

    // then
    Stock updatedStock = jpaStockRepository
            .findByProductId(product.getProductId())
            .orElseThrow();

    assertEquals(0, updatedStock.getQuantity());
}

given

  • given : 테스트 환경 준비

1) 동시에 실행할 스레드 개수 설정

int threadCount = 100;

 

2) 스레드 풀 생성 (100개의 스레드를 동시에 실행할 수 있는 환경)

ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
  • newFixedThreadPool(100): 최대 100개의 작업을 동시에 실행

3) CountDownLatch : 모든 스레드가 작업을 완료할 때까지 대기하기 위한 동기화 도구

CountDownLatch countDownLatch = new CountDownLatch(threadCount);
  • 100으로 초기화 → 각 스레드가 countDown()을 호출할 때마다 1씩 감소
  • 0이 되면 await()에서 대기 중인 메인 스레드가 계속 진행

4) Future 리스트 : 각 스레드의 실행 결과를 추적하기 위한 컨테이너

List<Future<?>> futures = new ArrayList<>();
  • Future를 통해 스레드 내부에서 발생한 예외를 감지할 수 있음

when

  • when : 실제 테스트 실행
// 100번 반복하면서 100개의 재고 감소 작업을 동시에 제출
for (int i = 0; i < threadCount; i++) {

    // executorService.submit(): 작업을 스레드 풀에 제출
    // submit()은 Future 객체를 반환 → 나중에 결과 확인 가능
    futures.add(executorService.submit(() -> {
        try {
            // 실제 재고 감소 로직 실행 (재시도 로직 포함)
            // 각 스레드는 재고를 1개씩 감소시키려고 시도
            stockRetryFacade.decreaseWithRetry(
                StockFixture.decreaseRequest(product, 1)
            );

        } catch (InterruptedException e) {
            // InterruptedException 발생 시
            // 1. 현재 스레드의 인터럽트 상태를 복원
            Thread.currentThread().interrupt();

            // 2. RuntimeException으로 감싸서 다시 던짐
            //    (Runnable은 checked exception을 던질 수 없기 때문)
            throw new RuntimeException(e);

        } finally {
            // 작업 성공/실패 여부와 관계없이 항상 실행
            // countDownLatch를 1 감소시켜 "이 스레드는 작업 완료했다"고 알림
            countDownLatch.countDown();
        }
    }));
}

// 모든 스레드가 countDown()을 호출할 때까지 메인 스레드를 여기서 대기
// countDownLatch가 0이 될 때까지 블로킹
// → 100개 스레드가 모두 작업을 완료하면 다음으로 진행
countDownLatch.await();

// Future 리스트를 순회하면서 각 스레드의 실행 결과 확인
for (Future<?> future : futures) {
    // future.get(): 해당 스레드가 완료될 때까지 대기하고 결과를 가져옴
    // 스레드 내부에서 예외가 발생했다면 여기서 ExecutionException 발생
    // → 테스트 실패로 처리됨
    future.get();
}

// 스레드 풀 종료 (더 이상 새 작업을 받지 않음)
// 리소스 정리를 위해 항상 호출해야 함
executorService.shutdown();

 

1) Future 가 필요한 이유는 뭘까용?

  • Future 가 없으면 스레드 내부 예외가 숨겨진다.
  • 예를 들어 :
@Test
void test() {
    ExecutorService executor = Executors.newFixedThreadPool(2);
    CountDownLatch latch = new CountDownLatch(2);

    // 스레드 1
    executor.submit(() -> {
        try {
            throw new RuntimeException("에러!");  // ← 예외 발생
        } finally {
            latch.countDown();
        }
    });

    // 스레드 2
    executor.submit(() -> {
        try {
            System.out.println("정상 실행");
        } finally {
            latch.countDown();
        }
    });

    latch.await();
    executor.shutdown();
    
    // 테스트는 통과! (예외를 몰라서)
}
스레드 1: 성공 (재고 99)
스레드 2: 성공 (재고 98)
...
스레드 50: NullPointerException 발생! ← 하지만 테스트는 계속 진행
...
스레드 100: 성공

결과: 재고가 50 (50번만 성공)

하지만 assertEquals(0, stock.getQuantity()) 에서 실패
→ "왜 50이지?" 원인을 알 수 없음!
  • Future 를 사용하면 스레드 내부 예외가 명확하게 드러난다.
  • 예외를 객체에 저장해 get()을 통해 호출 가능하기 때문에 스레드 안에서 발생한 예외를 호출한 쪽에서 확실하게 확인할 수 있다.
  • 예를 들어 :
@Test
void test() throws Exception {
    ExecutorService executor = Executors.newFixedThreadPool(2);
    CountDownLatch latch = new CountDownLatch(2);
    List<Future<?>> futures = new ArrayList<>();

    // 스레드 1
    futures.add(executor.submit(() -> {
        try {
            throw new RuntimeException("에러!");
        } finally {
            latch.countDown();
        }
    }));

    // 스레드 2
    futures.add(executor.submit(() -> {
        try {
            System.out.println("정상 실행");
        } finally {
            latch.countDown();
        }
    }));

    latch.await();

    // Future로 예외 확인
    for (Future<?> future : futures) {
        future.get();  // ← ExecutionException 발생! 테스트 실패
    }

    executor.shutdown();
}

 

2) 왜 자료 구조를 ArrayList 로 썼을까용?

  • 순차 접근이 주 목적
// 순차적으로 모든 Future를 확인
for (Future<?> future : futures) {
    future.get();  // 하나씩 차례대로 확인
}

 

➡️ 인덱스 기반 빠른 접근 : O(1)

  • 크기를 미리 알수 있고, 데이터 추가에 용이하다.
int threadCount = 100;  // 크기 고정

// 정확히 100개의 Future가 들어갈 것을 알고 있음
List<Future<?>> futures = new ArrayList<>(threadCount);  // 초기 용량 지정 가능

for (int i = 0; i < threadCount; i++) {
    futures.add(executor.submit(...));  // 끝에 추가만 함
}

 

➡️ 데이터 추가(append) : O(1) (평균)

 

2-1) LinkedList 랑은 무슨 차인데용?

// LinkedList
List<Future<?>> futures = new LinkedList<>();
for (int i = 0; i < 100; i++) {
    futures.add(executor.submit(...));  // O(1)
}
for (Future<?> future : futures) {
    future.get();  // 순회는 괜찮지만 메모리 오버헤드
}
  • 메모리 오버헤드(실제 데이터 저장 외에 추가로 소비되는 메모리)가 크다. (각 노드마다 prev, next 포인터)
  • 인덱스 접근 느림 : O(n)
  • 캐시 지역성
// ArrayList: 연속된 메모리 → CPU 캐시 히트율 높음
for (Future<?> future : futures) {
    future.get();  // ← 빠름
}

// LinkedList: 흩어진 메모리 → CPU 캐시 미스 많음
for (Future<?> future : futures) {
    future.get();  // ← 상대적으로 느림
}

 

3) 실행 흐름

## 실행 흐름

1. 스레드 풀 생성 (100개)
   
2. 100개 작업 제출 (각각 재고 1씩 감소)
   
3. 모든 작업 동시 실행 (경쟁 상태 발생 가능)
   
4. countDownLatch.await() - 모든 작업 완료 대기
   
5. future.get() - 각 스레드 예외 확인
   
6. DB 조회 - 최종 재고 확인
   
7. assertEquals(0, ...) - 재고가 정확히 0인지 검증

then

  • then : 결과 검증

1) DB에서 최신 재고 데이터 조회

Stock updatedStock = jpaStockRepository
            .findByProductId(product.getProductId())  // productId로 재고 조회
            .orElseThrow();  // 없으면 예외 발생
  • @BeforeEach에서 생성한 stock 객체가 아닌, DB에서 다시 가져와야 함!

2) 검증

assertEquals(0, updatedStock.getQuantity());
  • 100개 스레드가 각각 1씩 감소시켰으므로 초기 재고 100 - (100번 × 1씩 감소) = 0이어야 함
  • 만약 동시성 제어가 제대로 안 되었다면 0보다 큰 값이 나올 수 있음

핵심 개념 정리

1. ExecutorService (출처)

ExecutorService executorService = Executors.newFixedThreadPool(100);
  • 여러 작업을 병렬로 실행하기 위한 스레드 풀
  • newFixedThreadPool(100): 최대 100개의 작업을 동시에 실행

2. CountDownLatch

CountDownLatch latch = new CountDownLatch(100);
latch.countDown();  // -1 감소
latch.await();      // 0이 될 때까지 대기
  • 여러 스레드의 작업 완료를 동기화
  • 카운터가 0이 되면 대기 중인 스레드가 진행

3. Future (출처)

Future<?> future = executorService.submit(...);
future.get();  // 결과 대기 + 예외 확인
  • Java 표준 라이브러리
  • 비동기 작업의 결과를 나타내는 객체
  • get() : 작업 완료 대기 + 예외가 있으면 던짐
  • Java 8 이후부터는 CompletableFuture 사용을 권장한다고 한다.

참고 자료

1) Oracle Docs : Future

https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/Future.html

 

Future (Java SE 21 & JDK 21)

Type Parameters: V - The result type returned by this Future's get method All Known Subinterfaces: RunnableFuture , RunnableScheduledFuture , ScheduledFuture All Known Implementing Classes: CompletableFuture, CountedCompleter, ForkJoinTask, FutureTask, Rec

docs.oracle.com

2) Oracle Docs : ExecutorService

https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ExecutorService.html

 

ExecutorService (Java Platform SE 8 )

An Executor that provides methods to manage termination and methods that can produce a Future for tracking progress of one or more asynchronous tasks. An ExecutorService can be shut down, which will cause it to reject new tasks. Two different methods are p

docs.oracle.com

3) Oracle Docs : CountDownLatch

https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/CountDownLatch.html

 

CountDownLatch (Java Platform SE 8 )

A synchronization aid that allows one or more threads to wait until a set of operations being performed in other threads completes. A CountDownLatch is initialized with a given count. The await methods block until the current count reaches zero due to invo

docs.oracle.com