우당탕 동시성 제어하기

devty·2023년 6월 8일
0

서론

동시성 제어가 필요했던 이유

  • 지금 개발하고 있는 EWM(Exercise_with_me)에서는 운동 관련된 제품 및 식료품도 이커머스 서비스로 포함 시켰다.
  • 주문 서비스 특성상 하나의 제품을 여러 사람이 사기에 재고 수량에 대한 일관성을 지키기 위해서 점검해 보았습니다.

주문 서비스 로직은 밑과 같습니다.

  1. 주문 API를 통해 장바구니에 대한 상품들과 로그인 한 유저에 대한 정보를 받아옵니다.

    @PostMapping("/add/{productId}")
        public ResponseEntity<ReturnObject> AddStock(
                @LoginUser User user,
                @RequestBody OrderRegisterRequest orderRegisterRequest
        ) {
            registerOrderUseCase.registerOrder(user, orderRegisterRequest);
    
            ReturnObject returnObject = ReturnObject.builder()
                    .success(true)
                    .data("상품의 재고를 추가하였습니다.")
                    .build();
    
            return ResponseEntity.status(HttpStatus.OK).body(returnObject);
        }
  2. 주문을 처리하는 비즈니스 로직입니다.

    @Override
    public void registerOrder(User user, OrderRegisterRequest orderRegisterRequest) {
        List<OrderItem> orderItems = new ArrayList<>();
    
        for (OrderItemRegisterRequest orderItemRegisterRequest : orderRegisterRequest.getOrderItemRegisterRequests()) {
            OrderItem orderItem = orderItemRegisterRequest.toEntity();
            orderItems.add(orderItem);
        }
        Order order = orderRegisterRequest.toEntity(orderItems, user);
        validateOrder(order);
    
        for (OrderItem orderItem : orderItems) {
            reduceStockUseCase.reduceStock(orderItem);
        }
        save(order, orderItems);
    }
    • orderRegisterRequest(DTO) → order(Domain Entity)로 변환시켜줍니다.
    • validateOrder 메소드로 order에 대한 유효성 검사를 처리합니다.
    • reduceStock 메소드로 주문한 상품들에 대한 재고 수량을 감소시킵니다.
    • save 메소드로 주문을 저장합니다.
  3. 재고수량 감소 비즈니스 로직은 밑과 같습니다.

    @Override
    public void reduceStock(OrderItem orderItem) {
        Stock stock = loadStockPort.loadStock(orderItem.getProductId());
        if (stock == null) {
            throw new EntityNotFoundException("상품이 없습니다.");
        }
        if (stock.getQuantity() < orderItem.getCount()) {
            throw new RuntimeException("수량이 없습니다.");
        } else {
            stock.reduceStock(orderItem.getCount());
            saveStockPort.saveStock(stock);
        }
    }

본론

가정

테스트 코드 : 100명의 유저가 1개의 상품에 대해 1개씩 주문을 한다

@SpringBootTest
@AutoConfigureMockMvc
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class StockControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Autowired
    private StockJpaRepo stockJpaRepo;

    @Autowired
    private ReduceStockUseCase reduceStockUseCase;

    @Autowired
    private PlatformTransactionManager transactionManager;

    OrderItem orderItem;
    List<OrderItem> orderList = new ArrayList<>();

    @BeforeEach
    public void init() {
        stockJpaRepo.save(new StockJpaEntity(1L, 100, 1L));
        orderItem = new OrderItem(1L, 1L, 1, 1L);
    }

    @AfterEach
    public void clear() {
        // 테스트용 데이터 삭제
        stockJpaRepo.deleteAll();
    }

    @Test
    @DisplayName("100명이 동시에 1개씩 재고 감소시키기")
    public void AtTheSameTime_100Requests() throws InterruptedException {
        //given
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(2);
        CountDownLatch latch = new CountDownLatch(threadCount);

        // when
        IntStream.range(0,100).forEach(e -> executorService.submit(() -> {
                    try {
                        TransactionStatus status =  transactionManager.getTransaction(null);
                        reduceStockUseCase.reduceStock(orderItem);
                        transactionManager.commit(status);
                    } finally {
                        latch.countDown();
                    }
                }
        ));
        latch.await();

        // then
        StockJpaEntity stock = stockJpaRepo.findByProductId(1L);
        assertEquals(0L, stock.getQuantity());
    }

}
  • int threadCount = 100; CountDownLatch latch = new CountDownLatch(threadCount); → 이 코드가 없다면 IntStream.range(0,100).forEach 해당 foreach문이 순차적으로 하나씩 실행이 될텐데 위 코드가 있다면 100번의 실행이 직렬로 실행되는게 아닌 병렬로 동시에 처리가 됨.
  • ExecutorService executorService = Executors.newFixedThreadPool(2); → 동시에 작업 가능한 스레드 개수를 설정한다.
  • TransactionStatus status = transactionManager.getTransaction(null); → 트랜잭션의 상태를 관리하는데 null이라면 시작을 의미한다.
  • transactionManager.commit(status); → 트랙잭션의 상태가 끝(성공)을 의미한다.
  • latch.await(); → 모든 작업이 끝났음을 의미함.
  • 사담이지만 클린 아키텍처로 만든 이후에는 테스트코드에 대한 모킹을 하는 시간이 현저히 줄어들었다. → 개발 효율성 증가

결과는 처참히 실패하였다.

  • 100개의 스레드를 동시에 접근시켜서 100(총 재고) - 1(재고) * 100(유저 수) = 0 을 기대했는데 실제값은 44가 나왔다.
    • 시도할 때 마다 다른 값이 나오긴 하였다. 큰 격차는 없이 42 ~ 48 사이를 왔다갔다 했다.
  • reduceStock(재고 수량 감소)메소드를 하나의 트랙잭션으로 관리하여 실제값이 0으로 나올줄 알았다.
  • 예상 작업 순서
    Thread-1StockThread-2
    select * form Stock where productId =1{id : 1 quantity : 100}
    update set quantity = quantity -1 form Stock where productId =1{id : 1 quantity : 99}
    {id : 1 quantity : 99}select * form Stock where productId =1
    {id : 1 quantity : 98}update set quantity = quantity -1 form Stock where productId =1
  • 실제 작업 순서
    Thread-1StockThread-2
    select * form Stock where productId =1{id : 1 quantity : 100}
    {id : 1 quantity : 100}select * form Stock where productId =1
    update set quantity = quantity -1 form Stock where productId =1{id : 1 quantity : 99}
    {id : 1 quantity : 99}update set quantity = quantity -1 form Stock where productId =1
  • 원인 분석을 위해 여러 코드와 블로그를 보는 와중에 Race Condition Lost Update 문제를 발견하여 해결 방법을 찾았다.

해결 방법

레이스 컨디션 해결방법 밑과 같은 방법들이 있다,

  1. Java synchronized
  2. MySql Lock (Pesimistic Lock, Optimistic Lock)
  3. Redis Lock (Redisson Lock)

Java synchronized

synchronized를 메소드에 명시해주면 하나의 스레드만 접근이 가능하게 만들어준다.

멀티스레드 환경에서 스레드간 데이터 동기화를 시켜주기 위해서 자바에서 제공하는 키워드이다.

밑과 같은 코드로 재고수량(비즈니스 로직을 처리할)메소드에 추가하여 테스트 코드를 돌려보았더니 성공하였다.

@Override
public **synchronized** void reduceStock(OrderItem orderItem) {
    Stock stock = loadStockPort.loadStock(orderItem.getProductId());
    if (stock == null) {
        throw new EntityNotFoundException("상품이 없습니다.");
    }
    if (stock.getQuantity() < orderItem.getCount()) {
        throw new RuntimeException("수량이 없습니다.");
    } else {
        stock.reduceStock(orderItem.getCount());
        saveStockPort.saveStock(stock);
    }
}
  • 단, Transactional에 관련된 내용들은 다 빼주고 하여야 테스트를 성공할 수 있다.
  • 테스트 코드에서도 마찬가지이다.
    • TransactionStatus status = transactionManager.getTransaction(null);, transactionManager.commit(status); 이 두 부분도 주석처리 후 테스트를 돌려주면 성공으로 표기가 된다.

synchronized의 단점

  • synchronized는 한개의 스레드만 접근이 가능하므로 성능이 안 좋고 작업 시간이 오래걸릴 것 이다.
  • 다른 문제들도 있지만 직접 경험해 본 단점인 성능적인 측면에서 안 좋다고 생각이 들어 사용을 하지 않았습니다.

MySql Lock (Pesimistic Lock, Optimistic Lock)

Pesimistic Lock

비관락은 데이터 갱신시 충돌이 발생할 걸 염려(비관)하여 미리 잠금을 거는 방식이다.

  • 실제 작업 순서
    Thread-1StockThread-2
    select * form Stock where productId =1{id : 1 quantity : 100}← 재고 획득 시도
    update set quantity = quantity -1 form Stock where productId =1{id : 1 quantity : 100}→ 재고 획득 실패
    {id : 1 quantity : 99}← 재고 획득 시도 select * form Stock where productId =1
    {id : 1 quantity : 99}→ 재고 획득 성공 update set quantity = quantity -1 form Stock where productId =1

Optimistic Lock

낙관락은 트랜젝션 대부분이 충돌이 발생하지 않는다고 가정합니다.

  • 실제 작업 순서
    Thread-1StockThread-2
    select * form Stock where productId =1{id : 1 quantity : 100 version : 1}select * form Stock where productId =1
    update set quantity = quantity -1 form Stock where productId =1{id : 1 quantity : 99 version : 2}
    {id : 1 quantity : 99 version : 2}update set quantity = quantity -1 form Stock where productId =1 ← 재고 업데이트 실패

공통점

  1. Repository에 @Lock 어노테이션을 적용한 조회 메서드를 추가하여, row 단위로 락을 걸었습니다.
  2. 트랜젝션이 시작할 때, 사용자가 주문하고자 하는 제품 ID 로 재고수량을 찾습니다.
  3. 여기서 select 된 모든 행에 lock을 걸게 됩니다.

다른점 (Pesimistic Lock)

  1. JPA의 LockModeType 기능을 활용하여, PESSIMISTIC_WRITE 쓰기 락을 걸었습니다.

    @Lock(value = LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from StockJpaEntity s where s.productId = :productId")
    StockJpaEntity findByProductIdWithPessimisticLock(Long productId);
  2. 객체에 배타락을 획득함으로써, 다른 트랜젝션에서 READ, UPDATE, DELETE 를 수행하는 것을 막아줍니다.

다른점 (Optimistic Lock)

  1. 낙관락은 @Version을 통해 데이터의 변경 여부를 감지합니다. 따라서 Entity에 @Version을 붙인 변수를 추가해줘야합니다.

    @Entity
    @Table(name = "product_stock")
    @AllArgsConstructor
    @NoArgsConstructor
    public class StockJpaEntity extends BaseTimeEntity implements Serializable {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private int quantity;
    
        @Column(name = "product_id")
        private Long productId;
    
        @Version
        private Long version; // 낙관락을 사용하기 위함
  2. JPA의 LockModeType 기능을 활용하여, OPTIMISTIC 락을 걸었습니다.

    @Lock(value = LockModeType.OPTIMISTIC)
    @Query("select s from StockJpaEntity s where s.productId = :productId")
    StockJpaEntity findByProductIdWithOptimisticLock(Long productId);
  3. 먼저 데이터를 읽은 후에 update를 수행할 떄 현재 내가 읽은 버전이 맞는지 확인하며 업데이트 합니다.

단점 (Pesimistic Lock)

  1. 데이터 row 하나씩 락을 잡기 때문에 동시성이 떨어져서 성능에 저하가 생길수도 있다.
  2. 유저가 적을 땐 괜찮지만 유저가 많아지고 확장성이 중요할 경우에는 성능을 위해 고려해봐야함.

단점 (Optimistic Lock)

  1. 재고 데이터 변경이 잦은 경우에 예외가 많이 발생할 것이다. → 대부분 충돌이 없다는 가정이기 때문이다.
  2. 예외 발생시 처음부터 다시 수행해야하므로, 현재 재고 조회 + 재고량 감소 작업 실패할 때마다 다시 실행해야하므로 성능적인 측면에서 비관락보다 조금 더 오래걸린다.

Redis Lock (Redisson Lock)

Pub-sub 기반으로 Lock 을 구현한다.

  • Pub-Sub 방식이란, 채널을 하나 만들고, 락을 점유중인 스레드가, 락을 해제했음을, 대기중인 스레드에게 알려주면 대기중인 스레드가 락 점유를 시도하는 방식입니다.

Spring Gradle

dependencies {
    implementation 'org.redisson:redisson-spring-boot-starter:3.17.6'  
}

비즈니스 로직

@Component
@RequiredArgsConstructor
public class RedissonLockStockFacade {

    private final RedissonClient redissonClient;
    private final ReduceStockUseCase reduceStockUseCase;
    private final PlatformTransactionManager platformTransactionManager;

    public void reduceStock(OrderItem orderItem) throws InterruptedException {

        RLock lock = redissonClient.getLock(orderItem.getProductId().toString());
        try {
            boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
            if (!available) {
                System.out.println("lock 획득에 실패하였습니다");
                return;
            }

            TransactionStatus status = platformTransactionManager.getTransaction(null);
            reduceStockUseCase.reduceStock(orderItem);
            platformTransactionManager.commit(status);
        } finally {
            lock.unlock();
        }
    }
}
  • RLock lock = redissonClient.getLock(orderItem.getProductId().toString()); → 해당 주문한 상품의 Id를 키로 락 객체를 가져온다.
  • lock.tryLock(10, 1, TimeUnit.SECONDS) → 락 획득 최대 시간은 10초이고 락 점유 시간은 1초이다.

장점

  1. 여러 노드 간에 분산 락을 적용할 수 있어서 분산 환경에 도움이 많이 됨. → 추후에 MSA를 도입하기 위해 미리 Redisson Lock 선택하였습니다.
  2. Redisson은 락을 획득할 때 유효기간을 설정할 수 있습니다. 이는 락을 오랫동안 유지하지 않고 효과적으로 해제하여 시스템의 확장성과 성능을 향상시킨다.

결론

기술 선택

여러가지 기술들이 있었지만 각 회사 또는 각 프로젝트에 따라 각기 다른 기술스택을 선택해야한다고 생각합니다.

예를들어 정답은 없지만 서버의 개수, 비즈니스 로직, 트래픽 량 등 고려할 사항들을 고려해보고 선택해야 될 것 같습니다.

저는 추후에 해당 서비스에 MSA를 넣기 위해 Redisson을 이용한 방법을 선택하였습니다.

profile
지나가는 개발자

0개의 댓글

관련 채용 정보