[Project] 비관적 락(Pessimistic Lock) 동시성 제어 문제

Hayoon·2023년 12월 24일
1

Spring 정리

목록 보기
11/11

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

Pessimistic Lock을 적용하게 된 이유?

기존에 Optimistic Lock(낙관적 락)으로 동시성 제어를 처리하려고 했다. 낙관적 락은 말 그대로 트랜잭션 충돌이 적을 거라고 가정하고 동시성 제어를 한다. 하지만 주문 시 재고 처리, 포인트 차감 등의 상황은 서로 다른 트랜잭션의 공유자원 접근으로 충돌이 빈번할거라 추측하였다.

  • 비관적 락을 사용하면 한 번에 하나의 트랜잭션만 해당 메뉴의 재고를 변경할 수 있기 때문에 충돌을 방지할 수 있음
  • 주문 시스템의 경우 동시에 여러 사용자가 같은 메뉴를 주문하는 상황이 자주 발생하므로, 낙관적 락을 사용하면 충돌로 인한 롤백이 빈번하게 발생하여 오버헤드가 발생할거라 판단

이는 개인적인 판단이기에, 실제로 낙관적 락과 비관적 락의 두 기법의 성능을 비교하였다.

Pessimistic Lock 구현 과정

메뉴 재고 조회(@Lock(LockModeType.PESSIMISTIC_WRITE)를 통해서 비관적 락을 시도한다. 비관적 락은 트랜잭션이 성공적으로 완료될 것이라는 보장을 제공한다. 낙관적 락이 데이터의 변경을 가정하고 충돌이 발생했을 때 재시도 로직을 수행하는 반면, 비관적 락은 처음부터 충돌을 방지하기 위해 락을 걸어두므로 재시도 로직을 수행할 필요가 없다.

PESSIMISTIC_WRITE, PESSIMISTIC_READ의 차이?

락 타입사용 시기다른 트랜잭션에 대한 제한특징
PESSIMISTIC_WRITE데이터 수정 시수정 및 읽기 제한락이 해제될 때까지 다른 트랜잭션은 해당 데이터를 읽거나 쓸 수 없다. 이를 통해 데이터의 부정합을 효과적으로 방지할 수 있다.
PESSIMISTIC_READ데이터 읽기 시수정만 제한다른 트랜잭션이 해당 데이터를 읽는 것은 허용되지만, 데이터를 수정하는 것은 락이 해제될 때까지 허용되지 않는다.

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(value = LockModeType.PESSIMISTIC_WRITE)
	@Query("select m from Menu m where m.id = :id")
	Optional<Menu> findByIdForPessimisticLock(@NotNull @Param("id") Long id);

}

Service

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    ...

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

        Cart cart = cartRepository.findByMember(memberId);

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

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


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

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

        return orderRepository.save(order);
    }

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

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

Test

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

init()

@PostConstruct 애노테이션이 붙은 메소드는 객체가 생성되고 의존성 주입이 완료된 후에 자동으로 호출되는 초기화 메소드다. 이 메소드 내부에서 수행되는 로직은 테스트 실행 시간에 포함되지 않는다.
IntStream.range(0, parameter)를 사용하여 parameter만큼의 Member 객체를 생성한다. 각 Member 객체는 고유의 주소, 유저 등급, 이름, 비밀번호, 잔고, 이메일, 주문 횟수를 가지고 있다. 이 Member 객체들은 userRepository.save(member)를 통해 저장된다.
마지막으로, 생성된 Member 객체들은 toList()를 통해 리스트로 반환되고 members 필드에 저장된다.

concurrencyOrderForPessimisticLock()

when 부분에서는 각 사용자(Member)마다 쓰레드를 생성하여 주문을 시도한다. 32개의 쓰레드가 각각 주문 처리를 하고 작업을 마치면 countDown()이 호출되어 CountDownLatch의 count가 32 감소한다. 그러면 대기 중이던 쓰레드들 중 32개가 다시 주문을 처리하기 시작한다. 이 과정을 반복하여 총 100개의 주문이 모두 처리될 때까지 진행된다.
countDownLatch.await()에서는 총 100개의 주문 처리가 모두 끝날 때까지 메인 쓰레드가 대기한다.
then 부분에서는 테스트의 결과를 검증한다. menuRepository.findById(beverage.getId())를 통해 주문한 음료를 찾아내고, 그 음료의 재고(getStock())가 0인지 확인한다.

@PostConstruct
void init() {
    Nutrients nutrients = new Nutrients(80, 80, 80, 80);

    beverage = Beverage.builder()
            .description("에티오피아산 커피")
            .title("커피")
            .price(Money.initialPrice(new BigDecimal(1000)))
            .nutrients(nutrients)
            .menuSize(MenuSize.M)
            .now(LocalDateTime.now())
            .stock(parameter)
            .build();

    beverage = menuRepository.save(beverage);

    members = IntStream.range(0, parameter)
                .mapToObj(i -> {
                    Member member = Member.builder()
                        .address(new Address("서울시", "광진구", "화양동", "123-432"))
                        .userRank(UserRank.BRONZE)
                        .name("홍길동" + i)
                        .password("qwer1234@A")
                        .money(Money.ZERO)
                        .email("qwer123" + i + "@naver.com")
                        .orderCount(0)
                        .build();
                Member savedMember = userRepository.save(member);
                cartService.createCart(savedMember.getId());
                cartService.addMenu(savedMember.getId(), beverage.getId());
                return savedMember;
            })
            .collect(toList());
}

@Nested
@DisplayName("DB Lock 동시성 테스트")
class ConcurrencyTest {
    @Test
    @DisplayName("여러명의 사용자가 동시적으로 음료 A를 주문한다. (비관적 락 기법)")
    void concurrencyOrderForPessimisticLock() throws InterruptedException {

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

        // when
        for (Member member : members) {
            executorService.submit(() -> {
            try {
                orderService.orderWithPessimisticLock(member.getId(), LocalDateTime.now());
                } finally {
                    countDownLatch.countDown();
                }
            });
        }

        countDownLatch.await();  // 모든 주문이 완료될 때까지 대기
        executorService.shutdown(); // 메인 쓰레드가 실행되고 쓰레드 풀 종료

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

init() 메서드에서 100명의 사용자를 테스트 전에 초기화하였다.

결과

100명의 사용자가 동시에 주문을 했을 때 재고가 100개에서 0개로 성공적으로 테스트를 수행했다.

낙관적 락, 비관적 락 비교

구분(Users)100명1000명
Pessimistic Lock2.8 sec10.4 sec
Optimistic Lock7.3 sec25.5 sec
처리속도 비교+4.5 sec+15.1 sec

낙관적 락은 충돌에 따른 재시도 로직으로 인해 Thread.sleep()이 자주 호출되어선지 상대적으로 비관적 락의 성능이 좋다고 판단된다.
충돌의 빈도가 빈번하다고 판단되는 수치는 몇인지, 실무에서도 비관적 락을 사용하는건지, 의견을 여쭙고 싶다.
추가로, 현재는 단일 DB 환경에서 테스트를 진행했다. 분산DB 환경에서는 분산 락은 여러 노드에 걸쳐 있는 데이터에 대한 동시성을 제어할 수 있다. 단일 DB 환경에서도 사용할 수 있지만, 분산 락을 사용하는 주된 목적은 여러 노드 간의 데이터 일관성을 유지하는 것이다.

다음 글에서는 Redis를 활용한 분산 락을 적용해보자.

profile
Junior Developer

0개의 댓글