카테고리 없음

[e-commerce] 동시성 제어 시나리오 분석

malang J 2024. 11. 1. 02:57

DB Lock

낙관적 락/비관적 락이 가장 기본으로 사용됩니다.

- 낙관적 락

DB에 Lock을 걸지 않고 읽기 시점/쓰기 시점의 데이터 변경 여부에 따라
동시성을 제어하는 방식이라 성능이 비관적 락보다 상대적으로 높습니다.

트랜잭션 간 충돌이 많아질 경우 retry 빈도 증가하게 되며 DB Connection, 스레드 점유 등의 단점이 존재합니다.

- 비관적 락

DB Lock(x-lock)을 사용해 하나의 트랜잭션만 작업이 가능하여 일관성을 확보하지만 해당 작업이 끝날때 까지 다른 트랜잭션은 대기 상태입니다.
트래픽이 몰리는 경우 락 대기시간으로 성능에 영향을 줄 수 있습니다.

 

Distributed Lock(Redis)

- Lettuce

  • Netty 기반의 Redis Client로 넌블로킹 I/O로 구현되어 비동기 방식으로 처리, 고성능
  • SETNX를 이용하여 Spin Lock 형태를 구현
    • 경쟁 스레드들이 지속적으로 요청을 보내기 때문에 서버 부하가 심하다
    • 개발자가 직접 retry, timeout 구현해야하며 지속적인 재시도로 네트워크 비용과 스레드 점유등의 문제가 발생

- Redisson

Redisson은 Redis 기반의 Java 클라이언트로 네트워크 트래픽 또는 CPU 사용량을 줄이기 위해 Lua 스크립트를 활용합니다. 재시도 로직을 내장하고 있어 락 흭득을 위한 별도의 재시도 로직을 작성하지 않아도 됩니다. 동시에 락 흭득 요청 시 FIFO 형태로 요청 순서를 보장합니다.

 

현재 시나리오에서 발생할 수 있는 동시성 문제는 재고 감소이며 코드는 아래와 같습니다. 

 

동시성 제어 방식 비교해보기


DB Lock

 

상품정보 불러오기

@Lock(LockModeType.PESSIMISTIC_WRITE)
ProductStockEntity findByProductIdWithPessimisticLock(@Param("productId") Long productId);

 

배타락을 사용하여 재고를 감소 시키는 로직

@Component
@RequiredArgsConstructor
public class OrderUseCase {
	
    public Long order(OrderRequest request) {

        request.setPaymentPrice((long) getProductsPrice(request));

        // 1. Order테이블, OrderItem 테이블 저장
        OrderEntity order = orderService.serviceOrder(request.getUser_id(), request);

        // 2. 재고 감소
        productService.decreaseStock(request.getProducts());

        // 3. 유저 포인트 차감
        userService.payment(request.getUser_id(), request.getPaymentPrice());

        return order.getId();
    }
}

@Component
@RequiredArgsConstructor
public class ProductUpdater {

    @Transactional
    public List<ProductStockEntity> decreaseStock(List<OrderProductsRequest> req) {
        List<ProductStockEntity> stockEntities = new ArrayList<>();

        for (OrderProductsRequest orderRequest : req) {
            ProductStockEntity productStock = productStockRepository.findByProductIdWithPessimisticLock(orderRequest.getProduct_id());
            productStock.decreaseStock(orderRequest.getProduct_id(), (long) orderRequest.getProduct_quantity());

            stockEntities.add(productStock);
            productStockRepository.stockSave(productStock);
        }

        return stockEntities;
    }
}

 

동시성 테스트 코드

@Test
void DB락_재고_100개_상품에_101번의_주문시도() throws InterruptedException {
    int threadCount = 101;
    Long userId = 1L;
    Long productId = 1L;

    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);

    List<OrderProductsRequest> orderProductsRequests = List.of(
            new OrderProductsRequest(productId, 1)
    );

    OrderRequest orderRequest = new OrderRequest(userId, orderProductsRequests, 0L);

    AtomicInteger successCount = new AtomicInteger();
    AtomicInteger failCount = new AtomicInteger();

    long startTime = System.currentTimeMillis();
    for (int i = 0; i < threadCount; i++) {
        executorService.submit(() -> {
            try {
                orderUseCase.order(orderRequest);
                successCount.getAndIncrement();
            } catch (OutOfStockException e) {
                failCount.getAndIncrement();
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();
    long endTime = System.currentTimeMillis();
    log.info("실행 시간 : {} milliseconds", endTime - startTime);

    Product product = productService.readProductDetail(productId);
    UserEntity userInfo = userService.getUserInfo(userId);

    assertEquals(100, successCount.get());
    assertEquals(1, failCount.get());
    assertEquals(0, product.getStock());
    assertEquals(800000, userInfo.getPoint());
}

 

성능

실행 시간 348 ms


복잡성

JPA 레포지토리에 @Lock 어노테이션으로 배타락을 설정할 수 있어 간편하다는 장점이 있습니다.  


효율성

배타락은 하나의 트랜잭션만 작업이 가능하여 데이터의 일관성은 확보됩니다. 그러나, 다른 트랜잭션의 대기시간 발생해 트래픽이 순식간에 몰리는 상황에서는 latency 증가를 초래할 수 있습니다.
현재 stock 테이블만 락을 걸기때문에 문제가 없지만, 여러 테이블에 락을 설정해야되는 트랜잭션이라면 데드락 발생이 예상됩니다.


 

Distributed Lock

 

분산 락 구현시 왜 Redis인가?


분산락에 관한 Reference들을 보면 Redis를 활용하는 것을 볼 수 있습니다.

  • 간단하게 Redis의 명령어를 사용하여 Lock 구현이 가능하다.
  • retry, timeout과 같은 부가 기능들을 제공한다.
  • Redis는 싱글 스레드병렬적으로 들어오는 요청들을 직렬화하여 원자성을 보장할 수 있다. 

 

Lettuce 분산 락 구현

@RequiredArgsConstructor
@Component
public class RedisLockRepository {
    private final RedisTemplate<String, String> redisTemplate;

    public Boolean lock(Object key) {
        return redisTemplate
                .opsForValue()
                .setIfAbsent(key.toString(), "lock", Duration.ofMillis(3000));
    }

    public Boolean unlock(Object key) {
        return redisTemplate.delete(key.toString());
    }
}

public class OrderUseCase {
    private final RedisLockRepository redisLockRepository;
    private final OrderService orderService;
    private final ProductService productService;
    
    public Long orderWithLettuce(OrderRequest request) {

        request.setPaymentPrice((long) getProductsPrice(request));

        // 1. Order테이블, OrderItem 테이블 저장
        OrderEntity order = orderService.serviceOrder(request.getUser_id(), request);

        // 2. 재고 감소
        for (OrderProductsRequest prod : request.getProducts()) {
            while(!redisLockRepository.lock(prod.getProduct_id())){
                try{
                    Thread.sleep(500);
                }catch (InterruptedException e){
                    throw new RuntimeException(e);
                }
            }

            try{
                productService.LettuceDecreaseStock(prod.getProduct_id(), (long) prod.getProduct_quantity());
            }finally {
                redisLockRepository.unlock(prod.getProduct_id());
            }
        }

        // 3. 유저 포인트 차감
        userService.payment(request.getUser_id(), request.getPaymentPrice());

        return order.getId();
    }
}

// 재고 감소 로직
@Component
@RequiredArgsConstructor
public class ProductUpdater {
    private final ProductStockRepository productStockRepository;
    
    @Transactional
    public List<ProductStockEntity> LettuceUpdateStock(Long productId, Long quantity) {
        List<ProductStockEntity> stockEntities = new ArrayList<>();

        ProductStockEntity productStock = productStockRepository.findById(productId);
        productStock.decreaseStock(productId, quantity);
        stockEntities.add(productStock);
        productStockRepository.stockSave(productStock);

        return stockEntities;
    }
}

@SpringBootTest
public class OrderConcurrentlyTest {
	
    @Test
    void Lettuce_재고_100개_상품에_101번의_주문시도() throws InterruptedException {

        int threadCount = 101;
        Long userId = 1L;
        Long productId = 1L;

        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        List<OrderProductsRequest> orderProductsRequests = List.of(
                new OrderProductsRequest(productId, 1)
        );

        OrderRequest orderRequest = new OrderRequest(1L, orderProductsRequests, 0L);

        AtomicInteger successCount = new AtomicInteger();
        AtomicInteger failCount = new AtomicInteger();

        long startTime = System.currentTimeMillis();
        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    orderUseCase.orderWithLettuce(orderRequest);
                    successCount.getAndIncrement();
                } catch (OutOfStockException e) {
                    failCount.getAndIncrement();
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        long endTime = System.currentTimeMillis();
        log.info("실행 시간 : {} milliseconds", endTime - startTime);

        Product product = productService.readProductDetail(productId);
        UserEntity userInfo = userService.getUserInfo(userId);

        assertEquals(100, successCount.get());
        assertEquals(1, failCount.get());

        assertEquals(0, product.getStock());
        assertEquals(800000, userInfo.getPoint());
    }
}

 

성능

실행 시간 17660 ms

 

복잡성

1. 의존성 추가로 간단하게 구현 가능합니다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

 

효율성

경쟁 스레드들이 락 흭득을 위해 반복적으로 retry를 진행하면서 스레드 점유와 같은 부하를 가져오게 됩니다.


Redisson 분산 락 구현

@Configuration
public class RedissonConfig {
    @Value("${spring.data.redis.host}")
    private String redisHost;

    @Value("${spring.data.redis.port}")
    private int redisPort;

    private static final String REDISSON_HOST_PREFIX = "redis://";

    @Bean
    public RedissonClient redissonClient() {
        RedissonClient redisson = null;
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
        redisson = Redisson.create(config);
        return redisson;
    }
}

public class OrderUseCase {
    private final RedisLockRepository redisLockRepository;
    private final OrderService orderService;
    private final ProductService productService;
    
    public Long orderWithRedisson(OrderRequest request) {
        request.setPaymentPrice((long) getProductsPrice(request));

        // 1. Order테이블, OrderItem 테이블 저장
        OrderEntity order = orderService.serviceOrder(request.getUser_id(), request);

        // 2. 재고 감소
        for (OrderProductsRequest prod : request.getProducts()) {
            RLock rLock = redissonClient.getLock(String.format("LOCK:PROD-%d", prod.getProduct_id()));

            try {
                boolean available = rLock.tryLock(10, 1, TimeUnit.SECONDS);
                if(!available) {
                    throw new IllegalArgumentException("Lock Not acquired");
                }
                productService.RedissonDecreaseStock(prod.getProduct_id(), (long) prod.getProduct_quantity());
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } finally {
                rLock.unlock();
            }
        }

        // 3. 유저 포인트 차감
        userService.payment(request.getUser_id(), request.getPaymentPrice());

        return order.getId();
    }
}

// 재고 감소 로직
@Component
@RequiredArgsConstructor
public class ProductUpdater {
    private final ProductStockRepository productStockRepository;
    
    @Transactional
    public List<ProductStockEntity> LettuceUpdateStock(Long productId, Long quantity) {
        List<ProductStockEntity> stockEntities = new ArrayList<>();

        ProductStockEntity productStock = productStockRepository.findById(productId);
        productStock.decreaseStock(productId, quantity);
        stockEntities.add(productStock);
        productStockRepository.stockSave(productStock);

        return stockEntities;
    }
}

@Test
void Redisson_재고_100개_상품에_101번의_주문시도() throws InterruptedException {

    int threadCount = 101;
    Long userId = 1L;
    Long productId = 1L;

    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);

    List<OrderProductsRequest> orderProductsRequests = List.of(
            new OrderProductsRequest(productId, 1)
    );

    OrderRequest orderRequest = new OrderRequest(1L, orderProductsRequests, 0L);

    AtomicInteger successCount = new AtomicInteger();
    AtomicInteger failCount = new AtomicInteger();

    long startTime = System.currentTimeMillis();
    for (int i = 0; i < threadCount; i++) {
        executorService.submit(() -> {
            try {

                orderUseCase.orderWithRedisson(orderRequest);
                successCount.getAndIncrement();

            } catch (OutOfStockException e) {

                failCount.getAndIncrement();

            } finally {

                latch.countDown();

            }
        });
    }

    latch.await();
    long endTime = System.currentTimeMillis();
    log.info("실행 시간 : {} milliseconds", endTime - startTime);

    Product product = productService.readProductDetail(productId);
    UserEntity userInfo = userService.getUserInfo(userId);

    assertEquals(0, product.getStock());
    assertEquals(100, successCount.get());
    assertEquals(1, failCount.get());

    assertEquals(800000, userInfo.getPoint());
}

 

  • redissonClient.getLock(name) : Lock 인스턴스 생성 로직, 파라미터 이름으로 Lock Key 설정
  • rLock.tryLock(waitTime, leaseTime, TimeUnit) : Lock 흭득 시도
    • waitTime : Lock 흭득 위한 대기 시간
    • leaseTime : Lock 흭득 후 작업 처리 최대시간 (해당 시간 지나면 Lock 자동 반납)
    • TimeUnit : 앞의 인자값들 시간 단위

성능

실행 시간 673 ms

 

복잡성

1. 의존성 추가

implementation 'org.redisson:redisson-spring-boot-starter:3.24.3'

 2. RedissonConfig 파일 추가

  • 호스트 및 포트 설정

 

효율성

개발자가 직접 retry와 timeout 을 구현할 필요없이 추상화 메서드를 사용할 수 있었습니다.
Pub-Sub 동작으로 인해 Lettuce에 비해 Redis에 부하가 덜 생기는 것을 확인했습니다.