여러 상품의 재고를 처리하는 기능을 구현했다.
그 과정에서의 얘기를 하려고한다.
- 한번의 주문에 여러상품들이 들어온다.
- 하나라도 부족하게되면 전체 주문이 실패해야한다.
- 멀티스레드 환경에서 동시성 이슈를 고민해야한다.
비관적락은 충돌이 일어날 것을 "비관적"으로 판단하고, 무조건 락을 거는 방법이다. 이 경우, 다른 트랜잭션은 해당 레코드에 접근할 수 없다.
데이터의 일관성은 보장되나 동시성은 떨어질 수 있다.
낙관적락은 충돌이 일어나지 않을 것을 "낙관적"으로 판단하고, 락을 걸어서 제어하는 것이 아닌 버전 관리를 통해 충돌을 감지하는 전략이다.
변경이 적은 경우, 동시성 이슈를 효과적으로 제어할 수 있다.
여러 상품의 재고를 한 번에 처리하는 상황에서 두 가지 전략을 고민했다.
낙관적 락의 경우, 여러 데이터셋의 충돌을 커밋 시점에 한번에 확인하게 되면 충돌 빈도가 높아져 재시도 로직이 자주 발생할 수 있다. 이는 결국 시스템 부하로 이어질 수 있다.

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

하지만 비관적 락의 경우 자원 접근 순서를 일관되게 유지하면 데드락을 방지할 수 있다고 판단했다. 그래서 정렬 로직을 적용한 비관적 락을 선택했다.
@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를 사용하여 트랜잭션 격리를 구현하는데, 이 환경에서:
동시성 이슈는 해결했으나, 성능 개선이라는 또 하나의 과제가 생겼다.
해당 API의 성능 테스트를 수행해보고 이제는 분산 락이나 메시지 큐 같은 외부 기능을 활용해 동시성을 더욱 향상시킬 방법을 찾아봐야지!