Skip to main content

Command Palette

Search for a command to run...

6.동시성 제어와 스케줄링을 통한 효과적인 주문 및 재고 관리 전략

Updated
5 min readView as Markdown
T

안녕하세요! Web개발을 공부하고 있는 윤종일 입니다. 현재는 Java 백엔드 개발을 깊게 파고들고 있어요! 제 궁극적인 목표는 풀스택 개발자가 되는 것입니다. 프론트엔드와 백엔드 모두를 자유롭게 넘나들며, 사용자에게 가치를 전달할 수 있는 완성도 높은 애플리케이션을 만드는 것이 목표입니다.

GitHub 링크

동시성 제어를 적용한 부분에서 문제를 발견했다.

  • 원래의 주문 처리 흐름에서의 동시성 제어

    스크린샷 2024-04-17 오후 2 55 23

문제점

  1. 락을 걸어놨지만 결제가 완료된 시점에서 수량이 업데이트 되기 때문에 수량이 1개라고 해도 주문서가 100개가 생길 수가 있다.

  2. a와 b의 주문서가 생기고 a와 b 둘 다 결제창에 진입한다. 둘 다 결제가 진행 된 후에 수량이 1개만 줄어들 위험이 있다.

  3. 지금은 결제 모듈로 카카오 API를 사용하고 있는데 다른 결제 모듈들을 가지고 왔을때 그 모듈들에도 락을 걸어줘야 한다.


개선된 동시성 제어

  • 분산 락을 적용할 정확한 시점을 고민해보고 주문하기에 락을 걸어서 동시성을 제어 했다.

    스크린샷 2024-04-17 오후 3 16 50

개선 효과

  1. 주문하기 자체에 락을 걸어서 재고가 1일 때 A,B가 동시에 주문해도 한 명만 결제가 될 수 있도록 개선했다.

  2. 100명 1000명이 시도해도 재고가 10개면 10개만 받고 깔끔하게 10명만 결제하기로 넘어갈 수 있다.


개선된 코드에서 발생한 문제

  • 수량이 100개인 상품을 어떤 유저 한명이 100개를 주문해놓고 결제를 안하면 다른사람이 결제를 할 수가 없다.
    ex) 주문량 빌런

    스크린샷 2024-04-17 오후 3 23 23

스케줄링을 통한 문제 해결

  • 주문하기 버튼을 클릭하면 "5분 이내에 결제가 완료되지 않으면 주문이 자동으로 취소됩니다."라는 팝업 메시지를 표시한다. 이와 함께 스케줄링 기능을 도입하여, 주문 후 5분 이내에 결제가 이루어지지 않은 경우 해당 주문을 자동으로 취소 처리하고 주문된 수량만큼 재고를 복원한다.

개선 효과

  1. 미결제 주문으로 인한 재고 낭비와 판매 기회 손실을 최소화할 수 있게 되었다.

  2. 사용자 경험 측면에서도 결제 완료에 대한 적절한 안내를 제공하고, 보다 효율적인 주문 처리 흐름을 제공할 수 있게 되었다.


개선된 코드

주문하기

    public void createOrder(
            Map<Long, Long> basket,
            UserDetailsImpl userDetails,
            Long addressId
    ) {
        String lockKey = "order_lock";
        RLock lock = redissonClient.getLock(lockKey);
        try {
          boolean isLocked = lock.tryLock(5, 10, TimeUnit.SECONDS);
          if (!isLocked) {
            throw new RuntimeException("락 획득에 실패했습니다.");
          }

          // 상품 수량 검증 코드
          checkBasket(basket);

          // 주문 객체 생성
          Order order = new Order(userDetails.getUser().getId(), OrderState.NOTPAYED, addressId);

          // 주문 객체 저장
          orderRepository.save(order);

          // 장바구니를 순회하며
          // 주문한 상품과 상품 개수를 상품 상세정보 테이블에 업데이트
          // 상품 테이블의 상품 수량 업데이트
          for (Map.Entry<Long, Long> entry : basket.entrySet()) {
            Long productId = entry.getKey();
            Long quantity = entry.getValue();

            // 상태를 업데이트하는 메서드
            updateStockAndCreateOrderDetail(productId, quantity, order);
          }
        } catch (InterruptedException e) {
          throw new RuntimeException("락 획득 중 오류가 발생했습니다.", e);
        } finally {
          if (lock.isLocked()) {
            lock.unlock();
          }
        }
      }

    //상태를 업데이트하는 메서드
    @Transactional
    public void updateStockAndCreateOrderDetail(Long productId, Long quantity, Order order) {
        //영속성 컨텍스트를 초기화
        entityManager.clear();
        //상품 객체 생성
        Product product = productService.getProduct(productId);
        //상품 수량 수정
        product.updateStockAfterOrder(quantity);
        System.out.println("현재 상품 수량: " + product.getStock());
        //수정된 상품 저장
        productService.save(product);
        //상품 상세정보 객체 저장
        OrderDetail orderDetail = new OrderDetail(order.getId(), productId, quantity, product.getPrice(), product.getName());
        orderDetailRepository.save(orderDetail);
    }

스케줄링 코드

    @Configuration
    @EnableScheduling
    public class SchedulingConfig {
    }
  • 스케줄링 기능을 활성화하기 위한 Spring 설정 클래스 만든다.

일정 시간이 지났는데 결제가 안된 주문서를 찾는 쿼리

    @Query("SELECT o FROM Order o WHERE o.state = 'NOTPAYED' AND o.createdAt < :cutoff")
    List<Order> findUnpaidOrdersOlderThan(@Param("cutoff") LocalDateTime cutoff);
  • OrderRepository에 쿼리를 작성한다.

주문 취소&수량 복구 업데이트 로직

    @Scheduled(fixedDelay = 10000) // 10초에 한번씩 실행
    public void cancelUnpaidOrdersAndRestoreStock(
    ) {
      //시간 설정 변수 선언
      LocalDateTime MinutesAgo = LocalDateTime.now().minus(30, ChronoUnit.SECONDS); //테스트를 위해 10초로 설정
      // MinutesAgo 변수 설정 시간 이상 미결제 주문 조회
      List<Order> unpaidOrders = orderRepository.findUnpaidOrdersOlderThan(MinutesAgo);
      //일정 시간이 지난 order 리스트를 순회 하며 주문을 취소 시키고 재고를 복구함
      for (Order order : unpaidOrders) {
        if (order.getState() == OrderState.NOTPAYED) {
          order.changeState(OrderState.CANCELLED);
          orderRepository.save(order);
          restoreStock(order); // 재고 복구 로직
        }
      }
    }

    @Transactional
    public void restoreStock(
            Order order
    ) {
      //주문서의 id를 가진 주문 상세를 가져옴
      List<OrderDetail> orderDetails = orderDetailRepository.findByOrderId(order.getId());
      //주문 상세표들을 순회
      for (OrderDetail detail : orderDetails) {
        //100명이 한꺼번에 주문하면 한꺼번에 취소하는 상황에도 동시성이 필요함
        //락 적용
        String lockKey = "productLock:" + detail.getProductId();
        RLock rLock = redissonClient.getLock(lockKey);
        boolean isLocked = false;

        try {
          isLocked = rLock.tryLock(5, 10, TimeUnit.SECONDS);
          if (!isLocked) {
            throw new RuntimeException("Unable to lock the product stock for product ID: "
                    + detail.getProductId());
          }
          //주문 상세에 있는 상품 객체를 불러옴
          //상품 객체의 수량을 복구
          Product product = productService.getProduct(detail.getProductId());
          product.updateStockAfterOrder(-detail.getQuantity());
          System.out.println("복구" + product.getStock());
          productService.save(product);

        } catch (InterruptedException e) {
          Thread.currentThread().interrupt();
          throw new RuntimeException("Lock acquisition interrupted", e);

        } finally {
          if (isLocked && rLock.isLocked()) {
            rLock.unlock();
          }
        }
      }
    }
  • JMeter로 테스트를 하기 위해 주문 취소 로직의 실행 시간을 10초로 설정했다.

  • JMeter로 테스트를 하기 위해 주문 한 뒤에 결제를 안했을때 주문이 취소되는 시점을 30초로 설정했다.

테스트 결과

상품수량

스크린샷 2024-04-17 오후 3 47 03

  • 수량을 1000개로 설정

JMeter 설정

스크린샷 2024-04-17 오후 3 52 58

  • JMeter 스레드를 100개로 설정

  • user.csv에 기존 데이터 베이스의 유저 100명을 넣어서 JMeter에 업데이트

실행 결과

스크린샷 2024-04-17 오후 3 56 09

스크린샷 2024-04-17 오후 3 56 23

  • 동시성이 잘 적용돼서 수량이 100개가 순차적으로 줄어드는 것을 확인할 수 있다.

스크린샷 2024-04-17 오후 3 55 43

*주문서 상태가 NOTPAYED로 성공적으로 100개 만큼 잘 생성 되었다.

주문 취소 스케줄링 결과

스크린샷 2024-04-17 오후 3 56 44

스크린샷 2024-04-17 오후 3 56 55

  • 주문을 하고 설정해놓은 시간동안 결제를 하지 않아서 스케줄링을 적용한 코드가 실행돼서 수량이 성공적으로 복구 되는것을 확인할 수 있다.

스크린샷 2024-04-17 오후 3 59 57

  • 주문서의 상태가 CANCELLED로 성공적으로 바뀐것을 확인할 수 있다.

테스트 2 (상품 수량이 1개일 때 동시에 두 명의 사용자가 주문했을 경우)

  1. JMeter에 스레드를 2개로 설정한다.

  2. 상품의 수량을 1개로 변경한다.

테스트 결과

스크린샷 2024-04-17 오후 4 10 52

  • 동시성이 성공적으로 제어가 돼서 1번 유저는 주문에 성공하고 2번 유저는 주문에 실패한다.

  • 10초 뒤에 1번 유저가 결제를 안해서 다시 수량이 복구되고 order의 상태가 CANCELLED로 변경된다.

More from this blog

서브넷 이해하기

서브넷 IPv4 IPv4로 보자면 데이터 전송 방식은 유니캐스트 멀티캐스트 브로드 캐스트다. 32bit 숫자로 구성되어 있다. 일반적으로 4개의 10진수로 표현된다. 데이터 전송 방식 데이터 전송 방식설명 유니캐스트한 대의 컴퓨터가 다른 한 대의 컴퓨터에게 데이터를 보내는 방식 멀티캐스트한 대의 컴ㅂ퓨터가 특정 그룹에 속한 여러 컴퓨터에게 ㄷ이터를 보내는 방식 브로드캐스트한 대의 컴퓨터가 같은 네트워크에 있는 모든 컴퓨터...

Aug 5, 20242 min read3
서브넷 이해하기

네트워크 계층

네트워크 계층 네트워크는 원하는 정보를 원하는 수신자 또는 기기에 정확하게 전송하기 위한 기반 인프라이다. OSI 7계층 (지겹지만 반복) OSI 7계층은 국제 표준화 기구인 ISO(International Standardization Organization)에서 개발한 컴퓨터 네트워크 프로토콜 디자인과 통신을 계층으로 나누어 설명한 개방형 시스템 상호 연결 모델이다. 각 계층은 서로 독립적으로 구성되어 있고, 각 계층은 하위 계층의 기...

Jul 29, 20243 min read3
네트워크 계층

메모리 단편화

메모리 단편화 메모리 단편화란 분할된 주기억장치에 프로세스를 할당, 반납 과정에서 사용되지 못하고 낭비되는 기억장치가 발생하는 현상이다. 유형으로는 내부 단편화와 외부 단편화가 있다. 내부 단편화 내부 단편화 개념 내부 단편화는 분할된 공간에 프로세스를 적재한 후 남은 공간이다. 내부 단편화는 고정 분할 할당 방식 또는 페이징 기법 사용 시 발생하는 메모리 단편화다. 해결 방안설명 슬랩 할당자 (Slab Allocator)페...

Jul 20, 20241 min read

2024.07.13 애플리케이션 테스트 수행

애플리케이션 테스트 수행 IDE 도구 개념 IDE(Integrated Development Environment) 도구는 코딩, 디버그, 컴파일, 배포 등 프로그램 개발과 관련된 모든 작업을 하나의 프로그램 안에서 처리하는 환경을 제공하는 소프트웨어이다. 기존에는 컴파일러, 텍스트 편집기, 디버거를 따로 사용했으나 통합 개발환경을 통해 하나로 묶은 대화형 인터페이스를 제공한다. IDE 도구의 기능 IDE 도구는 다양한 기능을 통합한 형태...

Jul 12, 20242 min read2
2024.07.13 애플리케이션 테스트 수행
J

Java 백엔드 및 풀스택 개발

111 posts

안녕하세요! Web개발을 공부하고 있는 윤종일 입니다. 현재는 Java 백엔드 개발을 깊게 파고들고 있어요! 제 궁극적인 목표는 풀스택 개발자가 되는 것입니다. 프론트엔드와 백엔드 모두를 자유롭게 넘나들며, 사용자에게 가치를 전달할 수 있는 완성도 높은 애플리케이션을 만드는 것이 목표입니다.

E-Mail : ji4971@naver.com