본문 바로가기
Spring

낙관적 락과 동시성 테스트하기

by hseong 2023. 9. 16.

개요

여러 명의 라이더가 하나의 배달에 대해 동시에 배차 요청을 하는 상황이 있을 것입니다.

라이더A배달에 대해 배차 요청을 하고, 동시에 라이더B배달에 대해 배차 요청이 들어오게 된다면 다음과 같은 상황이 발생할 것입니다.

이 경우 라이더A배달에 대해 배차 요청을 하고 요청이 성공했다는 응답까지 받게 됩니다. 그러나 동시에 들어온 라이더B의 배차 요청으로 인해 라이더A의 갱신 내역은 사라지고 라이더B의 갱신 내역이 저장되게 됩니다. 이를 두 번의 갱신 분실 문제라 합니다.

이를 해결하기 위해서는 다음과 같은 방법이 있습니다.

  • 마지막 커밋만 인정하기
  • 최초 커밋만 인정하기
  • 충돌하는 갱신 내용 병합하기

기본적으로는 마지막 커밋만 인정하기가 사용됩니다. 하지만 배차 요청의 경우 마지막 커밋만 인정해서는 라이더 입장에서 어이없는 상황이 발생할 것입니다. 따라서 이 상황에서는 최초 커밋만 인정하는 것이 훨씬 합리적입니다.

저는 이 상황을 해결하기 위해 JPA가 제공하는 낙관적 락을 이용하였습니다. 본 게시글에서는 프로젝트에 JPA의 낙관적 락을 적용하게 된 과정과 이를 적용했을 때의 테스트 코드 작성 방법에 대해 다룹니다.

본격적으로 락을 적용하기에 앞서 락을 적용하지 않은 코드를 실행시키고 JMeter를 이용한 테스트를 돌려보면서 실제로 다수의 라이더가 한번에 배차 요청을 하는 경우 정말로 갱신 분실이 발생하는지 알아보겠습니다.

코드

다음 코드는 로그인한 라이더와 배차 요청한 배달을 저장소에서 불러온 뒤, 불러온 배달라이더 배정을 호출하면서 인자로 불러온 라이더를 넘겨줍니다.

배달이미 라이더가 배정되었는지 검증을 진행한 뒤 배정되었으면 예외를 던지고 아니면 파라미터로 전달된 라이더와 연관관계를 맺으면서 라이더의 배차 요청이 종료됩니다.

예외를 던지는 경우 컨트롤러 단에서 상태 코드가 409인 응답을, 정상이라면 204 응답을 반환할 것입니다.

DeliveryService.java

@Transactional
public void acceptDelivery(AcceptDeliveryCommand acceptDeliveryCommand) {
    Rider rider = findRiderByRiderId(acceptDeliveryCommand.riderId());
    Delivery delivery = findDeliveryByDeliveryId(acceptDeliveryCommand);
    delivery.assignRider(rider);
}

private Rider findRiderByRiderId(final Long riderId) {
    return riderRepository.findById(riderId)
        .orElseThrow(() -> new NotFoundRiderException("존재하지 않는 라이더입니다."));
}

private Delivery findDeliveryByDeliveryId(final Long deliveryId) {
    return deliveryRepository.findById(deliveryId)
        .orElseThrow(() -> new NotFoundDeliveryException("존재하지 않는 배달입니다."));
}

Delivery.java

public void assignRider(Rider rider) {
    checkAlreadyAssignedToRider();
    this.rider = rider;
}

private void checkAlreadyAssignedToRider() {
    if (Objects.nonNull(this.rider)) {
        throw new AlreadyAssignedDeliveryException("이미 배차 완료된 배달입니다.");
    }
}

이제 해당 코드를 실제로 실행시켰을 때 어떤 상황이 벌어지는지 눈으로 직접 확인해보겠습니다.

JMeter

1초 동안 100명의 라이더가 배차 대기 중인 배달 목록을 조회하고 동일한 배달에 대해서 배차 요청을 한다고 가정을 해보겠습니다.

테스트는 맥북 m1 16gb의 로컬 환경에서 진행하며 데이터베이스는 MySQL을 사용합니다. 테스트를 위해 라이더배달 데이터를 만들어두었습니다.

1초 동안 이루어진 100번의 요청 중 10번이 204 응답을 반환하며 성공하였고, 나머지 90번은 409 응답을 반환하며 실패하였습니다.

한 명의 라이더가 배달에 대해 배차를 받은 상태라면 다른 라이더의 요청은 실패해야합니다. 그러나 10명의 라이더가 한 요청이 성공적으로 처리되었고 이 중 9명의 요청은 정상 요청임에도 분실되었습니다. 이를 해결하기 위해 락을 사용할 필요가 있습니다.

낙관적 락

1. @Version

JPA가 제공하는 낙관적 락을 사용하기 위해서는 Delivery@Version 어노테이션을 사용해서 버전 관리 기능을 추가해주어야 합니다.

Delivery.java

@Version
private Long version;

이는 다음과 같은 기능을 합니다.

  • 엔티티를 수정 할 때마다 버전이 하나씩 증가
  • 엔티티 수정 시점에 조회 시점의 버전과 수정 시점의 버전이 다르면 예외 발생

이를 이용하면 다음과 하나의 트랜잭션이 커밋되면 해당 데이터의 버전 정보가 증가합니다. 그리고 다른 하나의 트랜잭션은 커밋하는 순간 엔티티 조회 시점의 버전 정보와 데이터베이스의 버전 정보가 다르므로 예외가 발생하게 됩니다. 따라서 갱신 분실이 아닌 최초 커밋만 인정하기가 적용됩니다.

2. OPTIMISTIC

@Version만 적용했을 때는 엔티티를 수정해야 버전을 체크하지만 이 옵션을 추가하면 엔티티를 조회만 해도 버전을 체크합니다. 한 번 조회한 엔티티는 트랜잭션을 종료할 때까지 다른 트랜잭션에서 변경하지 않음을 보장합니다.

이는 트랜잭션 커밋 시점에 버전 정보를 조회하여 현재 엔티티의 버전과 같은지 검증합니다.

다음과 같이 쿼리 메서드나 @Query와 함께 사용할 수 있습니다.

@Lock(LockModeType.OPTIMISTIC)
@Query("select d from Delivery d where d.deliveryId = :deliveryId")
Optional<Delivery> findByIdOptimistic(@Param("deliveryId") Long deliveryId);

비관적 락

JPA의 비관적 락은 데이터베이스 트랜잭션 락 메커니즘에 의존하는 방법입니다. 이는 다음과 같은 특징을 가집니다.

  • 엔티티뿐만 아니라 스칼라 타입을 조회할 때도 사용할 수 있다.
  • 데이터 수정 즉시 트랜잭션 충돌을 감지할 수 있다.

사용 방법은 다음과 같이 조회 쿼리에 락 모드 타입을 명시해주면 됩니다. @Version은 사용할 필요가 없습니다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select d from Delivery d where d.deliveryId = :deliveryId")
Optional<Delivery> findByIdPessimistic(@Param("deliveryId") Long deliveryId);

비교

낙관적 락은 트랜잭션 대부분은 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법입니다. 반면 비관적 락은 트랜잭션의 충돌이 발생한다고 가정하고 우선 락을 걸고 보는 방법입니다.

한 번 두 옵션을 모두 사용해서 테스트를 돌려보겠습니다. 각각 동일한 배달에 대해 1초동안 100건의 요청이 이루어지는 상황을 5번에 걸쳐서 진행해보았습니다.

1. 낙관적 락

2. 비관적 락

로컬 환경의 테스트이기는 하나 낙관적 락이 응답 시간이 조금 더 짧은 것을 확인할 수 있습니다. 따라서 배차 요청에 한해서는 @Version을 사용하는 낙관적 락을 사용하도록 하겠습니다.

지금까지는 외부 도구를 사용하여 동시성 테스트를 진행하였습니다. 하지만 동시성 처리가 제대로 이루어졌는지에 대해 항상 이러한 방식으로 테스트를 진행하기에는 시간이 너무 오래 걸립니다. 이제부터는 코드를 작성하여 동시성 테스트를 진행하는 방법에 대해 알아보겠습니다.

Executor, ExecutorService, CountDownLatch

우선 동시성에 대한 테스트를 진행하기 전, 테스트 코드 작성에 필요한 java.util.concurrent 패키지의 Executor, ExecutorService, CountDownLatch에 대해 간단하게 알아보겠습니다.

1. Executor

public interface Executor {

    void execute(Runnable command);
}

  • Runnable 작업을 실행하는 객체입니다.
  • 일반적으로 쓰레드를 명시적으로 생성하는 대신 Executor를 이용할 수 있습니다.
  • concurrent 패키지는 Executor를 상속하는 더 넓은 범위은 인터페이스인 ExecutorService를 제공합니다.

2. ExecutorService

public interface ExecutorService extends Executor {
    ...
    Future<?> submit(Runnable task);
        ...
}

  • 메서드의 종료를 관리하고 하나 이상의 비동기 작업을 추적하기 위한 Future를 제공하는 Executor입니다.
  • submit 메서드는 실행을 취소하거나 완료되기를 기다리는데 사용할 수 있는 Future를 생성하고 반환함으로써 베이스 메서드인 execute(Runnable)을 확장합니다.
  • 팩토리 메서드를 제공하는 Executors 클래스를 통해 생성할 수 있습니다.

3. CountDownLatch

public class CountDownLatch {

    private static final class Sync extends AbstractQueuedSynchronizer {
        private static final long serialVersionUID = 4982264981922014374L;

        Sync(int count) {
            setState(count);
        }

        int getCount() {
            return getState();
        }

        protected int tryAcquireShared(int acquires) {
            return (getState() == 0) ? 1 : -1;
        }

        protected boolean tryReleaseShared(int releases) {
            // Decrement count; signal when transition to zero
            for (;;) {
                int c = getState();
                if (c == 0)
                    return false;
                int nextc = c - 1;
                if (compareAndSetState(c, nextc))
                    return nextc == 0;
            }
        }
    }

    private final Sync sync;

    public CountDownLatch(int count) {
        if (count < 0) throw new IllegalArgumentException("count < 0");
        this.sync = new Sync(count);
    }

    public void await() throws InterruptedException {
        sync.acquireSharedInterruptibly(1);
    }

    public boolean await(long timeout, TimeUnit unit)
        throws InterruptedException {
        return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
    }

    public void countDown() {
        sync.releaseShared(1);
    }

    ...
}

  • 하나 이상의 쓰레드가 다른 쓰레드에서 수행 중인 작업이 완료될 때까지 대기할 수 있도록 하는 동기화 보조 기능(synchronization aid)입니다.
  • 생성자를 통하여 인자로 전달된 카운트로 초기화됩니다.
  • countDown() 메서드를 호출하여 카운트를 카운트를 감소시킬 수 있습니다.
  • await() 메서드를 호출하면 주어진 카운트가 0이 될 때까지 차단(block)됩니다. 카운트가 0이 되면 모든 대기 쓰레드가 해제(release)되고 이후에 이루어지는 모든 await() 호출이 즉시 반환됩니다.

테스트 코드

4명의 라이더가 하나의 배달에 대해 배차 요청을 한다고 가정하였습니다. 따라서 쓰레드 풀 사이즈를 4로 설정하고 count 역시 4로 설정해주었습니다.

업데이트에 실패한 쓰레드는 스프링이 추상화한 예외인 ObjectOptimisticLockingFailureException을 던지게 됩니다. 쓰레드가 실행한 작업이 메서드가 종료되면 countDown을 호출하여 카운트를 감소시켜 주어야 하기 때문에 테스트 대상 메서드는 try-finally로 감싸주겠습니다.

낙관적 락이 성공적으로 동작하였다면 Deliveryversion 필드는 0에서 1로 업데이트 되었을 것입니다. 따라서 해당 값에 대한 검증을 수행해줍니다.

@SpringBootTest
public class DeliveryIntegrationTest {

        // 테스트를 위한 리포지토리 @AutoWired

    @Autowired
    DeliveryService deliveryService;

    @Nested
    @DisplayName("acceptDelivery 메서드 실행 시")
    class AcceptDeliveryTest {

        ExecutorService service;
        CountDownLatch latch;

        // 테스트를 위한 각종 엔티티 create and save

        @BeforeEach
        void setUpConcurrent() {
            service = Executors.newFixedThreadPool(4); // 쓰레드 풀 사이즈를 4로 설정
            latch = new CountDownLatch(4); // 카운트를 4로 설정
        }

          ...

        @Test
        @DisplayName("성공: 여러 명의 라이더 중 한 명만 성공")
        void success() throws InterruptedException {
            //given
            Delivery targetDelivery = createAndSaveDelivery();
            List<Rider> riders = createAndSaveRiders(4); // 카운트에 맞게 4명의 라이더를 생성

            //when
            for (int i = 0; i < 4; i++) {
                Rider rider = riders.get(i);
                service.execute(() -> { // 전달된 Runnable을 실행
                    AcceptDeliveryCommand command = AcceptDeliveryCommand.of(
                        targetDelivery.getDeliveryId(),
                                                rider.getRiderId());
                    try { // 낙관적 락 예외 발생 시 countDown을 호출하기 위한 try-finally
                        deliveryService.acceptDelivery(command);
                    } finally {
                        latch.countDown(); // 카운트 1씩 감소
                    }
                });
            }
            latch.await(); // 카운트가 0이 될 때까지 block

            //then
            Delivery findDelivery
                = deliveryRepository.findById(targetDelivery.getDeliveryId()).get();
            assertThat(findDelivery.getVersion()).isEqualTo(1); // 버전을 검증
        }
    }
}

테스트를 실행시키면 version이 1로 업데이트 되어 동시성 제어가 잘 이루어지고 있음을 확인할 수 있습니다.