[Project] 낙관적 락(Optimistic Lock) 동시성 제어 문제

Hayoon·2023년 11월 22일
1

Spring 정리

목록 보기
10/11

토이 프로젝트를 진행하면서 발생했던 문제에 대한 본인의 생각과 고민을 기록한 글입니다.
기술한 내용이 공식 문서 내용과 상이할 수 있음을 밝힙니다.
Mysql 공식문서 참고: https://dev.mysql.com/doc/refman/8.0/en/

Optimistic Lock을 적용하게 된 이유?

Yunni-Bucks 프로젝트에서 고도화 작업을 진행하면서 다수의 사용자가 동시에 주문을 할 경우 재고 감소 로직을 DB Lock을 적용하여 구현하였다.

다수 사용자 동시 주문은 충돌이 빈번할거라 판단했다. 이유는 다음과 같다.

  1. 도메인: 쇼핑몰, 배달앱 같은 도메인에서는 많은 사용자들이 동시에 주문을 시도할 수 있어 충돌이 빈번하게 발생할 수 있다고 일차원적으로 판단했다.
  2. 경쟁 상태 (Race Condition): 여러 사용자가 동시에 주문을 시도할 때, 주문 처리 로직에서 데이터의 일관성을 보장하지 못하거나 동일한 자원에 대한 동시 접근이 발생할 수 있다.
  3. 제한된 자원: 주문 시스템이나 백엔드 시스템에서는 처리할 수 있는 자원의 한계가 있을 수 있다. 동시에 많은 사용자가 주문을 시도하면 자원 부족으로 인해 충돌이 발생할 수 있다. 예를 들어, 동시에 많은 사용자가 동일한 상품을 주문하려고 할 때, 해당 상품의 재고가 부족하면 충돌이 발생할 수 있다.

Optimistic Lock 구현 과정

메뉴 재고 조회(@Lock(LockModeType.OPTIMISTIC)를 통해서 낙관적 락을 시도한다. @Version 어노테이션이 적용된 필드(version 필드)는 엔티티의 버전 정보를 나타내며, 갱신 시마다 버전이 자동으로 증가한다.

주어진 코드에서 @Version 어노테이션이 Menu 엔티티의 version 필드에 적용되어 있다. 이를 통해 JPA는 엔티티를 조회할 때 버전 정보를 함께 가져오고, 업데이트 시에는 버전이 일치하는지 확인하여 충돌을 감지한다. 따라서 findByIdForOptimisticLock() 메서드를 통해 메뉴를 조회하고 업데이트할 때, JPA는 낙관적 락을 수행하여 동시에 업데이트가 발생하는 경우 충돌을 감지하고 처리한다.

이전 글에서도 설명했지만 사용자1이 업데이트를 하고 버전을 1 증가시켰다. 그리고 사용자2가 버전1 조건으로 업데이트를 하려 했지만 DB는 버전이 2므로 업데이트 실패한다.

Entity

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@DiscriminatorColumn
public abstract class Menu {

    @Id
    @GeneratedValue
    private Long id;
    private String title;
    private String description;
    private Money price;
    private Nutrients nutrients;
    @Column(name = "stock")
    private int stock;

    @Version
    private Long version;
}

Repository

public interface JpaMenuRepository extends JpaRepository<Menu, Long> {

    @NotNull
    @Lock(LockModeType.OPTIMISTIC)
    @Query("select m from Menu m where m.id = :id")
    Optional<Menu> findByIdForOptimisticLock(@NotNull @Param("id") Long id);
}

Service

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    ...

    @Transactional
    public Order orderWithOptimisticLock(Long memberId, LocalDateTime now, int stock) {

        Cart cart = cartRepository.findByMember(memberId);

        List<CartItem> cartItems = cart.getCartItems();

        cartItems.stream()
                .map(CartItem::getMenu)
                .forEach(menu -> {
                    decreaseStockWithOptimisticLock(menu.getId(), stock);
                    increaseMenuOrderCountWithOptimisticLock(menu.getId(), stock);
                });

        Money money = calculator.calculateMenus(cart.getMember(), cart.convertToMenus());

        Order order = Order.createOrder(cart, money, now);

        return orderRepository.save(order);
    }

    public void decreaseStockWithOptimisticLock(Long menuId, int stock) {
        Menu menu = menuRepository.findByIdForOptimisticLock(menuId);
        menu.decrease(stock);
    }

    public void increaseMenuOrderCountWithOptimisticLock(Long menuId, int stock) {
        Menu menu = menuRepository.findByIdForOptimisticLock(menuId);
        menu.increaseOrderCount(stock);
    }

Test

멀티 쓰레드를 이용하기 때문에 ExecutorService를 사용한다. ExecutorService는 비동기로 실행하는 작업을 단순화하여 사용할 수 있게 도와주는 Java의 API이다. 주문수만큼 재고를 감소시키도록 for 반복문을 사용했다.
모든 주문의 요청이 모두 끝날때 까지 기다려야 하므로 CountDownLatch로 다른 Thread에서 수행 중인 작업이 완료될때까지 대기할 수 있다.

@Test
@DisplayName("여러명의 사용자가 동시적으로 음료 A를 주문한다. (낙관적 락 기법)")
void concurrencyOrderForOptimisticLock() throws InterruptedException {

    // given
    int numberOfThread = parameter; // 재고 100개
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch countDownLatch = new CountDownLatch(numberOfThread);

    // when
    for (Member member : members) {
        executorService.submit(() -> {
            try {
                optimisticLockStockFacade.order(member.getId(), LocalDateTime.now());
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } finally {
                countDownLatch.countDown();
            }
        });
    }

    countDownLatch.await();  // 모든 주문이 완료될 때까지 대기합니다.
    executorService.shutdown();

    // then
    Menu findMenu = menuRepository.findById(beverage.getId());
    assertThat(findMenu.getStock()).isEqualTo(0);
}

결과

무한 루프가 돈다. 충돌이 발생해 재고 업데이트에 실패하게 된 트랜잭션들이 stock: 99, version: 2가 아닌 처음의 stock: 100, version:1을 읽고 있는 것 같다. 주문 로직이 하나의 트랜잭션 안에 들어 있고, 고립 수준이 REPEATABLE READ로 처음 읽은 값을 계속 읽게 되기 때문이다. 따라서 첫 트랜잭션을 제외한 모든 트랜잭션은 무한히 실패하게 된다.

해결방법

  1. 재시도 로직을 설정해주자. (FACADE 패턴을 사용해보자.)
  • 추상화: Facade 패턴은 복잡한 시스템을 단순화하여 클라이언트에 제공한다. 재시도 로직을 포함하면, 클라이언트는 재시도 로직에 대해 알 필요 없이 간단하게 요청을 보낼 수 있다.
  • 재사용성: 재시도 로직을 일관되게 적용할 수 있다. Facade 패턴을 통해 여러 구성 요소에 걸쳐 재시도 로직을 적용할 수 있다. (Service Layer에서 분리하기 위함)
  1. 고립수준을 REPEATABLE READ에서 READ COMMITED로 낮춰주자.
  • 해당 방법은 데이터베이스 트랜잭션의 고립 수준을 변경하면, 데이터 일관성과 동시성에 영향을 미치기 때문에 사용을 주의하자.
@Component
@RequiredArgsConstructor
public class OptimisticLockStockFacade {

    private final OrderService orderService;

    public Order order(Long memberId, LocalDateTime localDateTime) throws InterruptedException {
        while (true) {
            try {
                return orderService.orderWithOptimisticLock(memberId, localDateTime);
            } catch (Exception e) {
                Thread.sleep(50);
            }
        }
    }
}

업데이트 실패시 재시도를 해야하므로 while문으로 반복한다. 재고 감소 성공 시 order 로직을 이어서 진행하고, 재고 감소 실패 시 50ms 있다가 재시도를 한다. 메인스레드를 0.05초 정도 대기시켜, 백그라운드 스레드가 완전히 마무리 되도록 잠시 대기한다.

낙관적 락, 비관적 락 비교

  1. 낙관적 락을 사용하여 동시성 제어를 하였다. 낙관적 락은 동시성 충돌이 자주 발생하지 않는 상황에서 효율적이지만 궁금하기 때문에 동시 주문처리를 낙관적 락으로 시도해보았다.
    충돌 발생은 빈번했고, 무한 루프가 돌기도 했다. 따라서 낙관적 락의 재시도 로직을 통해 트랜잭션의 데이터 액세스를 재시도한다. 하지만, 재시도 시 처리 시간과 오버헤드를 비관적 락과 비교해봤을 때 시간차이가 약 5초 발생한다.

  2. 비관적 락을 사용하면, 데이터를 락으로 걸어 다른 트랜잭션의 접근을 막는다. 따라서 한 트랜잭션이 락을 획득하면 업데이트를 제어하기 때문에 데이터 정합성이 보장된다. 하지만 다른 트랜잭션은 그 락이 해제될 때까지 대기해야 하므로 동시성을 제한하며, 대기 시간 동안의 CPU 자원 등이 낭비될 수 있다.

이러한 차이는 환경, 데이터의 크기, 트랜잭션의 복잡성, 테스트 코드 등 여러 요인에 따라 달라질 수 있지만 나의 주문 로직에서는 비관적 락이 성능이 좀더 앞선다고 보여진다.

profile
Junior Developer

0개의 댓글