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 컬럼으로 충돌을 감지하고 재시도한다. 충돌이 드문 경우에 적합하다.
결제와 묶을 때의 순서
- 재고를 먼저 확보(차감 또는 예약)하고 PG 승인을 요청한다.
- 승인이 실패하면 확보한 재고를 되돌린다.
- 반대로 승인 후 차감하면, 차감 실패 시 이미 승인된 결제를 취소해야 해서 흐름이 복잡해진다.
당시 구현은 "대부분의 경우"를 막았지만, 동시성은 결국 DB 수준에서 보장해야 한다는 걸 회고하며 정리했다.