현재 진행중인 프로젝트에서 선착순 예약 구매의 기능을 구상하고 있습니다. 따라서 동시에 여러 사용자가 한 번에 구매했을 때 어떻게 될지에 대해 고민해보게 되었고, 동시성 제어와 락에 대해 공부를 해봤습니다.
동시에 한 상품에 재고를 감소시키려 하면 위처럼 기대와 다르게 값이 나오게 됩니다.
둘 이상의 쓰레드가 공유 데이터에 대해 동시에 변경하려해 문제가 발생하게 됩니다.
공유하는 데이터에 대해 락을 통해 동시성을 제어할 수 있습니다.
충돌을 예상해 데이터를 읽는 순간 락을 걸어 업데이트가 완료될 때 까지 락을 유지하는 방법으로, 실제 데이터에 Lock을 걸어 데이터 정합성을 맞춥니다.
이때 exclusive lock을 걸어 다른 트랜잭션에서는 lock이 해제 되기 전까지 shared lock도 생성할 수 없어 데이터를 가져가지 못하게 됩니다.
위와 같이 select 시 for update를 통해 락을 걸고 데이터를 조회합니다.
장점
충돌이 빈번하게 일어난다면 낙관적 락보다 성능이 좋을 수 있습니다.
락을 통해 업데이트를 제어하므로 데이터 정합성이 보장됩니다.
단점
성능이 좋지 않습니다.
충돌이 없을거라고 예상해 실제로 Lock을 이용하지 않고 version 등 컬럼을 통해 데이터의 정합성을 맞추는 방법입니다.
데이터 변경 후 커밋 시점에 버전 정보를 확인해 충돌 여부를 파악합니다.
장점
별도의 락을 잡지 않으므로 성능상 이점이 있습니다.
단점
실패 시 재시도 로직을 작성해야 합니다.
충돌이 빈번하게 일어날 것으로 예상되면 더 오래걸리게 됩니다.
프로젝트에서는 여러 사용자가 동시에 접속할 가능성이 높아 데이터의 무결성을 보장해야 합니다.
따라서 위의 Lock의 특징을 비교해 선착순 예약 구매 시에 더 적합하다고 생각하는 비관적 락을 적용했습니다.
주문 생성 시 product의 재고를 감소시키는 상황에 락을 걸어야 하기 때문에 Product의 값을 조회 시 락을 걸었습니다.
@DisplayName("주문 시 동시성 테스트")
@Test
public void order() throws InterruptedException {
// given
int numThreads = 10;
CountDownLatch doneSignal = new CountDownLatch(numThreads);
ExecutorService executorService = Executors.newFixedThreadPool(numThreads);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
Users user = Users.builder()
.email("ghdb132@naver.com")
.password("132465aa")
.name("hobin")
.nickname("ghgh")
.phone("01012345678")
.address("인천시")
.role(UserRole.USER)
.build();
Users savedUser = userRepository.save(user);
Product product = new Product(1L, "신발", "신발입니다.", 10000L, 100L, "hobin");
Product savedProduct = productRepository.save(product);
List<AddOrderProductRequest> lists = new ArrayList<>();
AddOrderProductRequest addOrderProductRequest = new AddOrderProductRequest(savedProduct.getId(), 2L);
lists.add(addOrderProductRequest);
AddOrderRequest addOrderRequest = AddOrderRequest.builder()
.userId(savedUser.getId())
.deliveryPhone(savedUser.getPhone())
.deliveryAddress(savedUser.getAddress())
.orderProducts(lists)
.build();
// when
for (int i = 0; i < numThreads; i++) {
executorService.execute(() -> {
try {
orderService.addOrder(addOrderRequest);
successCount.getAndIncrement();
System.out.println("성공");
} catch (BusinessException e) {
failCount.getAndIncrement();
System.out.println("실패");
} finally {
doneSignal.countDown();
}
});
}
doneSignal.await();
executorService.shutdown();
// then
Product pro = productRepository.findById(1L)
.get();
Assertions.assertThat(pro.getStock()).isEqualTo(80);
// Assertions.assertThat(successCount.get()).isEqualTo(5);
// Assertions.assertThat(failCount.get()).isEqualTo(5);
}
위의 테스트와 같이 재고가 잘 감소된 모습을 확인할 수 있습니다.