동시성 이슈 해결 및 프로젝트에 적용

유호빈·2024년 4월 29일
1

현재 진행중인 프로젝트에서 선착순 예약 구매의 기능을 구상하고 있습니다. 따라서 동시에 여러 사용자가 한 번에 구매했을 때 어떻게 될지에 대해 고민해보게 되었고, 동시성 제어와 락에 대해 공부를 해봤습니다.

1. 문제 발생

동시에 한 상품에 재고를 감소시키려 하면 위처럼 기대와 다르게 값이 나오게 됩니다.
둘 이상의 쓰레드가 공유 데이터에 대해 동시에 변경하려해 문제가 발생하게 됩니다.

2. DB Lock 사용

공유하는 데이터에 대해 락을 통해 동시성을 제어할 수 있습니다.

1. Pessimistic Lock - 비관적 락

충돌을 예상해 데이터를 읽는 순간 락을 걸어 업데이트가 완료될 때 까지 락을 유지하는 방법으로, 실제 데이터에 Lock을 걸어 데이터 정합성을 맞춥니다.

이때 exclusive lock을 걸어 다른 트랜잭션에서는 lock이 해제 되기 전까지 shared lock도 생성할 수 없어 데이터를 가져가지 못하게 됩니다.

위와 같이 select 시 for update를 통해 락을 걸고 데이터를 조회합니다.

  • 장점
    충돌이 빈번하게 일어난다면 낙관적 락보다 성능이 좋을 수 있습니다.
    락을 통해 업데이트를 제어하므로 데이터 정합성이 보장됩니다.

  • 단점
    성능이 좋지 않습니다.

2. Optimistic Lock - 낙관적 락

충돌이 없을거라고 예상해 실제로 Lock을 이용하지 않고 version 등 컬럼을 통해 데이터의 정합성을 맞추는 방법입니다.

데이터 변경 후 커밋 시점에 버전 정보를 확인해 충돌 여부를 파악합니다.

  • 장점
    별도의 락을 잡지 않으므로 성능상 이점이 있습니다.

  • 단점
    실패 시 재시도 로직을 작성해야 합니다.
    충돌이 빈번하게 일어날 것으로 예상되면 더 오래걸리게 됩니다.

3. 프로젝트에 Lock 적용

프로젝트에서는 여러 사용자가 동시에 접속할 가능성이 높아 데이터의 무결성을 보장해야 합니다.

따라서 위의 Lock의 특징을 비교해 선착순 예약 구매 시에 더 적합하다고 생각하는 비관적 락을 적용했습니다.

주문 생성 시 Service

Product Repository에 비관적 락

주문 생성 시 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);
    }
    

위의 테스트와 같이 재고가 잘 감소된 모습을 확인할 수 있습니다.

profile
시작하자

0개의 댓글