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