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
'Projects > HubEleven' 카테고리의 다른 글
| [동시성 처리] Optimistic Lock (낙관적 락) 재시도 로직 (0) | 2026.02.23 |
|---|---|
| [동시성 처리] DB Lock : Optimistic Lock (낙관적 락) - 실습 (0) | 2026.02.20 |
| [동시성 처리] Trouble Shooting : Pessimistic Lock 적용 오류 (0) | 2026.02.19 |
| [동시성 처리] DB Lock : Pessimistic Lock (비관적 락) - 실습 (0) | 2026.02.18 |
| [동시성 처리] Trouble Shooting : 멀티스레드 재고 감소 통합 테스트 코드 Eureka Server Connection refused (0) | 2026.02.16 |