생각정리/Spring

[JPA] 동시성 제어 테스트 코드 (H2)

생각중임 2024. 4. 30. 20:23

낙관적 락

JPA가 제공하는 낙관적 락은 버전을 이용해서 데이터를 구분하는데, 엔티티에 @version 필드를 만들어 커밋시점에 다를 경우 예외를 발생시켜서 충돌 시 재시도 처리를 해줄 수 있습니다.

서비스

재고 감소를 하고 커밋 때, 상품의 버전이 달라 충돌이 일어나면, 재시도 횟수를 설정해 두고 재시도 횟수 안에서 상품 처리 다시 해준다. 락 획득 시 원활한 재시도를 위해 잠깐의 텀을 주도록 한다.

@Transactional
public Long order(Long memberId, Long itemId, int count) {
    // 엔티티 조회
    Member member = memberRepository.findOne(memberId);

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

    int retryCount = 0;
    int maxRetries = 10;
    Order order = null;
    while (retryCount < maxRetries) {
        try {
            Item item = itemRepository.findOne2(itemId);

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

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

            // 주문 저장
            orderRepository.save(order);

            break;
        } catch (OptimisticLockingFailureException e) {
            retryCount++;
            if (retryCount >= maxRetries) {
                System.out.println("재시도 횟수 초과");
                throw e;
            }
            System.out.println("락획득 재시도 합니다.");
            try {
                Thread.sleep(100);
            } catch (InterruptedException ex) {
                throw new RuntimeException(ex);
            }
        }
    }

    return order.getId();
}

주문을 생성하면서 재고를 감소킵니다.

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;
}

락 적용

낙관적 락을 사용할 엔티티에 @Version 추가하고, 조회 시점부터 트랜잭션이 끝날 때 까지 다른 트랜잭션에 의해 변경되지 않도록 해준다.

public abstract class Item {
    ...

    private int stockQuantity;

    @Version
    private Integer version;
}
public Item findOne(Long id) {
    return em.find(Item.class, id, LockModeType.OPTIMISTIC);
}

테스트 코드

@Test
@Rollback(value = false)
@DisplayName("동시성 제어 - 낙관적 락")
public void concurrency2() throws InterruptedException {
    // given
    int orderCount = 1;

    int threadCount = 20;
    ExecutorService executorService = Executors.newFixedThreadPool(5);
    CountDownLatch latch = new CountDownLatch(threadCount);

    // when
    AtomicInteger retryCount = new AtomicInteger();
    int maxRetries = 10;
    for (int i = 0; i < threadCount; i++) {
        executorService.execute(() -> {
            while (retryCount.get() < maxRetries) {
                try {
                    orderService.order2(1L, 1L, orderCount);

                    latch.countDown();

                    break;
                } catch (OptimisticLockingFailureException e) {
                    retryCount.getAndIncrement();
                    if (retryCount.get() >= maxRetries) {
                        System.out.println("재시도 횟수 초과");
                        throw e;
                    }
                    System.out.println("락획득 재시도 합니다.");
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException ex) {
                        throw new RuntimeException(ex);
                    }
                }
            }
        });
    }

    latch.await();
    executorService.shutdown();

    // then
    Item result = itemService.findOne(1L);
    assertEquals(result.getStockQuantity(), 80);
}

비관적 락

상품 조회 시 비관적 락을 이용해서 데이터베이스의 해당 데이터에 락을 걸고 다른 트랜잭션에서 수정을 할 수 없도록 만들어 동시성을 제어합니다.

서비스

엔티티 조회 시점에서 락을 걸고 주문 상품 생성단계에서 상품의 재고를 감소시킵니다.

@Transactional
public Long order(Long memberId, Long itemId, int count) {
    // 엔티티 조회
    Member member = memberRepository.findOne(memberId);
    Item item = itemRepository.findOne(itemId);

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

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

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

    // 주문 저장
    orderRepository.save(order);

    return order.getId();
}

락 적용

데이터베이스의 select for update를 사용해서 쓰기 락을 걸어 수정단계에서 동시성 문제를 해결합니다.

락을 획득까지 트랜잭션의 대기시간을 설정해 주고 대기시간 동안 락을 획득하지 못하면 예외를 방생시킵니다.

public Item findOne(Long id) {
    Map<String, Object> properties = new HashMap<>();
    properties.put("jakarta.persistence.lock.timeout", 10000);

    return em.find(Item.class, id, LockModeType.PESSIMISTIC_WRITE, properties);
}

테스트 코드

상품 수량 100개에서 20개를 구매해 재고가 80개가 되도록 테스트합니다.

@Test
@Rollback(value = false)
@DisplayName("동시성 제어 - 비관적 락")
public void concurrency() throws InterruptedException {
    // given
    int orderCount = 1;

    int threadCount = 20;
    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);

    // when
    for (int i = 0; i < threadCount; i++) {

        executorService.submit(() -> {
            try {
                orderService.order(1L, 1L, orderCount);
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();
    executorService.shutdown();

    // then
    Item result = itemService.findOne(1L);
    assertEquals(result.getStockQuantity(), 80);
}

진행 중 발생한 문제

1. 테스트 코드에서 낙관적 락 충돌 오류

낙관적 락을 사용하고 서비스에서 락 충돌 시에 처리 로직을 추가했는데도 테스트 코드에서 계속 충돌 예외로 인해서 문제가 발생합니다.

로그로 확인을 해보니 서비스단에서 예외가 캐치가 되지 않는 것을 확인했고, 어디에서 예외가 발생하는가를 보니 테스트 코드가 끝나면서 예외 오류가 발생했습니다.

테스트 코드에서는 끝날 때까지 실제 DB에 커밋되기 때문에 테스트 코드가 끝나면서 락 충돌이 발생하면서 예외 처리를 할 수 없었던 것, 그래서 서비스에 있는 락 충돌 처리를 테스트 코드에 적용을 시켜 재시도 처리를 해서 테스트를 성공할 수 있었습니다.

2. 테스트 코드에서 유저와 상품 데이터 추가 시 로직 실행 문제

데이터를 테스트 코드 안에서 넣거나, BeforeEach를 이용해서 유저와 상품 데이터를 생성하면 OrderService.order()에서 엔티티 조회 시 엔티티 조회에 데이터가 nullpointException 오류가 발생합니다.

임시로 테스트를 멀티쓰레드를 제외한 테스트를 이용해 무슨 문제인지 확인을 해보았습니다.

@BeforeEach
public void before() {
    Member member = createMember();
    Book book = getBook("시골 JPA", 10000, 101);

    System.out.println("BeforeEach 종료");
}

@Test
@DisplayName("동시성 제어 - 비관적 락")
public void concurrency() throws InterruptedException {
    System.out.println("test 시작");
    // given
    int orderCount = 1;

    int threadCount = 20;
    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);

    List<Member> members = memberService.findMembers();
    for (Member member : members) {
        System.out.println("member.getName() = " + member.getName());
    }

    // then
    System.out.println("test 종료");
}

테스트 코드 안에서 혹은 BeforeEach에서 데이터를 넣을 경우, 테스트가 끝나거나 다른 트랜잭션을 사용하기 전까지 데이터 자체가 insert가 안 되는 걸 볼 수 있었습니다.

BeforeEach의 트랜잭션은 실행 중인 테스트코드의 트랜잭션과 동일한 것을 알 수 있습니다.

원인 확인해 보면 메인쓰레드의 동일한 트랜잭션에서 데이터가 생성이 되고 쓰레드풀로 생성된 다른 스레드에서는 아직 트랜잭션이 커밋되지 않았기 때문에 해당 데이터를 불러올 수 없어 문제가 발생하는 것으로 보입니다.

해당 문제는 데이터를 테스트 코드에서 입력하는 것이 아닌, 실행시 넣을 수 있는 sql.schame 혹은 initService를 만들어 테스트 데이터를 넣는 방식으로 해결을 하였습니다.

정리

  1. BeforeEach의 트랜잭션은 실행중인 테스트코드의 트랜잭션과 동일하다.
  2. 테스트코드가 끝날 때까지 데이터가 실제 DB에 커밋되지 않는다.
  3. 2번의 이유로 메인쓰레드에서 insert 한 데이터는 새롭게 생성한 쓰레드에서 조회가 되지 않는다.
  4. 테스트 데이터는 사전에 입력해 두자.