← 프로젝트 회고 · Backend
RETROSPECTIVE · 회고

결제 직전 재고 재검증, 그리고 그것만으로는 부족한 이유

Backend

핸드메이드 쇼핑몰 프로젝트에서 같은 옵션 상품을 여러 명이 동시에 결제하면 재고보다 많이 팔릴 수 있는 문제가 있었다. 당시에는 PG 결제 승인 요청 직전에 옵션별 재고를 다시 확인하는 방식으로 막았다.

// 결제 승인 직전 재검증
for (OrderItem item : items) {
OptionCategory opt = optionRepository.findById(item.getOptionId()).orElseThrow();
if (opt.getQuantity() < item.getQuantity()) {
orderService.discard(orderGroup); // 주문 폐기
return badRequest("quantity_over"); // 결제 중단
}
}
confirmPayment(...); // PG 승인
decreaseStock(items); // 재고 차감

여전히 남는 구멍: check-then-act

"확인"과 "차감" 사이에 다른 요청이 끼어들 수 있다. 두 사용자가 동시에 재검증을 통과하면 둘 다 결제가 승인되고, 재고는 음수가 된다. 애플리케이션 레벨 확인만으로는 경쟁 상태를 없앨 수 없다.

DB가 보장하게 하는 방법

1. 조건부 UPDATE (원자적 차감)

UPDATE option_category
SET quantity = quantity - :qty
WHERE id = :id
AND quantity >= :qty;
-- 영향받은 행이 0이면 재고 부족 → 롤백

확인과 차감이 한 문장에서 일어나서 가장 단순하고 빠르다.

2. 비관적 락

@Lock(LockModeType.PESSIMISTIC_WRITE) // SELECT ... FOR UPDATE
@Query("select o from OptionCategory o where o.id = :id")
Optional<OptionCategory> findForUpdate(Long id);

행을 잠근 채 읽고 계산해야 할 때 쓴다. 인기 상품처럼 경합이 심하면 대기가 길어질 수 있다.

3. 낙관적 락: @Version 컬럼으로 충돌을 감지하고 재시도한다. 충돌이 드문 경우에 적합하다.

결제와 묶을 때의 순서

  1. 재고를 먼저 확보(차감 또는 예약)하고 PG 승인을 요청한다.
  2. 승인이 실패하면 확보한 재고를 되돌린다.
  3. 반대로 승인 후 차감하면, 차감 실패 시 이미 승인된 결제를 취소해야 해서 흐름이 복잡해진다.

당시 구현은 "대부분의 경우"를 막았지만, 동시성은 결국 DB 수준에서 보장해야 한다는 걸 회고하며 정리했다.

Gunmo Lee
결제 직전 재고 재검증, 그리고 그것만으로는 부족한 이유 | Gunmo's Dev Life