장바구니 미션 - 동시성, db락에 대해

ttomy·2023년 6월 29일
0

[https://github.com/hum02/jwp-shopping-cart/tree/step2](미션 github)

장바구니 주문의 동시성 문제?

장바구니 미션에서 장바구니의 상품을 주문하는 기능이 있다. 이때 여러 주문이 동시에 들어가면 문제가 생길 수 있을 것 같았다.

-주문 service코드

@Service
public class OrderService {
 //생략

    @Transactional
    public long order(Member member, OrderRequest orderRequest) {
        List<CartItem> cartItems = cartItemDao.findItemsByIds(orderRequest.getCartItemIds());
        Order order = Order.of(cartItems, member, orderRequest.getTotalPrice());

        Money deliveryFee = deliveryFeeCalculator.calculate(order);

        Money discountedMoney = applyDiscounting(orderRequest, order);

        Order payed = payService.payOrder(order, deliveryFee, discountedMoney, member.getId());

        cartItemDao.deleteByIds(orderRequest.getCartItemIds());

        couponService.addCouponDependsOnPay(member, payed);
        return orderDao.save(payed, discountedMoney);
    }

예상되는 문제상황은

1) 예산금액을 초과해 결제

2) 재고 수량은 넘어서 주문

3) 이미 주문한 장바구니 상품을 삭제하기 전에 또 주문
등의 상황이 우려된다.

이는 spring의 thread들이 동시적으로 db에 접속할 때,
어떤 스레드가 주문정보를 읽어서 수정하려는 사이 다른 스레드가 주문을 완료해 commit하면서 생길 수 있는 write skew문제라고 보인다.

write skew문제
쓰기 스큐(write skew)란 트랜잭션이 읽은 값을 기반으로 특정 레코드의 수정을 시도하지만, UPDATE를 실행하는 시점에 읽은 값과 이전에 읽었던 상태가 서로 달라 잘못된 결과를 나타낼 수 있는 경우다.

위와 같은 문제가 정말로 발생할까?
동시에 주문요청을 보내서 테스트해보자.

테스트

- 테스트 코드
@ActiveProfiles("test")
@Sql("/schema.sql")
@Sql("/data.sql")
@SpringBootTest
public class OrderTransactionTest {

    public static final int TOTAL_ORDER_COUNT = 5;

    @Autowired
    OrderService orderService;

    @Autowired
    MemberDao memberDao;

    @Autowired
    OrderDao orderDao;

    @Test
    void orderWithoutLock() throws InterruptedException {
        //given
        CountDownLatch countDownLatch = new CountDownLatch(TOTAL_ORDER_COUNT);
        List<OrderWithoutLockWorker> workers = new ArrayList<>();
        for (int i = 0; i < TOTAL_ORDER_COUNT; i++) {
            workers.add(new OrderWithoutLockWorker(countDownLatch));
        }

        //when
        workers.forEach(worker -> new Thread(worker).start());
        countDownLatch.await();

        //then
        List<OrderDto> orders = orderDao.findByMemberId(1L);
        Assertions.assertThat(orders.size()).isEqualTo(1);
    }

    private class OrderWithoutLockWorker implements Runnable {

        private CountDownLatch countDownLatch;

        public OrderWithoutLockWorker(CountDownLatch countDownLatch) {
            this.countDownLatch = countDownLatch;
        }

        @Override
        public void run() {
            Member member = memberDao.getMemberById(1L);
            OrderRequest orderRequest = new OrderRequest(List.of(1L, 2L), 103000, null);
            orderService.order(member, orderRequest);
            countDownLatch.countDown();
        }
    }
}

service에 @Transactional만 걸었을 경우

  • 결과

위에서 우려했던 3)의 상황이 일어났다.
주문된 상품은 장바구니에서 삭제된다.
(Orderservice 코드의 Order메서드에서 cartItem을 삭제하고 있다)
정책 상 장바구니의 상품은 오직 한번만 주문될 수 있어야 하지만 동시에 주문하니 스레드 수인 5번이나 주문되어버렸다.

이 문제를 해결하기 위해 어떻게 해야할까?

고려할만한 방법들

  1. java synchronized,volatile,atomic 활용
  2. DB트랜잭션 격리(@transactional의 isolation옵션)
  3. DB lock걸기(낙관적락, 비관적락, x락,s락...)
  4. 메시징큐로 순차성을 부여
    등의 방법이 있을 듯하다.

여기서 1,2,3의 방법에 대해서 생각해보겠다.

synchronized

order()에 synchronized를 붙이면 해당 메서드가 수행 중이면 다른 스레드에서 이 메서드를 호출하지 못한다.
다만 이렇게 하면 서로 다른 장바구니 아이템인데도 불구하고 다른 스레드에 의해 막힐 경우가 있어 성능을 제한시킨다.
과하게 상호배제를 하고 있다고 느껴진다.

또한 synchronized는 하나의 프로세스 안에서만 보장되기에 여러 서버에서
이를 서비스할 경우 race condition이 발생할 수 있다.

transaction isolcation옵션

@transactional의 isolation레벨
read Uncommited: 거의 격리되지 않음. 다른 tx의 커밋도 전에 읽어갈 수 있음.(dirty read) 롤백되어도 읽어간 거 그대로 씀

readCommited: 커밋한 것만 읽어가게 한다. 다만 tx 중간에 다른 tx이 커밋한 경우 같은 읽기 행위임에도 다른 데이터를 읽어올 수도 있는 문제 발생(UNREAPABLE_READ)

REPEATABLE_READ : 자신의 트랜잭션이 생성되기 이전의 트랜잭션(낮은 번호의 트랜잭션)의 커밋된 데이터만 읽는다 -> mysql의 디폴트 격리수준
phantom read문제? innoDB에서의 해결?

serializable :가장 단순하고 가장 엄격한 격리수준.
InnoDB에서 기본적으로 순수한 SELECT 작업은 아무런 잠금을 걸지않고 동작한다. 하지만 격리수준이 SERIALIZABLE일 경우 읽기 작업에도 공유 잠금을 설정한다.
이러한 특성 때문에 동시처리 능력이 다른 격리수준보다 떨어지고, 성능저하가 발생하게 된다.

하지만 이래도 읽은 시점의 정보는 동일하기에, write skew문제는 일어날 듯하다. 내 생각은 읽기 락까지 걸어야겠다는 결론이다.

isolation level이 Repeatable_read일 때까지는 똑같은 중복주문 문제가 발생할 듯하고, serializable수준일 경우에는 해결될 것이라 생각한다. 테스트 해보자.

@Transactional(isolation = Isolation.REPEATABLE_READ)일 경우

예상과 조금 다르게 실패했다.
deadlock이 걸려서 트랜잭션이 멈춘다.

각 스레드에서 동시에 orderService.order 메서드를 호출하면서 동시에 member 데이터를 가져와서 업데이트하려고 합니다. 하지만 REPEATABLE_READ 격리 수준에서는 트랜잭션이 시작되었을 때 읽은 데이터를 트랜잭션이 종료될 때까지 변경되지 않는 것으로 간주합니다.
따라서, 여러 스레드가 동시에 동일한 데이터를 읽고 업데이트하려고 할 때 충돌이 발생하고 데드락이 발생하는 것입니다. 데드락이 발생하면 H2 데이터베이스는 현재 트랜잭션을 롤백시키고 해당 오류를 반환합니다.

  • serializable일 경우

하지만 mysql은 보통의 격리수준에서 select시 s락을 걸지 않는다. serializable수준으로 격리를 할 때만 s락을 건다.(https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html)

serializable일 경우에는 s락이 걸린 상태에서 x락을 획득하려 하니 데드락이 발생할 것이다. (https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html)
해결을 위해 쿼리롤 select ~ for update로 x락을 걸도록 하면 적어도 다른 트랜잭션에서 읽어갈 때부터 락이 걸려있어 수량/금액으로 인한 문제는 발생하지 않을 듯하다. 이러면 동시적으로 요청을 처리하는 성능은 떨어질 수 있다. 하지만 mysql은 row마다 lock거니 감수할 정도이지 않을까.
-유의점: select할 때 쿼리되는 게 없으면 모든 터플에 락을 걸어버리나? 아예 락을 걸지 않나?

@transactional의 범위
class에 붙은 게 우선, 메서드에서 메서드를 호출하면 프록시로 감싸지 못하므로 트랜잭션 적용안될 수 있다.

reaonly가 하는 일?
mysql에서는 읽기 전용이므로 트랜잭션에 id를 부여하지 않아 성능 상승
스토리지 엔진인 innoDB에서 읽기 전용이면 쿼리 최적화 하기도 한다.

reference

https://velog.io/@znftm97/%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-V1-%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BDOptimisitc-Lock-feat.%EB%8D%B0%EB%93%9C%EB%9D%BD-%EC%B2%AB-%EB%A7%8C%EB%82%A8

https://yeonyeon.tistory.com/291

0개의 댓글