Troubleshooting: 낙관적 LOCK, 비관적 LOCK - takeoff-26/logistics-service GitHub Wiki

시작하며

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

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

비관적 락

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

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

낙관적 락

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

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

고민과 선택

여러 상품의 재고를 한 번에 처리하는 상황에서 두 가지 전략을 고민했다. 낙관적 락의 경우, 여러 데이터셋의 충돌을 커밋 시점에 한번에 확인하게 되면 충돌 빈도가 높아져 재시도 로직이 자주 발생할 수 있다. 이는 결국 시스템 부하로 이어질 수 있다. 비관적 락도 문제가 있다. 여러 트랜잭션이 서로 다른 순서로 자원을 점유하면서 데드락이 발생할 위험이 있다. 하지만 비관적 락의 경우 자원 접근 순서를 일관되게 유지하면 데드락을 방지할 수 있다고 판단했다. 그래서 정렬 로직을 적용한 비관적 락을 선택했다.

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

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의 성능 테스트를 수행해보고 이제는 분산 락이나 메시지 큐 같은 외부 기능을 활용해 동시성을 더욱 향상시킬 방법을 찾아봐야지!