▶️ 개요

마켓 API를 구현하며 마주했던 동시성 문제에 관해 이번 포스트에서 다뤄보고자 한다.

먼저, 서비스의 큰 흐름은
구매자의 구매요청(제품 수량만큼 제한) → 판매자의 판매승인 → 구매자의 구매확정

현재 로직에서는 세 가지 경우에서 동시성 문제가 발생할 것 이라고 판단했다.
판매 승인 단계에서는 발생하지 않을 것으로 판단했는데, 판매자 한 명이 해당 주문 객체의 ID를 받아서 하나의 주문에 대해서만 판매 승인을 할 수 있기 때문에 동시성 문제가 발생하지 않을 것이라고 생각했다.

동시성 이슈문제 상황
A. 가격 변경 시 동시성 문제구매 요청과 동시에 판매자가 가격을 변경했을 때, 주문 객체에 가격은 어떻게 생성이 될까?
B. 재고 관리 동시성 문제동시에 여러 사용자가 구매 요청을 보낼 경우, 재고 수량을 초과할 때 적절한 예외를 반환하여 재고 수량 이하로 주문 객체가 생성될까?
C. 재고 감소 동시성 문제두 사용자가 거의 동시에 동일한 제품을 구매 확정할 경우, 재고 수량이 올바르게 감소될까?

구매 요청 단계

A. 가격 변경 시 동시성 문제
: 구매 요청과 동시에 판매자가 가격을 변경했을 때, 주문 객체에 가격은 어떻게 생성이 될까?

이 경우, 주문 객체에 저장되는 가격이 구매 요청 시점의 가격인지, 판매자가 변경한 새로운 가격인지 명확히 처리해야 하는데, 구매자 입장에서 구매 요청 시 보았던 가격으로 판매자가 변경하더라도 유지되도록 결정하였다.

B. 재고 관리 동시성 문제
: 동시에 여러 사용자가 구매 요청을 보낼 경우, 재고 수량을 초과할 때 적절한 예외를 반환하여 재고 수량 이하로 주문 객체가 생성될까?

구매 확정 단계

C. 재고 감소 동시성 문제
: 두 사용자가 거의 동시에 동일한 제품을 구매 확정할 경우, 재고 수량이 올바르게 감소될까?

제품 재고 감소 (product.decreaseQuantity(1)):이 부분에서 동시성 문제가 발생할 가능성은 제품의 재고 수량이 동시에 여러 트랜잭션에 의해 감소될 때, 재고 수량이 올바르게 감소되지 않을 수 있다고 생각했다.


▶️ A. 구매요청단계 가격 변경 시 동시성 문제

: 구매 요청과 동시에 판매자가 가격을 변경했을 때, 주문 객체에 가격은 어떻게 생성이 될까?

현재 스레드에서 상태 불일치 문제가 발생할 것이라고 판단했다.

Thread B에서 상품 가격을 수정한 후, Thread A에서 상품 상태를 업데이트할 때 수정된 가격을 반영하지 못할 수 있다.

따라서 REPEATABLE_READ 레벨의 격리 수준을 구매요청 메소드에 적용하여 데이터의 일관성을 보장해주었다.

REPEATABLE_READ : 트랜잭션이 시작된 시점 이후에 다른 트랜잭션이 수행한 변경 사항을 볼 수 없다.

따라서 두 개의 스레드가 동시에 이루어져도 createOrder 가 시작된 시점의 가격을 보장하도록 해주었다.

▶️ 테스트 코드

  1. 제품의 판매 가격 5,000
  2. 구매자가 구매 요청을 함과 동시에 판매자가 10,000으로 제품 가격을 변경
  3. 주문 객체의 가격인 order.getOrderPrice()가 5,000으로 생성되면 테스트 성공!

두 가지 작업(구매 요청과 가격 수정)을 비동기로 실행하고, CompletableFuture.allOf().join()을 사용해 두 작업이 모두 완료될 때까지 기다리기 때문에 동시성 테스트가 직관적일 것이라고 판단하여 CompletableFuture를 사용해 두 가지 스레드가 동시에 동작하도록 테스트 코드를 작성해보았다.

    @Test
    @Order(1)
    @DisplayName("구매 요청과 동시에 판매자가 가격을 변경했을 때, 구매 요청 시 보았던 가격으로 주문이 이루어진다.")
    void 가격변경시동시성문제테스트() throws Exception {
        // Given
        OrderRequestDto orderRequestDto = OrderRequestDto.builder()
                .productId(product.getId())
                .build();
        ModifyRequestDto modifyRequestDto = ModifyRequestDto.builder()
                .price(10000)
                .build();

        // 스레드 1: 구매 요청
        CompletableFuture<Void> orderTask = CompletableFuture.runAsync(() -> {
            try {
                orderService.createOrder(buyer.getEmail(), orderRequestDto);
            } catch (Exception e) {
                e.printStackTrace();
            }
        });

        // 스레드 2: 가격 수정
        CompletableFuture<Void> modifyTask = CompletableFuture.runAsync(() -> {
            try {
                productService.modifyProduct(seller.getEmail(), product.getId(), modifyRequestDto);
            } catch (Exception e) {
                e.printStackTrace();
            }
        });

        // 두 스레드가 완료될 때까지 대기
        CompletableFuture.allOf(orderTask, modifyTask).join();

        // Then: 구매 요청의 가격이 수정 전 가격인지 확인
        List<Orders> orders = orderRepository.findAll();
        assertEquals(1, orders.size());
        Orders order = orders.get(0);

        assertEquals(5000, order.getOrderPrice(), "구매 요청 시 보았던 가격으로 주문이 이루어져야 한다.");
    }

이렇게 동시성 문제가 해결되었지만 단일 구매 요청을 테스트한 것이기에 여러 구매 요청이 동시에 와도 가격 변동이 없을까 라는 생각이 들어 추가로 테스트 코드를 작성해보았다.

    @Test
    @Order(2)
    @DisplayName("구매 요청과 동시에 판매자가 가격을 변경했을 때, 구매 요청 시 보았던 가격으로 주문이 이루어진다.")
    void 여러개의구매요청에서가격변경시동시성문제테스트() throws Exception {
        // Given
        OrderRequestDto orderRequestDto = OrderRequestDto.builder()
                .productId(product.getId())
                .build();
        ModifyRequestDto modifyRequestDto = ModifyRequestDto.builder()
                .price(10000)
                .build();

        int numberOfBuyers = 1000; // 동시 구매 요청 수
        ExecutorService executorService = Executors.newFixedThreadPool(numberOfBuyers + 1);
        CountDownLatch latch = new CountDownLatch(1);
        CountDownLatch doneLatch = new CountDownLatch(numberOfBuyers + 1); // 모든 작업 완료 대기용

        // 구매 요청 스레드 생성
        for (int i = 0; i < numberOfBuyers; i++) {
            executorService.submit(() -> {
                try {
                    latch.await(); // 모든 스레드가 준비될 때까지 대기
                    orderService.createOrder(buyer.getEmail(), orderRequestDto);
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    doneLatch.countDown(); // 작업 완료 후 카운트 다운
                }
            });
        }

        // 가격 수정 스레드 생성
        executorService.submit(() -> {
            try {
                latch.await(); // 모든 스레드가 준비될 때까지 대기
                productService.modifyProduct(seller.getEmail(), product.getId(), modifyRequestDto);
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                doneLatch.countDown(); // 작업 완료 후 카운트 다운
            }
        });

        // 모든 스레드 시작
        latch.countDown();
        // 모든 작업 완료 대기
        doneLatch.await();

        // Then: 구매 요청의 가격이 수정 전 가격인지 확인
        List<Orders> orders = orderRepository.findAll();
        for (Orders order : orders) {
            assertEquals(3000, order.getOrderPrice(), "구매 요청 시 보았던 가격으로 주문이 이루어져야 한다.");
        }

    }

테스트는 통과했고, 콘솔에 오류가 찍히는 이유는 현재 구매 가능한 제품 수량을 3개인데, 1000개의 구매요청이 동시에 이루어졌을 때, ProductUnavailableException 예외가 발생한 상황이다.

따라서 동시성 문제에서 가격 변동 뿐 아니라 구매요청 제한까지 검증해볼 수 있었다.


첫 번째 테스트 환경에서는 두 개의 메소드가 동시에 일어날 상황에서 많은 구매 요청이 있었을 때 초과한 구매 요청에 대한 예외가 반영되고 있는 것을 확인했지만, 구매 요청하는 메소드 내부에서 동시성 처리를 명확하게 해주고 있는지 추가로 테스트 해봐야겠다고 생각했다.

▶️ B. 구매요청단계 재고 관리 동시성 문제

: 동시에 여러 사용자가 구매 요청을 보낼 경우, 재고 수량을 초과할 때 적절한 예외를 반환하여 재고 수량 이하로 주문 객체가 생성될까?

  1. 주문 생성은 제품 수량 이하로 생성 가능
  2. 초과된 구매 요청은 주문 객체를 생성하지 않고, ProductUnavailableException 예외를 발생시킴

▶️ 테스트 코드

이번엔 예외 처리를 다루어야 했기에 ExecutorService와 CountDownLatch를 사용하여 테스트 환경을 만들었다.

    @Test
    @Order(3)
    @DisplayName("주문 생성은 제품의 수량에 맞게 제한되고, 초과된 구매 요청은 ProductUnavailableException을 발생시켜서 주문객체를 생성 못하게")
    void 재고관리동시성문제테스트() throws InterruptedException {
        // Given
        OrderRequestDto orderRequestDto = OrderRequestDto.builder()
                .productId(product.getId())
                .build();

        int threadCount = 10;  // 시도할 스레드 수
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        // When
        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    orderService.createOrder(buyer.getEmail(), orderRequestDto);
                } catch (ProductUnavailableException e) {
                    // 예상된 예외이므로 무시
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        executorService.shutdown();

        // Then : pendingOrderCount는 실제로 성공적으로 생성된 주문의 수(구매 요청을 받았지만 아직 거래가 확정되지 않은 주문의 수)
        long pendingOrderCount = orderRepository.countByProductAndOrderStatusNot(product, OrderStatus.거래확정);
        assertThat(pendingOrderCount).isLessThanOrEqualTo(product.getQuantity());

        System.out.println("++threadCount = " + threadCount);
        System.out.println("++orderRequestDto.geyProductId = " + orderRequestDto.getProductId());
        System.out.println("++productId = " + product.getId());
        System.out.println("++productQuantity = " + product.getQuantity());
        System.out.println("++pendingOrderCount = " + pendingOrderCount);

    }

문제 상황 : 제품 수량이 3이고, 동시에 구매 요청을 10이 한 상황이면 pendingOrderCount가 최대 3개까지만 생성이 가능한데, 실제로 10개가 생성되어 동시성 문제가 발생한 것을 확인했다.
해결 방안 : 비관적 락 적용
현재 시나리오에서 수량이 정확하게 관리되도록 제한해주어야 하기 떄문에, 데이터 일관성 측면에서 비관적 락 적용이 더 적합하다고 판단했다.

public interface ProductRepository extends JpaRepository<Product, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Product p WHERE p.id = :productId")
    Optional<Product> findByIdWithLock(@Param("productId") Long productId);
}

private Product getByProduct(Long productId) {
    return productRepository.findByIdWithLock(productId)
           .orElseThrow(() -> new ProductNotFoundException("해당 제품을 찾을 수 없습니다. 제품 ID: " + productId));
}

비관적 락 적용 후 테스트 했는데 계속 실패했다. 찾아보니 테스트 환경이었던 H2 인메모리 환경에서는 트랜잭션 격리 수준인 (isolation = Isolation.*REPEATABLE_READ*) 를 함께 사용하지 못한다고 한다…

판매 수정과 구매 요청의 두 개 메소드의 동시성 관리를 위해 트랜잭션 격리 레벨을 조정해주었던 것이 문제가 된 것이다. 따라서 격리 레벨을 지우고 테스트 코드를 돌려보면 테스트가 통과한다.

아님 그냥 테스트 코드에서 트랜잭션을 재정의해주어도 해결이 된다. 이 부분은 참고용으로 알아두어도 좋을 것 같다.

비관적 락 적용 후 데드락 발생!
createOrder메소드에 REPEATABLE_READ 격리 수준을 주어 트랜잭션이 시작된 후 다른 트랜잭션이 데이터를 수정할 수 없도록 보장했는데, 이로 인해 트랜잭션이 종료될 때까지 데이터가 락이 걸려있는 상태가 길어질 수 있어, 데드락이 발생하였다.

→ 즉, 동시성 이슈가 있는 상황에서 동시에 Product 엔티티를 비관적 락으로 잠그려고 시도하면서 두 메소드가 동시에 실행될 때 createOrder 메소드의 격리 수준으로 락이 걸려있는 상태가 길어져 교착 상태가 발생한 것이다.

재시도 로직을 추가하거나 다양한 방법이 있겠지만 이런 환경에서는 낙관적 락을 적용해주는 것이 좋다고 판단하여 낙관적 락으로 문제를 해결하였다.

두 번째 시도 : 낙관적 락 적용

--- Product
    @Version
    private Long version;

테스트 성공!!!!


▶️ 참고 | 재시도 로직

데드락 문제가 발생한 것에 대해서 재시도 로직을 작성해서 문제는 해결되었지만 비관적 락에 재시도 로직까지는 약간 리소스 낭비인 것 같아 낙관적 락으로 바꾸었다. 해결했었던 재시도 로직이다.

org.springframework.dao.CannotAcquireLockException: JDBC exception executing SQL 
[/* SELECT p FROM Product p WHERE p.id = :productId */ select p1_0.id,p1_0.create_date,p1_0.member_id,p1_0.modify_date,p1_0.name,p1_0.price,p1_0.product_status,p1_0.quantity from product p1_0 where p1_0.id=? for update] 
[Deadlock detected. The current transaction was rolled back. Details: "PRODUCT"; SQL statement:
/* SELECT p FROM Product p WHERE p.id = :productId */ select p1_0.id,p1_0.create_date,p1_0.member_id,p1_0.modify_date,p1_0.name,p1_0.price,p1_0.product_status,p1_0 [40001-224]] [n/a]; SQL [n/a]

- build.gradle
implementation 'org.springframework.retry:spring-retry'
 
- RetryConfig
@Configuration
@EnableRetry
public class RetryConfig {

    @Bean
    public RetryOperationsInterceptor defaultRetryInterceptor() {
        return RetryInterceptorBuilder.stateless()
                .maxAttempts(3)
                .backOffOptions(1000, 2.0, 10000) // initialInterval, multiplier, maxInterval
                .build();
    }
}

- 메소드 어노테이션으로 추가
@Retryable(value = CannotAcquireLockException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000))

동시성 문제를 다루며 트랜잭션 격리 레벨과 비관적/낙관적 락에 대해서 학습해 볼 수 있었다.

다중 스레드 환경을 구성하는 건 여전히 어려웠지만, 다양한 관점에서 동시성 문제를 다룰 수 있어 좋은 경험이었다.

이 후 독립적인 테스트 환경을 조성, 지금 동시성 테스트를 했던 코드에서 결함은 없었는지 깊게 파악하고자 '토비의 스프링부트' 서적을 읽고 있다.

이번 포스트에서는 동시성 테스트를 하면서 발생했던 이슈들에 대해 정리해보았는데, 다음 포스트에서는 관련 이론을 작성할 계획이다.

0개의 댓글