인프런 커뮤니티 질문&답변

CS님의 프로필 이미지
CS

작성한 질문수

실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발

stock_quantity 동시성 해결하는 방법에 대해

해결된 질문

작성

·

741

·

수정됨

0

@Test 
public void 상품_주문() {
    //given
    Member member = new Member();
    member.setName("회원1");
    member.setAddress(new Address("서울", "강가", "123-123"));
    em.persist(member);

    Book book = new Book();
    book.setName("시골 JPA");
    book.setPrice(10000);
    book.setStockQuantity(10);
    book.setAuthor("kim");
    em.persist(book);

    //when
    int orderCount = 2; // 두권 주문 
    Long orderId = this.orderService.order(member.getId(), book.getId(), orderCount); 

    //then
    Order getOrder = this.orderRepository.findOne(orderId);

    Assertions.assertEquals(OrderStatus.ORDER, getOrder.getStatus());
}
  • 강의 내용 중 테스트 코드

//==생성 메서드==//
public static OrderItem createOrderItem(Item item, int orderPrice, int count) {
        OrderItem orderItem = new OrderItem();
        orderItem.setItem(item);
        orderItem.setOrderPrice(orderPrice);
        orderItem.setCount(count);

        item.removeStock(count); // 해당 상품의 재고 수량 차감
        return orderItem;
    }
  • 강의 내용 중 OrderItem 엔티티 내 생성 메서드 createOrderItem()

강의에서는 위와 같이 OrderItem 엔티티의 생성 메서드라는 것을 통해 주문상품을 만들고 상품의 재고를 감소시키는데요. jpa가 상품 수량(stock_quantity) 감소시킬 때 사용한 UPDATE 쿼리를 보니까 UPDATE item SET stock_quantity = 8; 과 같이 되어있더라고요. 이러면 여러 클라이언트가 동시에 해당 상품 주문할 때 덮어쓰는 문제가 발생하니까 따로 해결 방법을 찾아봤습니다.


JPA에 낙관적 락이라는 게 있길래 적용해봤더니 다른 트랜잭션이 중간에 상품 수량을 변경하고 커밋하면 해당 트랜잭션에서 변환된 스프링 예외(ObjectOptimisticLockingFailureException)가 올라오며 덮어쓰는 문제는 막을 수 있었습니다(테이블 데이터 생성 후 ddl-auto: none 모드로 실행, h2 콘솔과 함께 테스트). 예외를 잡고 새 스냅샷으로 다시 호출할 수 있겠지만, db에 stock_quantity 데이터만 정확히 맞추면 되는 게 목적이어서 createOrderItem 로직을 별도의 리포지토리로 대체해보았습니다.

@Slf4j
@Repository
@RequiredArgsConstructor
public class OrderItemRepository {

    private final EntityManager em;

    /**
     * 주문 상품 생성
     * @param item
     * @param orderPrice
     * @param count
     * @return
     */
    public OrderItem createOrderItem(Item item, int orderPrice, int count) {
        OrderItem orderItem = new OrderItem();
        orderItem.setItem(item);
        orderItem.setOrderPrice(orderPrice);
        orderItem.setCount(count);

        int restStock = item.getStockQuantity() - count;
        if (restStock < 0) {
            log.info("need more stock for {}.{}", item.getId(), item.getName());
            throw new NotEnoughStockException("need more stock");
        }
        // 해당 상품의 재고 수량 차감
        em.createQuery("UPDATE Item i " +
                        "SET stock_quantity = stock_quantity - :count " +
                        "WHERE item_id = :item_id")
                .setParameter("count", count)
                .setParameter("item_id", item.getId())
                .executeUpdate(); //

        em.refresh(item);

        return orderItem;
    }
  • em.createQuery()로 item.removeStock(count); 부분을 바꿨습니다. SET stock_quantity = stock_quantity - :count

 /** OrderService 내 order 메서드 */
    @Transactional(readOnly = false)
    public Long order(Long memberId, Long itemId, int count) {

        //엔티티 조회
        Member member = this.memberRepository.findOne(memberId);
        Item item = this.itemRepository.findOne(itemId);

        //배송 정보 생성
        Delivery delivery = new Delivery();
        delivery.setAddress(member.getAddress());

        //주문 상품 생성
        OrderItem orderItem = this.orderItemRepository.createOrderItem(item, item.getPrice(), count);

        //주문 생성
        Order order = Order.createOrder(member, delivery, orderItem);

        //주문 저장
        this.orderRepository.save(order);
        return order.getId();
    }
  • 기존에 호출하던 OrderItem.createOrderItem(item, item.getPrice(), count); 대신에 orderItemRepository.createOrderItem()를 호출하도록 OrderService 코드를 변경


강의에서 작성한 상품_주문() 테스트를 단일 실행했을 때도 em.createQuery.executeUpdate()할 때 보니까, flush인가 그것도 호출안했는데 jpa가 그전에 persist한 book이랑 member까지는 실제로 인서트하고 업데이트 쿼리를 실행하는 것을 확인했습니다.



[질문]

1. 강의에선 OrderItem 엔티티 자체에서 createOrderItem()을 처리해주기 때문에 OrderItem의 기본 생성자도 protected로 지정해서 막았는데, 글에서 적용한 방식으로 해결하려면 public으로 바꿔야 했습니다. 지금처럼 OrderItemRepository를 따로 만들어서 처리하는 게 구조상 문제가 없는 건지 리포지토리의 역할이 맞는지 모르겠고, 문제가 생길 수 있는지 궁금합니다.
(예측되는 문제점: 주문/주문 취소/상품 수정 등 stock_quantity와 얽혀있는 로직마다 쿼리를 작성해야 됨)

2. JPA의 기본적인 변경 감지 방식을 유지하면서 낙관적 락 없이도 간단하게 해결할 수 있는 방법이 있는지 알고 싶습니다.

답변 2

1

김영한님의 프로필 이미지
김영한
지식공유자

안녕하세요. CS님

문제를 잘 해결하셨네요 :)

이 문제를 해결하려면 다음 2단계로 되어 있는 것을 1단계로 줄이면 됩니다.

  1. 값을 읽는다.

  2. 값을 수정한다.

 

작성하신 업데이트 쿼리는 값을 읽는 것과 수정하는 것을 원자적으로 처리하기 때문에 이 경우 동시성 문제를 해결할 수 있습니다.

실제 실무에서도 단순히 update 쿼리로 동시성 문제가 해결되는 경우에는 update 문을 사용해서 동시성을 해결하는 경우가 종종 있습니다.

질문하신 것 처럼 리포지토리에서 update 쿼리가 발생하기 때문에 별도의 리포지토리에서 해당 로직을 호출하도록 작성하는 것이 맞다 생각합니다. 물론 걱정하신 것 처럼 상품의 수량이 변경될 때 마다 해당 리포지토리를 항상 잘 호출하도록 해주어야 합니다. 물론 이 부분은 트레이드 오프입니다. 도메인 로직에 있으면 로직이 더 깔끔해지고 관리 측면에서도 좋겠지만 동시성 문제를 update 쿼리로 해결해야 하기 때문에 해당 부분이 리포지토리에 있어야 합니다.

JPA의 기본적인 변경 방식을 유지하면서 이 문제를 해결할 수 는 없습니다. 동시성 문제와 관련해서 다음 내용들을 읽어보시면 도움이 되실거에요.

감사합니다.

CS님의 프로필 이미지
CS
질문자

상세한 답변 감사드립니다 영한님.

인프콘 영상에서 기술과 비즈니스의 조화, 추상화와 구체화 넘나들기 등 좋은 말씀 잘들었습니다^^

1

안녕하세요, 인프런 AI 인턴이에요.

주문 서비스에서 상품 수량(stock_quantity)을 감소시키는 작업은 동시에 여러 사용자가 요청할 경우 문제가 발생할 수 있습니다. 여러 스레드에서 동시에 상품 수량을 확인하고 감소시키는 경우 잘못된 결과가 나올 수 있습니다.

이런 경우 동시성 문제를 해결하기 위해 다음과 같은 방법들을 고려할 수 있습니다.

  1. 데이터베이스의 트랜잭션 격리 수준 설정: 트랜잭션 격리 수준을 높여서 여러 트랜잭션이 동시에 수행될 때 충돌이 일어나지 않도록 합니다. 일반적으로 데이터베이스의 기본 격리 수준은 READ COMMITTED이며, 이보다 높은 격리 수준인 REPEATABLE READ, SERIALIZABLE 등을 고려할 수 있습니다.
  2. 재고 관리 방식 변경: 상품 수량을 감소시키는 대신 재고가 부족한 경우 예외를 발생시키고, 예외를 처리하는 방식으로 동시성 문제를 해결할 수 있습니다. 예를 들어, 주문테이블과 재고테이블을 각각 다른 트랜잭션으로 처리하여, 재고가 충분하지 않을 경우 주문 트랜잭션을 롤백하는 것이 가능합니다.
  3. 잠금(Lock) 사용: 상품 수량을 감소시키는 작업을 한 번에 하나의 트랜잭션만 수행하도록 잠금을 사용하는 방법입니다. 예를 들어, SELECT…FOR UPDATE 문을 사용하여 상품을 조회하고 잠금을 걸어서 동시에 수정할 수 없도록 합니다.

하지만, 각 방법마다 장단점이 있으며, 상황에 따라 적절한 방법을 선택해야 합니다. 동시성 문제를 해결하려면 데이터베이스의 트랜잭션 격리 수준 설정이나 잠금 사용 등 전문적인 지식이 필요할 수 있으므로, 신중히 고려해야 해요.

CS님의 프로필 이미지
CS

작성한 질문수

질문하기