동시성 문제와 RDB에서의 해결 — 비관적 락 적용기

2025. 8. 10. 16:14·WIL

 

1. 문제 발견

이번 주차 과제에서는 여러 사용자가 동시에 같은 자원을 갱신하는 상황에서 발생하는 Race Condition 문제를 직접 경험하였습니다.

로컬 환경에서는 쉽게 드러나지 않지만, 동시 요청이 몰리는 운영 환경이나 부하 테스트 상황에서는 생각보다 자주 발생할 수 있습니다.

 

대표적인 예시는 다음과 같습니다.

 

  • 동시에 두 명의 사용자가 같은 상품을 주문 → 재고가 음수가 되는 현상 발생
  • 여러 사용자가 동일 포인트를 사용 → 포인트가 부족한데도 결제가 완료되는 문제

2. Race Condition이란?

Race Condition은 여러 스레드(또는 트랜잭션)가 동시에 같은 데이터에 접근하여 갱신할 때, 실행 순서나 타이밍에 따라 결과가 달라지는 상황을 말합니다.

 

 

예시 시나리오

  1. 사용자 A, B가 동시에 재고 10인 상품을 주문 (각각 1개)
  2. 두 트랜잭션이 거의 같은 시점에 재고를 읽음 → 둘 다 10으로 인식
  3. 각자 재고 차감 후 9로 업데이트
  4. 최종 재고는 9여야 하지만, 동시에 처리되면 중간 업데이트가 덮어씌워져 재고가 꼬임 (Lost Update)

 

3. RDB에서의 해결 전략

RDB에서는 대표적으로 다음 두 가지 방법이 있습니다.

전략 설명 장점 단점
낙관적 락 @Version 필드를 두고, 트랜잭션 종료 시점에 버전 비교로 충돌 감지 락 비용이 없음, 성능 우수 충돌 시 재시도 필요, 필드 침투
비관적 락 조회 시점부터 DB가 해당 행을 잠금 (SELECT ... FOR UPDATE) 강력한 정합성 보장, 충돌 시 대기 데드락 가능성, 성능 저하

4. 제가 선택한 방법 — 비관적 락

이번 과제에서는 비관적 락(Pessimistic Lock) 을 사용하였습니다.

그 이유는 비즈니스의 중요한 정보를 담는 도메인 모델에 version이라는 필드를 넣는 방식이 마음에 들지 않았기 때문입니다.

 

  • 중요한 도메인 모델: 재고, 포인트, 쿠폰 사용 이력 등
  • 이러한 모델들은 비즈니스 핵심 규칙을 담고 있으므로, 불필요한 필드 침투를 피하고 싶었습니다.
  • 낙관적 락의 경우 @Version 필드가 필수인데, 이는 도메인 설계에 영향을 줍니다.
  • 반면, 비관적 락은 조회 쿼리 레벨에서만 잠금을 걸어 도메인 변경 없이 정합성을 확보할 수 있습니다.

5. 비관적 락 적용 예시

public interface InventoryRepository extends JpaRepository<Inventory, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")}) // 타임아웃 3초
    @Query("SELECT i FROM Inventory i WHERE i.productId = :productId")
    Inventory findByProductIdForUpdate(@Param("productId") Long productId);
}

 

@Transactional
public void decreaseStock(Long productId, int quantity) {
    Inventory inventory = inventoryRepository.findByProductIdForUpdate(productId);

    if (inventory.getStock() < quantity) {
        throw new CoreException(ErrorType.OUT_OF_STOCK);
    }
    inventory.decrease(quantity);
}
  • PESSIMISTIC_WRITE → 해당 행을 쓰기 락으로 점유하여 다른 트랜잭션 접근 차단
  • @QueryHint의 jakarta.persistence.lock.timeout 옵션으로 락 대기 시간을 설정 가능

6. 동시성 테스트로 검증

@Test
void concurrencyTest_stockShouldNotBeNegative() throws InterruptedException {
    int threadCount = 10;
    ExecutorService executor = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);

    for (int i = 0; i < threadCount; i++) {
        executor.submit(() -> {
            try {
                orderService.placeOrder(1L, 1L, 1); // userId, productId, quantity
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();

    Inventory inventory = inventoryRepository.findByProductId(1L);
    assertThat(inventory.getStock()).isGreaterThanOrEqualTo(0);
}

7. 회고

  • 비관적 락을 사용하면 도메인 설계에 영향을 주지 않고도 강력한 정합성 보장이 가능합니다.
  • 하지만 성능 부담과 데드락 가능성을 고려해야 하므로, 락 범위와 지속 시간을 최소화하는 것이 중요합니다.
  • 타임아웃(@QueryHint)을 적극적으로 활용하면, 특정 요청이 무한정 대기하지 않도록 안전장치를 둘 수 있습니다.
  • 실제 운영 환경에서는 락 경합 시 재시도 로직, 타임아웃 발생 시 예외 처리 방안까지 함께 설계하는 것이 좋습니다.

 


📌 정리

  • Race Condition은 운영 환경에서 충분히 발생 가능한 문제입니다.
  • RDB에서는 낙관적/비관적 락을 통해 해결할 수 있습니다.
  • 이번 케이스에서는 도메인 필드 침투를 피하기 위해 비관적 락을 선택하였고, 타임아웃 설정을 통해 안정성을 확보하였습니다.
  • 반드시 테스트를 통해 검증한 뒤 적용하는 것이 좋습니다.

'WIL' 카테고리의 다른 글

MSA와 EDA 이해하기: 분산 아키텍처 기본 개념 정리  (0) 2025.10.26
DDD 강의 회고: 도메인 주도 설계의 사실과 오해  (4) 2025.10.12
10주간의 백엔드 부트캠프 회고: 설계부터 운영까지, 실전적 고민의 기록  (0) 2025.09.17
캐시 전략(Cache Strategies) 정리  (3) 2025.08.17
WIL – TDD & 테스트 가능한 구조  (0) 2025.07.16
'WIL' 카테고리의 다른 글
  • DDD 강의 회고: 도메인 주도 설계의 사실과 오해
  • 10주간의 백엔드 부트캠프 회고: 설계부터 운영까지, 실전적 고민의 기록
  • 캐시 전략(Cache Strategies) 정리
  • WIL – TDD & 테스트 가능한 구조
JoshDev
JoshDev
    • 분류 전체보기 (24)
      • Java (3)
      • Spring (9)
      • Test Code (2)
      • WIL (6)
      • Vue.js (2)
      • WEB (0)
      • DB (1)
        • MySQL (1)
  • 인기 글

  • hELLO· Designed By정상우.v4.10.4
JoshDev
동시성 문제와 RDB에서의 해결 — 비관적 락 적용기
상단으로

티스토리툴바