토이 프로젝트를 진행하면서 발생했던 문제에 대한 본인의 생각과 고민을 기록한 글입니다.
기술한 내용이 공식 문서 내용과 상이할 수 있음을 밝힙니다.
Mysql 공식문서 참고: https://dev.mysql.com/doc/refman/8.0/en/
Yunni-Bucks
프로젝트에서 고도화 작업을 진행하면서 다수의 사용자가 동시에 주문을 할 경우 재고 감소 로직을 DB Lock을 적용하여 구현하였다.
다수 사용자 동시 주문은 충돌이 빈번할거라 판단했다. 이유는 다음과 같다.
메뉴 재고 조회(@Lock(LockModeType.OPTIMISTIC)를 통해서 낙관적 락을 시도한다. @Version 어노테이션이 적용된 필드(version 필드)는 엔티티의 버전 정보를 나타내며, 갱신 시마다 버전이 자동으로 증가한다.
주어진 코드에서 @Version
어노테이션이 Menu 엔티티의 version 필드에 적용되어 있다. 이를 통해 JPA는 엔티티를 조회할 때 버전 정보를 함께 가져오고, 업데이트 시에는 버전이 일치하는지 확인하여 충돌을 감지한다. 따라서 findByIdForOptimisticLock()
메서드를 통해 메뉴를 조회하고 업데이트할 때, JPA는 낙관적 락을 수행하여 동시에 업데이트가 발생하는 경우 충돌을 감지하고 처리한다.
이전 글에서도 설명했지만 사용자1이 업데이트를 하고 버전을 1 증가시켰다. 그리고 사용자2가 버전1 조건으로 업데이트를 하려 했지만 DB는 버전이 2므로 업데이트 실패한다.
@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;
}
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
@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);
}
멀티 쓰레드를 이용하기 때문에 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);
}
@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초 정도 대기시켜, 백그라운드 스레드가 완전히 마무리 되도록 잠시 대기한다.
낙관적 락을 사용하여 동시성 제어를 하였다. 낙관적 락은 동시성 충돌이 자주 발생하지 않는 상황에서 효율적이지만 궁금하기 때문에 동시 주문처리를 낙관적 락으로 시도해보았다.
충돌 발생은 빈번했고, 무한 루프가 돌기도 했다. 따라서 낙관적 락의 재시도 로직을 통해 트랜잭션의 데이터 액세스를 재시도한다. 하지만, 재시도 시 처리 시간과 오버헤드를 비관적 락과 비교해봤을 때 시간차이가 약 5초 발생한다.
비관적 락을 사용하면, 데이터를 락으로 걸어 다른 트랜잭션의 접근을 막는다. 따라서 한 트랜잭션이 락을 획득하면 업데이트를 제어하기 때문에 데이터 정합성이 보장된다. 하지만 다른 트랜잭션은 그 락이 해제될 때까지 대기해야 하므로 동시성을 제한하며, 대기 시간 동안의 CPU 자원 등이 낭비될 수 있다.
이러한 차이는 환경, 데이터의 크기, 트랜잭션의 복잡성, 테스트 코드 등 여러 요인에 따라 달라질 수 있지만 나의 주문 로직에서는 비관적 락이 성능이 좀더 앞선다고 보여진다.