비관적락 vs 낙관적락

hyezuu·2025년 3월 18일

시작하며

여러 상품의 재고를 처리하는 기능을 구현했다.
그 과정에서의 얘기를 하려고한다.

  • 한번의 주문에 여러상품들이 들어온다.
  • 하나라도 부족하게되면 전체 주문이 실패해야한다.
  • 멀티스레드 환경에서 동시성 이슈를 고민해야한다.

비관적 락

비관적락은 충돌이 일어날 것을 "비관적"으로 판단하고, 무조건 락을 거는 방법이다. 이 경우, 다른 트랜잭션은 해당 레코드에 접근할 수 없다.
데이터의 일관성은 보장되나 동시성은 떨어질 수 있다.

  • 발생할 수 있는 문제: 데드락

낙관적 락

낙관적락은 충돌이 일어나지 않을 것을 "낙관적"으로 판단하고, 락을 걸어서 제어하는 것이 아닌 버전 관리를 통해 충돌을 감지하는 전략이다.
변경이 적은 경우, 동시성 이슈를 효과적으로 제어할 수 있다.

  • 충돌 시점에 어떻게 처리할 것인지 추가적인 로직이 필요하다 (재시도)

고민과 선택

여러 상품의 재고를 한 번에 처리하는 상황에서 두 가지 전략을 고민했다.

낙관적 락의 경우, 여러 데이터셋의 충돌을 커밋 시점에 한번에 확인하게 되면 충돌 빈도가 높아져 재시도 로직이 자주 발생할 수 있다. 이는 결국 시스템 부하로 이어질 수 있다.

비관적 락도 문제가 있다. 여러 트랜잭션이 서로 다른 순서로 자원을 점유하면서 데드락이 발생할 위험이 있다.

하지만 비관적 락의 경우 자원 접근 순서를 일관되게 유지하면 데드락을 방지할 수 있다고 판단했다. 그래서 정렬 로직을 적용한 비관적 락을 선택했다.

@Override
@Transactional
public void prepareStock(PrepareStockRequestDto requestDto) {
    getSortedStocks(requestDto.stocks())
        .forEach(stockItem ->
            getStockWithLock(stockItem.stockId())
                .decreaseStock(stockItem.quantity()));
}

private List<StockItemRequestDto> getSortedStocks(List<StockItemRequestDto> stocks) {
    return stocks.stream()
        .sorted(Comparator
            .comparing((StockItemRequestDto item) -> item.stockId().productId())
            .thenComparing(item -> item.stockId().hubId()))
        .toList();
}

productId와 hubId를 기준으로 정렬함으로써 모든 트랜잭션이 동일한 순서로 자원에 접근하도록 했다. 이를 통해 데드락 위험을 효과적으로 제거할 수 있었다.

MVCC와 약한 배타락(FOR NO KEY UPDATE)의 활용
PostgreSQL의 FOR NO KEY UPDATE는 MVCC(Multi-Version Concurrency Control) 아키텍처 내에서 작동하는 약한 배타락 메커니즘이다.
JPA에서는 다음과 같이 비관적 락과 타임아웃을 함께 설정했다:

@LockTimeout(timeout = 1000)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM Stock s WHERE s.id = :id AND s.deletedAt is null")
Optional<Stock> findByIdWithLock(@Param("id") StockId id);

이 코드는 재고 조회 시 배타적 락을 획득하면서 1초의 타임아웃을 설정한다. 그러나 여기서 흥미로운 점은 PostgreSQL JPA 구현체가 PESSIMISTIC_WRITE를 FOR NO KEY UPDATE로 변환한다는 점이다. 실제 생성된 SQL을 살펴보면:

Hibernate: 
  select
      s1_0.hub_id,
      s1_0.product_id,
      ...
  from
      p_stock s1_0 
  where
      (
          s1_0.hub_id, s1_0.product_id
      )=(
          ?, ?
      ) 
      and s1_0.deleted_at is null for no key update

이처럼 for no key update 구문이 사용되는 것을 확인할 수 있다. 이를 통해:

데이터 일관성 보장: 재고 수량 변경 작업 중 다른 트랜잭션의 수정을 차단
조회 성능 개선: 다른 트랜잭션의 읽기 작업은 차단하지 않아 전체 시스템 처리량 향상
락 획득 타임아웃 설정: 1초 이상 락을 기다리지 않도록 하여 시스템 전체의 병목 현상 방지

이는 재고 시스템과 같이 동시 요청이 많은 환경에서 중요한 최적화 전략이다.

Hibernate: 
    update
        p_stock 
    set
        deleted_at=?,
        deleted_by=?,
        quantity=?,
        updated_at=?,
        updated_by=? 
    where
        hub_id=? 
        and product_id=?
Hibernate: 
    select
        s1_0.hub_id,
        s1_0.product_id,
        s1_0.created_at,
        s1_0.created_by,
        s1_0.deleted_at,
        s1_0.deleted_by,
        s1_0.quantity,
        s1_0.updated_at,
        s1_0.updated_by 
    from
        p_stock s1_0 
    where
        (
            s1_0.hub_id, s1_0.product_id
        )=(
            ?, ?
        ) 
        and s1_0.deleted_at is null for no key update

PostgreSQL의 FOR NO KEY UPDATE는 MVCC(Multi-Version Concurrency Control) 아키텍처 내에서 작동하는 배타락 메커니즘이다.
MVCC는 데이터베이스가 데이터의 여러 버전을 동시에 유지하여 읽기 작업이 쓰기 작업을 차단하지 않도록 하는 방식이다. PostgreSQL은 기본적으로 MVCC를 사용하여 트랜잭션 격리를 구현하는데, 이 환경에서:

  • FOR UPDATE나 FOR NO KEY UPDATE 구문은 MVCC 내에서 행 수준 락을 획득한다
  • 이미 락이 걸린 행에 대해 다른 트랜잭션이 수정을 시도하면 첫 번째 트랜잭션이 완료될 때까지 대기한다
  • 락 타임아웃은 이 대기 시간을 제한한다

마무리

동시성 이슈는 해결했으나, 성능 개선이라는 또 하나의 과제가 생겼다.
해당 API의 성능 테스트를 수행해보고 이제는 분산 락이나 메시지 큐 같은 외부 기능을 활용해 동시성을 더욱 향상시킬 방법을 찾아봐야지!

profile
기록

0개의 댓글