마지막 재고 1개는 누구의 주문이 되는가
마지막 재고 1개는 누구의 주문이 되는가#
재고가 1개 남은 상품에 두 사람이 거의 동시에 주문을 넣는다.
둘 다 결제 화면까지 가고, 둘 다 “주문이 처리되었습니다”를 본 뒤에야 누군가에게 취소를 안내한다면, 문제는 재고 수량 하나가 틀린 데서 끝나지 않는다. 결제 취소, 환불, 고객 문의, 판매자 대응, 신뢰 하락이 한 번에 따라온다.
이 상황에서 내가 지키고 싶은 원칙은 단순하다.
마지막 재고는 한 주문만 가져가야 한다. 다른 고객에게는 결제 취소보다 품절이 낫다.
그래서 재고가 마지막 1개일 때는 결제 승인부터 받지 않는다. 먼저 한 주문에만 재고를 선점하고, 다른 고객에게는 품절을 보여 준다. 그다음 결제를 승인한다. 결제가 명확히 실패하면 즉시 선점을 풀고, 결제 화면을 이탈하면 정해진 시간이 지난 뒤에만 푼다.
중요한 것은 “재고를 먼저 차감한다”는 한 줄이 아니다. 재고를 예약 상태로 다루고, 어떤 결과에서 누가 그 예약을 확정하거나 되돌릴 수 있는지 정하는 것이다.
재고 차감과 결제 승인 사이에는 예약 상태가 있다#
재고를 먼저 줄이고 결제를 나중에 받으면 이런 반론이 바로 나온다.
결제가 실패하면 이미 빠진 재고는 어떻게 할 건데?
답은 영구 차감이 아니라 예약이다. 재고 1개를 마지막 주문에 배정하는 순간의 상태는 판매 완료가 아니라 결제 예약됨이어야 한다.
판매 가능: 새 주문이 선점할 수 있다.결제 예약됨: 특정 주문만 결제를 끝낼 수 있다. 다른 고객에게는 품절로 보인다.판매 완료: 결제가 확정돼 더는 판매할 수 없다.예약 해제: 실패·취소·만료 뒤 다시 판매 가능 상태로 돌아간다.
제한 재고를 다루는 Stripe Checkout도 결제를 끝내지 않은 고객이 재고를 오래 점유하지 않도록 세션 만료를 두고, 만료 이벤트를 받아 예약 재고를 되돌리도록 안내한다.[1] 다만 Stripe의 세션 만료 범위가 모든 결제수단과 자체 결제 화면의 정답은 아니다. 예약 시간은 고객이 결제를 끝내는 데 필요한 시간, 상품 희소성, 결제사 응답 특성을 함께 보고 정해야 한다.
여기서 UX의 기준도 분명해진다. 마지막 상품이 이미 결제 예약 상태라면 다른 고객에게 “거의 다 됐어요”라는 기대를 주지 않는다. 바로 품절로 보인다. 예약이 풀렸을 때만 다시 판매 가능으로 바뀐다.
한 주문만 선점하는 일은 읽기와 쓰기를 나누면 뚫린다#
가장 흔한 실수는 재고를 먼저 읽고, 애플리케이션 코드에서 재고가 있네라고 판단한 다음 나중에 차감하는 방식이다.
1. 현재 재고를 읽는다: 1개
2. 재고가 있으면 주문을 만든다
3. 재고를 1개 뺀다 두 요청이 1번을 거의 동시에 실행하면 둘 다 1개를 읽고, 둘 다 주문을 만들 수 있다. 문제는 결제사가 아니라 재고 판단과 차감이 서로 다른 순간에 일어난 데 있다.
마지막 재고의 선점은 조건과 변경을 한 번의 DB 쓰기에 넣어야 한다. 예를 들면 다음과 같은 형태다.
UPDATE inventory
SET available_quantity = available_quantity - 1,
reserved_quantity = reserved_quantity + 1
WHERE product_id = :product_id
AND available_quantity > 0
RETURNING available_quantity; UPDATE는 WHERE 조건을 만족한 행만 바꾸고, RETURNING은 실제 변경된 행의 값을 돌려준다.[3] 반환 행이 없으면 그 요청은 마지막 재고를 선점하지 못한 것이다. PostgreSQL의 기본 Read Committed에서도 같은 행을 동시에 갱신하려는 요청은 먼저 갱신한 트랜잭션의 결과를 기다린 뒤, 변경된 행에 대해 WHERE 조건을 다시 평가한다.[7]
이것은 SELECT를 먼저 한 뒤 “재고가 남았으니 차감한다”보다 안전하다. 다만 여러 SKU를 함께 선점하거나 쿠폰·판매 제한·장바구니 분할까지 엮이면 단일 SQL만으로 충분하다고 일반화하면 안 된다. 필요한 무결성 범위에 따라 트랜잭션, 일관된 잠금 순서, 낙관적 충돌 처리 중 무엇을 쓸지 정해야 한다. 행 잠금은 같은 행을 수정하려는 다른 writer를 막고 트랜잭션 종료 시 풀린다.[8]
결제 실패·이탈·타임아웃은 같은 실패가 아니다#
예약을 풀어야 한다고 해서 모든 실패를 같은 방식으로 다루면 다시 문제가 생긴다.
1. 결제 실패 응답을 받았다면 즉시 복구한다#
카드 거절처럼 결제 실패가 명확하면, 그 주문이 잡아 둔 예약을 바로 해제한다. 이때 해제는 “재고를 1개 더한다”가 아니라, 그 주문의 예약이 아직 살아 있을 때만 수행해야 한다.
예를 들어 reservation_id와 상태를 두고 RESERVED → RELEASED 전이가 한 번만 일어나게 만든다. 같은 실패 웹훅이 두 번 오거나 운영자가 재처리해도 재고가 두 번 늘지 않아야 한다.
2. 결제 화면 이탈은 예약 만료로 복구한다#
브라우저를 닫았다고 결제 실패 이벤트가 항상 오는 것은 아니다. 그래서 예약에는 서버가 판단하는 만료 시각이 필요하다. 만료 시간이 지나면 예약을 찾아 해제하고, 품절 화면도 다시 판매 가능 상태로 바꾼다.
여기서 클라이언트의 카운트다운은 안내일 뿐이다. 실제 복구 권한은 서버의 예약 상태와 만료 시각에 있어야 한다. Stripe도 세션 만료 후 checkout.session.expired 이벤트로 예약 재고를 되돌리도록 설명한다.[1]
3. 타임아웃은 즉시 실패가 아니라 결과 미상이다#
가장 위험한 경우는 결제 요청을 보냈는데 응답을 못 받는 경우다. 네트워크 타임아웃은 결제사가 요청을 못 받았다는 뜻이 아니다. 이미 승인했지만 응답만 늦었을 수 있다. 외부 호출 타임아웃은 실패가 아니라 결과 미상으로 다룬다가 말하듯, 상대 시스템이 멱등 키나 조회 API를 제공하지 않으면 중복과 유실 중 하나를 감수해야 한다.
그래서 타임아웃 직후 재고를 풀면 안 된다. 이 글에서 말하는 “타임아웃 가능 시간을 넘긴 뒤 복구”는 다음 조건을 포함한다.
- 결제사별 최대 응답·웹훅 지연 시간을 운영 정책으로 정한다.
- 그 시간 전에는 예약을
결제 확인 중으로 유지한다. - 기한 뒤 결제사 조회와 내부 결제 기록에서 성공이 없을 때만 예약을 해제한다.
- 늦은 성공 웹훅과 예약 해제가 겹쳐도, 한 상태 전이만 이기도록 조건을 둔다.
이 보수적인 처리는 잠깐의 거짓 품절을 만들 수 있다. 그러나 성공한 결제를 놓치거나 같은 마지막 재고를 두 번 약속하는 것보다 피해가 작다. 결제사가 조회 API나 멱등성 계약을 제공하지 않는다면, 이 판단은 기술 문제가 아니라 사업상 위험 선택이 된다.
재시도는 주문을 다시 만드는 일이 아니다#
사용자는 결제 버튼을 두 번 누를 수 있고, 모바일 네트워크는 응답을 잃을 수 있다. 같은 주문 생성 요청이 다시 오면 새 예약이나 새 결제를 만들지 않고, 처음 요청의 결과를 돌려줘야 한다.
HTTP POST는 원래 여러 번 받으면 여러 번 상태를 바꿀 수 있다. Idempotency-Key는 같은 요청을 다시 보낼 때 서버가 한 번만 처리하도록 구분하는 방식이다.[4] Stripe도 동일 키의 재시도에 첫 요청의 결과를 돌려주고, 동일 키에 다른 파라미터가 오면 오류로 처리한다.[5]
주문 API에서는 이 키를 단순 로그 값으로 두면 안 된다. 최소한 다음을 함께 보관해야 한다.
- 키가 가리키는 주문 또는 예약 ID
processing,completed,failed같은 처리 상태- 처음 요청의 입력 fingerprint
- 응답 결과와 보관 기간
이미 처리 중인 같은 키가 오면 새 주문을 만들지 말고 처리 중을 돌려준다. 완료된 키라면 저장한 결과를 돌려준다. 중복 실행 방지는 멱등성 키의 처리 결과를 재사용한다의 핵심도 키 자체가 아니라, 같은 업무 요청의 결과를 재사용하는 계약이다.
AWS도 멱등성 토큰과 관련 상태를 영속 저장하고, 토큰 기록과 실제 상태 변경을 적절한 동시성 제어 아래 함께 다뤄야 race condition을 막을 수 있다고 권한다.[6]
결제와 재고를 하나의 DB 트랜잭션으로 묶으려 하지 않는다#
재고 DB와 외부 결제사는 하나의 원자적 트랜잭션으로 묶기 어렵다. 그래서 필요한 것은 “둘을 동시에 끝내기”가 아니라, 각 단계의 성공과 되돌림을 명시하는 것이다.
판매 가능
→ 예약 생성 / 재고 선점
→ 결제 요청
→ 결제 성공: 예약 확정 / 주문 완료
→ 결제 명시 실패: 예약 해제
→ 고객 이탈: 예약 만료 후 해제
→ 응답 타임아웃: 결제 확인 중 → 조회·기한 확인 후 확정 또는 해제 이런 순차적 로컬 변경과 보상 변경을 다루는 방식이 saga다. AWS는 결제처럼 업무 규칙 때문에 실패한 경우에는 앞 단계의 재고·주문 상태를 되돌리는 보상 트랜잭션을, 인프라 수준 실패에는 재시도 같은 전진 복구를 선택할 수 있다고 설명한다.[2]
주문 확정 뒤 알림·출고·분석 이벤트까지 이어진다면, 재고 상태만 커밋하고 이벤트 발행 전에 죽는 경우도 남는다. 이때는 상태 변경과 이벤트 기록을 같은 트랜잭션으로 남기고, 별도 릴레이가 발행하는 트랜잭셔널 아웃박스를 검토할 수 있다. 다만 단일 서비스의 작은 주문 흐름에 처음부터 복잡한 이벤트 구조를 넣을 이유는 없다. 먼저 예약·확정·해제의 상태 전이가 정확한지 확인해야 한다.
반론: 결제보다 재고를 먼저 잡으면 전환을 떨어뜨리지 않나#
그럴 수 있다. 예약 시간이 길수록 실제로 결제하지 않는 고객이 마지막 상품을 붙잡고, 다른 고객은 품절만 본다. 희소 상품에는 짧은 예약이 고객에게 압박으로 느껴질 수도 있다.
그래서 이 글의 결론은 모든 커머스에 긴 재고 선점을 넣자는 것이 아니다.
- 충분히 재고가 많은 상품은 선점보다 결제 성공 뒤 차감이 단순할 수 있다.
- 마지막 수량, 한정 판매, 좌석·티켓처럼 초과 판매 비용이 큰 상품은 예약이 더 낫다.
- 예약 시간은 UX 문구가 아니라 결제사 응답 한계, 실제 결제 완료 시간, 품절의 비용을 보고 정한다.
- 복구 작업은 같은 예약을 두 번 풀지 않도록 멱등해야 한다.
내 기준은 이것이다.
결제 취소 경험보다 품절 경험이 낫다면, 마지막 재고는 결제 결과가 아니라 예약 상태로 관리해야 한다.
마지막 재고 1개는 먼저 도착한 HTTP 요청의 것이 아니다. 재고를 한 번만 선점하고, 결제 결과가 명확해질 때까지 그 약속을 일관되게 지킬 수 있는 주문의 것이다.