Coding/사이드 플젝

[뚝배기] 동시성 제어 (비관적 락, 낙관적 락)

kangplay 2025. 4. 27. 22:31

주문 관련 API들을 모두 개발한 뒤, "과연 이 시스템이 실제 트래픽 상황에서도 안전할까?"라는 고민이 들었다. 특히 주문은 실시간성이 중요한 영역이라, 동시성(Concurrency) 문제가 반드시 고려되어야 한다.

그래서 지금까지 구현한 주문 API를 정리해보고, 각각 어떤 동시성 이슈가 발생할 수 있는지 고민해봤다.

동시성 상황

  • 주문 요청 API
    • 문제 상황: 동일한 주문이 여러 번 중복 요청되는 경우 
    • 필요한 제어: 중복 주문 방지. 한 번만 주문이 정상 처리되어야 한다.
  • 주문 내역 조회 API
    • 문제 상황: (주요 동시성 이슈는 없음)
    • 정리: 조회는 읽기 전용 작업이기 때문에 심각한 동시성 문제는 발생하지 않는다. 또한, MySQL의 기본 격리 수준인 REPEATABLE READ에서는 NON-REPEATABLE READ가 발생하지 않고, 일반 조회(잠금 없이 수행된 경우)에서도 팬텀 리드로 인한 일관성 문제가 발생하지 않는다. (잠금이 걸리는 경우에도 InnoDB의 Gap Lock이 이를 대부분 자동으로 방지해준다.) 따라서 조회 트랜잭션에서는 일관성 위반에 대해 크게 신경 쓸 필요가 없다.
  • 주문 취소 API (일반 회원)
    • 문제 상황: 사용자가 주문을 취소하려고 할 때, 다른 프로세스(사장님 등)가 주문 상태를 ACCEPTED나 REJECTED로 먼저 바꿔버릴 수 있다.
    • 필요한 제어: WAITING 상태인 경우에만 취소할 수 있도록 강력한 락이나 체크가 필요하다. 이미 상태가 변경된 주문을 다시 취소하는 걸 막아야 한다.
  • 주문 상태 변경 API (사장님)
    • 문제 상황: 사장님이 주문 상태를 바꿀 때, 사용자가 동시에 주문을 취소(REJECTED)했을 수 있다.
    • 필요한 제어: CANCELED, REJECTED로 넘어간 주문은 절대 상태 변경이 안 되게 해야 한다.

동시성 제어 - 주문 요청 API

주문 요청은 동일한 주문을 중복 생성하면 안된다. 따라서, idempotency key 사용하여 동시성 제어를 한다. 요청할 때 고유한 requestId를 바디로 같이 보내면, 서버는 이 requestId 저장하고 있다가 이미 처리된 요청은 무시하면 된다. 

 

즉, 주문 도메인에 requestId를 추가해야한다!

@Column(name = "request_id", nullable = false, unique = true)
private String requestId;

 

동시성 문제 테스트

중복 주문에 대한 테스트코드는 다음과 같다. 

@Test
@DisplayName("동일한 requestId로 주문을 생성하면 예외가 발생한다")
void createOrder_fail_dueToDuplicateRequestId() {
    // given
    OrderCreateRequestDto request = new OrderCreateRequestDto(
            List.of(
                    new OrderCreateRequestDto.MenuOrderDto(1L, 1),
                    new OrderCreateRequestDto.MenuOrderDto(2L, 1)
            ),
            REQUEST_COMMENT,
            uuid
    );

	// ** 중복
    given(orderRepository.existsByRequestId(uuid)).willReturn(true);

    // when & then
    assertThatThrownBy(() -> orderService.createOrder(request, user.getId()))
            .isInstanceOf(BusinessException.class)
            .hasMessage(DUPLICATE_REQUEST_ID.getDefaultMessage());
}

 

하지만 이 테스트코드로는 부족하다. 만약 트랜잭션 1이 주문 요청 과정에서, 트랜잭션 2가 동시에 주문을 요청하면, 둘 다 request_id는 없다고 판단하기 때문에 (둘 다 commit 전) 중복된 주문이 생성될 수 있다.

 

📍 REPEATABLE READ는 내가 읽은 "기존 데이터"가 바뀌지 않도록 보장해준다. (Non-Repeatable Read, Phantom Read 방지) 하지만 조회 결과 자체가 없음(null) 이었으면? → 그 "없음(null)" 자체는 보호해주지 않는다.

 

@Test
    @DisplayName("동일한 requestId로 동시에 주문 생성 시 둘 다 생성될 수 있는 문제를 테스트한다")
    void createOrder_duplicateRequestId_concurrent() throws InterruptedException {
        // given
        OrderCreateRequestDto request = new OrderCreateRequestDto(
                List.of(
                        new OrderCreateRequestDto.MenuOrderDto(menu.getId(), 1)
                ),
                REQUEST_COMMENT,
                uuid
        );

        // when
        for (int i = 0; i < threadCount; i++) {
            executorService.execute(() -> {
                try {
                    orderService.createOrder(request, user.getId());
                } catch (Exception e) {
                    System.err.println("주문 생성 실패: " + e.getMessage());
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();

        //then
        long reservationCount = orderRepository.count();
        assertThat(reservationCount).isEqualTo(1);
    }

아래와 같이 중복 주문 예외 처리는 모두 통과하고, 주문 생성이 시작되는 것을 알 수 있다.

근데 테스트가 통과한다. 왜일까?

SELECT는 MVCC가 적용되어 다른 트랜잭션의 삽입을 못 본다. (Phantom Read) 하지만 유니크 인덱스가 걸린 row를 insert 할 때에는 커밋 여부와 상관 없이 Unique 인덱스 레벨에서 강제 체크하여 막힌다!!

-> 실제로, requestId 필드에 unique 속성을 제거하였더니, 동시성 테스트에서 실패했다.

⚠️ REPEATABLE READ는 내가 읽은 데이터의 일관성만 보장해주지, 읽은 후 다른 트랜잭션이 데이터를 추가/수정하는 걸 막지는 못한다. 특히 유니크 키가 없는 경우, REPEATABLE READ가 막지 못한다.

동시성 제어 - 주문 취소 API + 주문 상태 변경 API

주문을 취소하거나 상태를 변경할 때, 먼저 해당 주문이 현재 변경 가능한 상태인지 확인한다.
하지만 이 과정에서 다른 트랜잭션이 동시에 접근해 주문 상태를 ACCEPTED, REJECTED, 또는 CANCELED로 바꿔버릴 수 있다.

이렇게 되면:

  • 나는 여전히 "변경 가능한 상태"라고 믿고
  • 실제로는 이미 바뀐 주문을 다시 변경 시도하게 된다

결과적으로 데이터 정합성이 깨지는 문제가 발생한다. 따라서, 주문 취소 및 상태 변경 API에도 동시성 제어가 반드시 필요하다.

동시성 문제 테스트

문제를 명확히 확인하기 위해 테스트 코드를 작성했다.

@Test
    @DisplayName("동시에 주문 상태 변경 시 하나만 성공한다.")
    void changeOrderStatus_concurrencyIssue() throws InterruptedException {
        // when
        for (int i = 0; i < threadCount/2; i++) {
            executorService.execute(() -> {
                try {
                    orderService.cancelOrder(order.getId(), user.getId());
                    count.incrementAndGet();
                    System.out.println("주문 취소 성공");
                } catch (Exception e) {
                    System.err.println("주문 취소 실패: " + e.getMessage());
                } finally {
                    latch.countDown();
                }
            });
        }

        for (int i = 0; i < threadCount/2; i++) {
            executorService.execute(() -> {
                try {
                    orderService.updateOrderStatus(order.getId(), OrderStatus.ACCEPTED, owner.getId());
                    count.incrementAndGet();
                    System.out.println("주문 상태 변경 성공");
                } catch (Exception e) {
                    System.err.println("주문 상태 변경 실패: " + e.getMessage());
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();

        // then
        assertThat(count.get()).isEqualTo(1);
    }
☝️ 여기서 잠깐: int와 AtomicInterger 차이(feat. Race Condition)

  int count AtomicInteger count
연산 방식 단순 읽고 더하고 저장 (3단계) CAS 기반 원자적 연산 (한방에)
스레드 안전성 ❌ 아님 ✅ 완벽하게 보장
멀티스레드 환경 사용 하면 안 됨 적극 사용 가능

실행 결과, 테스트가 실패한다.

동시성 제어 방법: 비관적 락 적용

이 문제를 해결하기 위해 먼저, 비관적 락(Pessimistic Locking) 을 적용했다.
구체적으로, 주문 엔티티를 조회할 때 SELECT ... FOR UPDATE 쿼리를 사용했다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "5000"))
@Query("SELECT o FROM Order o JOIN FETCH o.store WHERE o.id = :orderId")
Optional<Order> findByIdWithStoreForUpdate(@Param(value = "orderId") Long orderId);

default Order findByIdWithStoreForUpdateOrElseThrow(Long orderId) {
    return findByIdWithStoreForUpdate(orderId)
            .orElseThrow(() -> new BusinessException(ORDER_NOT_FOUND));
}

@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "5000"))
@Query("SELECT o FROM Order o WHERE o.id = :orderId")
Optional<Order> findByIdForUpdate(long orderId);

default Order findByIdForUpdateOrElseThrow(@Param(value = "orderId") Long orderId) {
    return findByIdForUpdate(orderId)
            .orElseThrow(() -> new BusinessException(ORDER_NOT_FOUND));
}

테스트가 통과되었다.

☝️ 여기서 잠깐: S와 S Lock, 그리고 X Lock
1. X락을 그 레코드에 걸었든, 말든, MySQL InnoDB엔진은 SELECT시 S락을 걸지 않으니 조회(S)가 가능하다.
2. 내가 선언한 PESSIMISTIC_WRITE은 X Lock으로, 다른 트랜잭션에서 S Lock(읽기 락) 조차 얻지 못한다.
동시성 제어 방법: 낙관적 락 적용
@Version
private Long version;

위와 같이 Order 엔티티에 version 속성을 추가해주었다. 

이 또한 테스트가 통과되었다.

결론: 낙관적 락 사용! ⭐⭐

비관적 락은 거쳐간 모든 인덱스와 레코드에 X 락(배타적 락)을 걸기 때문에, 충돌이 발생하면 하나의 트랜잭션을 제외한 나머지 트랜잭션은 대기 상태에 들어간다. 하지만 충돌이 적은 환경에서는 굳이 비관적 락을 사용해 성능을 저하시킬 필요가 없다. 왜냐하면, 충돌이 발생하지 않더라도 락 획득 과정에서 다른 트랜잭션이 불필요하게 대기하게 되어 성능 저하가 발생하기 때문이다.

 

반면, 낙관적 락은 충돌이 적은 경우 대부분 별다른 제약 없이 커밋까지 진행되기 때문에 시스템 부하가 적다. 다만 충돌이 많은 환경에서는, 트랜잭션들이 커밋 직전까지 충돌 여부를 알지 못하고 DB I/O 작업을 수행하기 때문에, 오히려 데이터베이스에 더 큰 부하를 줄 수 있다. 또한 충돌 발생 시 재시도 로직이 필요해 추가적인 부담이 생긴다.

 

주문 상태 변경 기능은 충돌 가능성이 낮은 작업이기 때문에, 성능과 효율을 고려하여 낙관적 락을 적용하였다!

트러블 슈팅: 단일 트랜잭션에서 버전 충돌 발생

테스트 코드는 성공적으로 통과했지만, 실제로 스프링 애플리케이션을 실행한 후 POSTMAN으로 상태 변경 API를 호출해보니, 예상과 달리 충돌이 없는 상황에서도 버전 충돌 예외가 발생했다.

 

문제를 추적해본 결과, DB에 저장된 일부 주문 데이터의 option 컬럼 값이 null로 들어가 있었고, 이로 인해 @Version 필드의 동작이 제대로 이루어지지 않아 충돌 예외가 발생한 것이었다. 정확한 원인은 알 수 없지만, spring.jpa.hibernate.ddl-auto=update 설정으로 인해 과거 데이터에 null이 허용되었을 가능성이 있다.

 

이후 새로운 주문 데이터를 생성해보니 option 값이 정상적으로 false 또는 0으로 저장되었고, 상태 변경 API도 기대한 대로 작동함을 확인했다!

버전 필드 값 확인 필수!