본문 바로가기

Programming/Database

낙관적 락 vs 비관적 락, 재고 차감에서 뭘 써야 하나

추천캐릭터 2026. 7. 23. 13:00
728x90

낙관적 락 vs 비관적 락, 재고 차감에서 뭘 써야 하나

결론부터

동시에 여러 요청이 같은 데이터를 건드리는 상황(재고 차감, 선착순 쿠폰, 잔액 차감)에서는 트랜잭션 격리 수준만으로 부족하다. 비관적 락은 "일단 잠그고 시작", 낙관적 락은 "일단 진행하고 충돌 나면 그때 재시도"다. 충돌이 잦고 정합성이 최우선이면 비관적 락, 충돌이 드물고 처리량이 중요하면 낙관적 락을 쓴다. 재고 차감처럼 짧고 자주 충돌하는 작업은 대개 비관적 락 쪽이 실무에서 더 무난하다.

문제 상황: 동시 요청이 재고를 함께 깎아 먹는다

재고 100개인 상품에 동시에 두 요청이 들어와 "재고 있음 → 1개 차감"을 각자 확인하고 실행하면, 두 요청 모두 "차감 전 재고 100"을 읽은 채로 각각 99로 갱신해버릴 수 있다. 실제로는 2개가 나갔는데 재고는 1개만 줄어든 것처럼 기록되는, 이른바 Lost Update다.

// 동시성 제어 없이 짜면 이렇게 된다
@Transactional
public void decreaseStock(Long productId, int quantity) {
    Product product = productRepository.findById(productId).orElseThrow();
    if (product.getStock() < quantity) {
        throw new IllegalStateException("재고 부족");
    }
    product.decreaseStock(quantity); // 두 트랜잭션이 동시에 여기 도달하면?
}

비관적 락 — 먼저 잠그고 남은 요청은 기다린다

JPA에서는 조회 시점에 DB 레벨 락을 거는 PESSIMISTIC_WRITE를 쓴다. MariaDB/MySQL(InnoDB) 기준으로 SELECT ... FOR UPDATE가 나간다.

public interface ProductRepository extends JpaRepository<Product, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select p from Product p where p.id = :id")
    Optional<Product> findByIdForUpdate(@Param("id") Long id);
}

@Transactional
public void decreaseStock(Long productId, int quantity) {
    Product product = productRepository.findByIdForUpdate(productId)
            .orElseThrow();
    if (product.getStock() < quantity) {
        throw new IllegalStateException("재고 부족");
    }
    product.decreaseStock(quantity);
    // 트랜잭션 끝날 때까지 다른 요청은 이 row를 못 건드리고 대기한다
}

먼저 도착한 요청이 row를 잠그면, 뒤이어 온 요청은 락이 풀릴 때까지 대기한다. 충돌 자체가 안 일어나므로 재시도 로직이 필요 없고 구현도 단순하다. 대신 대기하는 트랜잭션이 쌓이면 처리량이 떨어지고, 락을 오래 들고 있으면 커넥션 풀까지 영향받는다.

낙관적 락 — 일단 진행하고, 충돌하면 그때 재시도

JPA의 @Version으로 구현한다. 엔티티에 버전 컬럼을 추가하면, 업데이트 시점에 내가 읽은 버전과 지금 DB의 버전이 같은지를 WHERE 절에 넣어 함께 체크한다.

@Entity
public class Product {

    @Id
    private Long id;

    private int stock;

    @Version
    private Long version; // 이게 핵심
}

@Transactional
public void decreaseStock(Long productId, int quantity) {
    Product product = productRepository.findById(productId).orElseThrow();
    if (product.getStock() < quantity) {
        throw new IllegalStateException("재고 부족");
    }
    product.decreaseStock(quantity);
    // 커밋 시점에 UPDATE ... WHERE id = ? AND version = ?
    // 다른 트랜잭션이 먼저 커밋해서 버전이 바뀌었다면 0건 갱신 → 예외 발생
}

버전이 어긋나면 OptimisticLockException이 터진다. 이걸 호출부에서 잡아 재시도하는 로직을 직접 짜야 한다.

public void decreaseStockWithRetry(Long productId, int quantity) {
    int maxRetry = 3;
    for (int attempt = 1; attempt <= maxRetry; attempt++) {
        try {
            decreaseStock(productId, quantity);
            return;
        } catch (OptimisticLockException e) {
            if (attempt == maxRetry) throw e;
            // 잠깐 대기 후 재시도 (지수 백오프 권장)
        }
    }
}

락을 미리 걸지 않으므로 평소엔 처리량이 높지만, 충돌이 잦은 구간에서는 재시도가 쌓여 오히려 비관적 락보다 느려질 수 있다. 선착순 이벤트처럼 순간적으로 트래픽이 몰리는 상황에는 낙관적 락 하나만 믿고 가기보다, 애초에 큐나 Redis 원자적 연산으로 앞단에서 트래픽을 거르는 설계를 함께 고려하는 게 현실적이다.

선택 기준 정리

상황추천
동시 요청이 잦고, 실패보다 대기가 나은 경우 (재고, 좌석 예약)비관적 락
충돌이 드물고, 조회·쓰기 비율에서 조회가 압도적으로 많은 경우낙관적 락
순간적으로 트래픽이 폭증하는 선착순 이벤트낙관적 락 + 재시도만으론 부족, Redis 등 앞단 트래픽 제어 병행
단순 통계·카운터 증가DB의 원자적 연산(UPDATE ... SET count = count + 1)만으로 충분할 때가 많음

실무 주의사항

  • 비관적 락은 락 순서에 주의한다. 여러 row를 동시에 잠그는 로직에서 락 획득 순서가 요청마다 다르면 데드락이 난다. 항상 같은 순서(예: ID 오름차순)로 잠그도록 설계해야 한다.
  • 낙관적 락 재시도는 무한 루프가 되지 않게 최대 횟수를 반드시 둔다. 그리고 재시도 사이에 짧은 대기(지수 백오프)를 넣어야 충돌이 몰리는 상황에서 재시도끼리 또 부딪히는 걸 줄일 수 있다.
  • 두 방식을 같은 테이블에 섞어 쓰지 않는다. 한쪽은 FOR UPDATE로 잠그고 다른 쪽은 버전 체크만 믿고 지나가면, 낙관적 락 경로가 비관적 락의 보호를 못 받고 그대로 충돌할 수 있다.

마무리

동시성 문제는 트랜잭션 격리 수준을 올린다고 저절로 해결되지 않는다. "먼저 온 사람이 잠그고 나머지는 기다리게 할지", 아니면 "일단 다 보내고 충돌 난 사람만 다시 시키게 할지"를 데이터 접근 패턴에 맞춰 직접 선택해야 한다. 재고·잔액처럼 정합성이 생명인 값을 다룰 때는, 오늘 정리한 기준표부터 놓고 시작하자.

728x90