시스템 설계 스터디 1회차(2026-07-26) 논의 중 “1. 재고 동시성 문제”를 정리했다. 1부는 구조 수준의 선택(설계안), 2부는 코드 수준의 고민(상세안)이다.
참고 자료
- 원본 정리: 커머스 시스템 설계 (1)
- 자매편: 설계 노트: 포인트 차감과 결제 사이의 정합성 다루기
문제 정의
커머스 재고는 두 요구가 충돌하는 데이터다. 품절 여부를 즉시 답해야 하는 조회 요구와, 어떤 변동도 잃어버리면 안 되는 기록 요구다. 저장 구조를 정할 때 두 방식이 후보에 오른다.
- 원장(append-only) — 모든 변동을 불변 이벤트로 쌓고, 합산으로 현재 재고를 구한다
- 스냅샷 — 현재 수량 하나를 두고 변동 때마다 덮어쓴다
결론부터 말하면 둘 중 하나를 고르는 문제가 아니다. 원장을 원천 데이터로 두고, 스냅샷을 조회·차감용 파생 데이터로 추가한다. 1부에서 이 결정의 근거를, 2부에서 구현할 때 고려할 점을 정리한다.
1부 : 설계안 — 구조 수준의 선택
설계 전반의 원칙
구현이 바뀌어도 지켜야 하는 원칙 세 가지를 먼저 깔아둔다.
- 취소는 update가 아니라 보상 이벤트다 — 잘못된 기록도 지우지 않고 반대 부호의 조정 이벤트를 추가한다. 이 원칙이 깨지면 원장 전체를 믿을 수 없게 된다.
- 기준은 항상 원장이다 — 장애 복구, 정산, CS 분쟁의 기준은 스냅샷이 아니라 원장이다. 스냅샷을 직접 수정하고 싶어져도, 조정은 반드시 원장에 이벤트를 넣고 재반영하는 경로로만 한다.
- 스냅샷은 언제든 버리고 다시 만들 수 있는 캐시다 — 이 원칙만 지키면 두 방식의 단점을 서로의 장점으로 상쇄할 수 있다.
설계안 1 : 원장(append-only ledger) 방식 채택
구조
- 모든 재고 변동을 불변 이벤트로 기록한다. 수량을 덮어쓰는 테이블은 두지 않는다.
stock_ledger
- id (PK, 단조 증가)
- product_id
- type (입고 / 판매차감 / 취소복원 / 조정)
- quantity (+N / -N)
- reference_id (주문 ID 등 원인 식별자)
- created_at
- 입고, 판매, 취소, 재고 실사 조정까지 모든 변동은 insert로만 기록한다.
- 현재 재고 = 해당 상품 이력의 quantity 합산.
선택 이유
- 쓰기 경합이 없다 — insert는 새 row를 만들기만 하므로 같은 row를 두고 경합할 일이 없다. 스터디에서 “조회 → 합산 → 차감”을 통째로 비관적 락에 넣었다가 데드락을 겪은 경험담(insert와 update가 한 트랜잭션에 묶이고 FK 참조의 S lock이 충돌)이 나왔다. 근본 원인이 update 경합이므로, 경합 자체가 없는 구조를 기본으로 삼는다.
- 변동 이력이 전부 남는다 — 재고가 안 맞을 때 언제부터, 어떤 이벤트 때문에 틀어졌는지 역추적할 수 있다. 정산, 보정, CS 대응의 근거 데이터가 된다.
- 파생 데이터를 언제든 다시 만들 수 있다 — 집계값에 버그가 있어도 원장에서 재계산하면 된다. 복구 시나리오가 “원장 재집계” 하나로 수렴한다.
- 확장 방향과 맞는다 — 트래픽이 10배, 100배로 늘어도 insert는 파티셔닝과 샤딩으로 수평 확장하기 쉽다. 이벤트 기반 아키텍처로 넘어갈 때도 원장 이벤트를 그대로 발행 단위로 쓸 수 있다.
주의점
- 이력이 무한히 늘어난다 — 보관 정책이 필수다. 상세안 4에서 다룬다.
- 조회 성능에 한계가 있다 — 이 방식만으로는 “지금 살 수 있나”를 빠르게 답할 수 없다. 스냅샷을 차용하는 이유다.
설계안 2 : 스냅샷 차용
원장 단독 운영에서 드러나는 문제
- 현재고 조회 비용 — 상품 상세, 목록, 품절 배지 같은 조회 트래픽은 변동 트래픽의 수십~수백 배인데, 매번 이력을 합산할 수 없다.
- 원자적 차감 불가 — “재고가 5개 남았으면 차감”이라는 조건부 연산을 이력 합산으로는 한 번에 처리할 수 없다. 오버셀을 막을 지점이 없다.
구조
- 원장은 그대로 원천 데이터로 두고, 파생 데이터로 스냅샷 테이블을 추가한다.
stock_snapshot
- product_id (PK)
- available_qty (현재 판매 가능 수량)
- last_ledger_id (어느 원장 이벤트까지 반영했는지)
- updated_at
- 읽기 — 품절 여부를 포함한 모든 조회는 스냅샷만 참조한다.
- 차감 — 스냅샷에서 원자적으로 차감한다. 구현은 상세안 2에서 다룬다.
- 반영·보정 — 원장의 변동을 스냅샷에 언제, 어떤 경로로 반영할지는 트랜잭션 경계 문제와 함께 상세안 1에서 다룬다.
선택 이유
- 조회가 즉각적이다 — 비즈니스 로직이 참조하는 현재값을 한 번의 조회로 제공한다.
- 오버셀을 막을 지점이 생긴다 — 조건부 update 한 문장으로 DB 수준에서 원자적 차감을 보장한다. 락은 걸리지만 구간이 짧아서, 스터디 경험담의 데드락 유형(긴 트랜잭션 + 락 업그레이드)이 재현되지 않는다.
- 틀어져도 복구할 수 있다 — 스냅샷은 언제든 원장으로 재계산할 수 있는 캐시다. 스냅샷 단독 방식의 최대 약점은 값이 틀어졌을 때 근거가 없다는 점인데, 원장이 있으면 이 약점이 사라진다.
- 비즈니스의 허용 기준과 맞는다 — 재고가 있다고 보였는데 결제 시점에 품절 안내를 받는 상황은 감수할 수 있지만, 없는 재고가 팔리는 오버셀은 감수할 수 없다. 차감만 동기로 처리하고 나머지 반영은 늦추는 이 구조가 그 기준과 맞는다.
설계안 3 : 재고 예약 후 차감
설계안 1·2가 재고를 어디에 저장할지를 정했다면, 이 안은 언제 차감할지를 정한다. 주문 시작부터 결제 완료까지는 수십 초에서 수 분이 걸리는데, 차감을 이 구간의 어디에 두든 문제가 생긴다.
- 결제 완료 후 차감 — 결제하는 동안 다른 사용자가 마지막 재고를 사 갈 수 있다. 돈은 나갔는데 품절이라 환불해야 하는, 사용자 경험이 가장 나쁜 경로다.
- 결제 시작 시 차감 — 결제를 포기하거나 실패한 주문의 재고를 복원해야 한다. 복원 누락이 곧 재고 누수가 되고, 결제 창만 열어 재고를 잠그는 악용도 가능하다.
예약은 이 사이의 중간 상태다.
흐름
- 주문 시작 시 예약 — 판매 가능 수량에서 예약 수량을 잡아둔다.
- 결제 완료 시 확정 — 예약을 차감으로 전환하고, 원장에 판매차감 이벤트를 insert한다.
- 결제 실패·이탈 시 만료 — 유효 시간(TTL)이 지나면 예약을 자동 해제한다.
구조
- 스냅샷에
reserved_qty컬럼을 추가한다. 구매 가능 판단은available_qty - reserved_qty기준이다. 예약도 차감과 같은 조건부 update 한 문장으로 처리하므로 상세안 2의 패턴을 그대로 쓴다. - 예약은 원장에 기록하지 않는다. 예약은 대부분 몇 분 안에 확정되거나 소멸하는 임시 상태다. 불변 이력인 원장에 넣으면 예약-해제 쌍이 이력의 대부분을 차지해 보관 정책(상세안 4) 부담만 커진다. 예약은 별도 테이블이나 Redis에 TTL과 함께 두고, 원장에는 확정된 변동만 남긴다.
선택 이유
- 결제 중 품절을 차단한다 — 예약이 잡힌 재고는 다른 주문이 가져갈 수 없으므로, “결제했는데 품절” 경로가 사라진다.
- 가장 되돌리기 어려운 조치를 마지막에 두는 원칙과 맞는다 — 외부 PG 결제 전에 자사 예약만 잡으므로, 결제가 실패해도 복구는 자사 예약 해제로 끝난다. 자매편(포인트-결제 설계안)에서 포인트 선차감을 검토한 근거와 같은 원칙이다.
주의점
- 중간 상태 관리 비용이 생긴다 — “예약됐는데 확정도 만료도 안 된” 상태를 관리해야 한다. 만료 처리(TTL 배치 또는 지연 큐)가 추가 구현 대상이고, 대사 배치의 비교 대상도 늘어난다.
- TTL 길이가 트레이드오프다 — 너무 길면 이탈한 주문이 재고를 잠그고, 너무 짧으면 결제 중에 예약이 만료된다. 가상계좌처럼 결제 완료까지 오래 걸리는 수단은 별도 정책이 필요하다.
- 만료와 결제 완료의 경합을 처리해야 한다 — 만료 직후 결제 완료 응답이 도착하는 경우의 처리 순서를 정해둬야 한다.
- 도입 조건을 명시한다 — 결제 소요 시간이 길고 인기 상품 경합이 있는 서비스라면 필수다. 재고 여유가 있는 소규모 서비스라면 “결제 완료 후 차감 + 품절 시 환불”이 더 단순한 선택이다. 규모가 작으면 보정 주기로 충분하다는 스터디 논지와 같은 결이다.
설계안 비교
| 기준 | 원장 단독 | 스냅샷 단독 | 원장 + 스냅샷 |
|---|---|---|---|
| 현재고 조회 | 매번 합산 (느림) | 즉시 | 즉시 (스냅샷) |
| 원자적 차감 | 불가 | 가능 | 가능 (스냅샷) |
| 이력·감사 추적 | 완전 | 없음 | 완전 (원장) |
| 값이 틀어졌을 때 | 재계산으로 복구 | 복구 근거 없음 | 원장에서 재계산 |
| 쓰기 경합 | 없음 | 핫스팟 | 차감 순간만 |
원장 단독은 차감을, 스냅샷 단독은 복구를 포기해야 한다. 병행 구조는 두 테이블을 관리하는 비용을 내는 대신 어느 쪽도 포기하지 않는다. 여기에 설계안 3(예약)은 저장 구조와 별개로 차감 시점을 정하는 선택지로, 도입 조건에 해당할 때만 얹는다. 병행 구조의 비용이 구체적으로 무엇인지가 2부의 내용이다.
2부 : 상세안 — 코드 수준의 고민
설계안이 정해져도 코드 수준에서 다시 갈림길이 나온다. 두 테이블을 함께 운영하며 생기는 문제(상세안 1)와 각 테이블의 구현 규칙(상세안 2·3·4)을 정리한다.
상세안 1 : 트랜잭션 경계와 데드락
적용 지점 : 원장과 스냅샷을 함께 갱신하는 모든 쓰기 경로.
원장과 스냅샷을 함께 운영할 때 가장 조심할 지점이다. 정합성을 지키려고 두 로직을 하나의 트랜잭션으로 묶는 순간, insert와 update가 한 트랜잭션 안에서 섞이면서 데드락 조건이 만들어진다.
상황
- 정합성 걱정 때문에 “원장 기록(insert) + 스냅샷 갱신(update)“을 전부 한 트랜잭션에 묶는다. 입고도, 차감도, 취소 복원도 같은 방식으로 처리한다.
- 평소에는 문제가 없다. 요청이 몰리는 순간부터 락 대기가 쌓이기 시작한다.
- 스터디에서 나온 실제 경험담도 같은 구조였다. 재고 조회 → 차감 시점에 비관적 락을 걸어 관리했는데, 트래픽이 몰리자 대기가 쌓이며 데드락이 발생했다.
원인
- 한 트랜잭션 안에서 두 종류의 락을 잡는다.
- 원장 insert: 스냅샷으로 FK가 걸려 있으면, 참조된 스냅샷 row에 S lock(공유 락)을 잡는다.
- 스냅샷 update: 같은 row에 X lock(배타 락)이 필요하다.
- 트랜잭션 A와 B가 같은 상품을 처리하면 이렇게 꼬인다.
- A가 insert하며 스냅샷 row에 S lock을 잡는다.
- B도 insert하며 같은 row에 S lock을 잡는다. S lock끼리는 공존할 수 있어 여기까지는 통과한다.
- A가 update하려고 X lock으로 업그레이드를 시도한다 → B의 S lock이 풀리기를 기다린다.
- B도 update하려고 업그레이드를 시도한다 → A의 S lock이 풀리기를 기다린다.
- 서로가 서로를 기다린다. 데드락이다.
- 핵심은 락의 개수가 아니라 순서다. 공존 가능한 약한 락을 여럿이 잡은 뒤 강한 락으로 올리려는 패턴(락 업그레이드)이 데드락의 전형적인 조건이다.
해결 : 변동 유형별로 반영 경로를 나눈다
동기로 묶을 대상을 고르는 기준은 하나다. 지금 안 묶으면 오버셀이 나는가. 아니라면 트랜잭션 밖으로 뺀다.
| 변동 유형 | 반영 경로 | 근거 |
|---|---|---|
| 판매 차감 | 동기 (원장 insert + 스냅샷 update 한 트랜잭션) | 늦으면 오버셀 — 허용 기준상 불가 |
| 입고 | 비동기 (스케줄러 또는 이벤트) | 늦으면 품절로 보일 뿐 — 감수 가능 |
| 취소 복원 | 비동기 | 입고와 동일 |
- 차감만 트랜잭션으로 묶는다 — 이때 update는 조건부 한 문장(상세안 2)이라 락 점유 시간이 짧고, 합산·검증 로직을 락 안에 넣지 않는다.
- 입고·취소 복원은 원장에만 insert하고 끝낸다 — 스냅샷 반영은 스케줄러나 이벤트가 따로 처리한다. insert와 update가 한 트랜잭션에서 만나는 조합 자체를 없앤다.
- 원장 → 스냅샷 방향의 FK를 걸지 않는다 — 원인의 S lock은 FK 참조 때문에 생긴다. 스냅샷은 원장에서 언제든 재계산할 수 있는 파생 데이터이므로, 정합성을 FK가 아니라 대사 배치로 보장한다.
- 경합이 한계에 도달하면 차감을 직렬화한다 — 인기 상품 한 row에 차감이 몰리는 문제(핫스팟)는 락 구간 최소화로 대부분 버틸 수 있다. 한계를 넘으면 차감 이벤트를 카프카로 발행하고 컨슈머가 순차 처리한다. 같은 상품의 차감이 동시에 실행되지 않아 락 경합 자체가 사라진다. 스터디 경험담의 최종 해법도 이 방식이었다. 대신 차감 결과 확인이 비동기가 되므로 실시간 재고 확인 UX와의 트레이드오프를 감수해야 하고, 재고 버킷 분할 같은 대안도 함께 검토한다.
비동기 반영의 순서 보장
비동기 경로를 선택하는 순간 함께 구현해야 하는 규칙이다.
- 이벤트로 스냅샷을 갱신한다면 상품 단위 순서가 보장돼야 한다. 카프카라면 product_id를 파티션 키로 잡는다.
- 순서가 깨지면 늦게 도착한 이전 값이 최신 값을 덮어쓴다(스터디에서 “30을 반영한 뒤 18이 늦게 도착”하는 사례로 논의).
last_ledger_id보다 오래된 이벤트는 버리는 가드를 함께 둔다. - 입고를 스케줄러로 처리한다면 갱신 주기를 정한다(예: 정해진 입고 시간대에만 반영).
대사 배치
- 비동기 경로가 하나라도 있으면 스냅샷은 반드시 틀어질 수 있다고 전제한다. 대사 배치는 선택이 아니라 필수다.
- 주기 배치가
last_ledger_id이후 원장을 재집계해 스냅샷과 비교하고 보정한다. - 보정 주기는 오버셀이 발생하기 전에 잡을 수 있는지를 기준으로 정하고, 보정량이 임계치를 넘으면 알림을 울린다.
정리
- 데드락의 원인은 “원장과 스냅샷을 함께 쓰는 것”이 아니라 “두 로직을 무조건 한 트랜잭션으로 묶는 것”이다.
- 트랜잭션 경계를 정하면 나머지가 따라온다. 차감만 묶고, 비동기 경로에는 순서 보장을, 전체에는 대사 배치를 붙인다.
상세안 2 : 원자적 차감 — 조건부 update 한 문장
적용 지점 : 판매 차감. 오버셀 방어의 최종 지점이다.
문제 : “조회 → 검증 → 차감”으로 나누면 조회와 차감 사이에 다른 트랜잭션이 끼어들 수 있다. 이 틈을 락으로 막으려다 상세안 1의 데드락 구조로 돌아가게 된다.
해결 : 검증과 차감을 조건부 update 한 문장에 담는다. 영향받은 행이 0이면 품절로 처리한다.
UPDATE stock_snapshot
SET available_qty = available_qty - :n
WHERE product_id = :id AND available_qty >= :n
주의점 : 조건 available_qty >= :n이 DB 수준에서 오버셀을 막는다. 이 조건을 애플리케이션 검증으로 옮기는 순간 원자성이 깨지므로, 검증 로직을 밖으로 빼고 싶어져도 이 한 문장은 유지한다.
상세안 3 : 멱등성 — 유니크 제약으로 중복 기록 차단
적용 지점 : 원장 insert 전체. 재시도, 이벤트 중복 발행, 사용자 중복 클릭 모두 원인이 된다.
문제 : 같은 변동이 두 번 기록되면 원장의 합산 자체가 틀어진다. 파생 데이터인 스냅샷은 재계산으로 복구할 수 있지만, 원천인 원장이 오염되면 복구 기준이 사라진다.
해결 : 원장의 reference_id + type에 유니크 제약을 걸어 중복 insert를 막는다. 같은 주문(reference_id)의 같은 유형(type) 변동은 한 번만 기록된다.
주의점 : 멱등성 판단을 유니크 인덱스에 맡기면 테이블이 그 제약에 묶인다. 원장을 샤딩하면 유니크가 전역으로 보장되지 않고, 파티셔닝하면 유니크 인덱스에 파티션 키가 포함돼야 한다. 확장 시점에 걸림돌이 될 수 있다는 점은 자매편(포인트-결제 설계안)의 상세안 1과 같은 한계다.
상세안 4 : 원장 이력 보관 정책
적용 지점 : 원장 운영 전체. insert만 하는 구조라 데이터는 계속 쌓이고, 방치하면 합산 조회가 느려지고 디스크·백업 비용이 커진다.
전제 : 원장은 아무 row나 지울 수 없다
- 원장의 현재 재고는 이력 합산으로 구하므로, 중간 이력을 지우면 합산이 틀어진다.
- 지우려면 순서가 정해져 있다. 지울 구간의 합산 결과를 먼저 원장에 남기고, 그 다음에 원본을 정리한다.
- 이 순서만 지키면 감사 추적을 유지하면서 데이터를 정리할 수 있다. 오래된 구간의 상세 이력은 아카이브에, 합산 근거는 원장에 남는다.
해결 1 : 오래된 구간을 이월 이벤트 한 줄로 요약한다 (compaction)
- 오래된 구간의 이력을 상품별로 합산해, “2026-06 이월 재고 +1,200” 같은 조정 이벤트 한 줄로 insert한다.
- 이월 이벤트가 커밋된 뒤에는 그 구간의 원본 이력이 없어도 현재 재고 합산이 맞는다. 원본은 삭제하거나 아카이브로 보낼 수 있는 상태가 된다.
- 요약 시점은 건수가 아니라 두 가지 기준으로 잡는다.
- 합산 조회의 p99 지연이 기준선을 넘는가
- 정산 주기·법정 보관 기간이 지나 상세 이력을 온라인 DB에 둘 이유가 없는가
해결 2 : RANGE 파티셔닝으로 정리 비용을 없앤다
- 원장을
created_at기준 월 단위PARTITION BY RANGE로 나눈다. - 조회할 때는 최근 파티션만 읽는다(partition pruning). 합산 쿼리가 오래된 구간을 스캔하지 않는다.
- 정리할 때는 이월 이벤트를 남긴 구간의 파티션을 통째로 삭제한다(파티션 DROP).
- DELETE와 달리 디스크가 즉시 반환되고 단편화도 없다.
- undo 로그·binlog 부담이 없어서 복제 지연도 만들지 않는다.
- 오래된 데이터 정리 방법 중 비용이 가장 싸고, 원장처럼 시간순으로 쌓이기만 하는 테이블에 잘 맞는다.
해결 3 : 지울 수 없는 구간은 압축 아카이브로 이관한다
- 감사·정산 대응 때문에 상세 이력을 남겨야 하면 삭제 대신 이관한다. 두 가지 보관처가 있다.
- DB 안에 두는 경우 — 압축 아카이브 테이블. SQL로 바로 조회할 수 있는 상태를 유지하면서 디스크를 줄인다.
ROW_FORMAT=COMPRESSED KEY_BLOCK_SIZE=4같은 압축 포맷으로 아카이브 테이블을 만들고INSERT ... SELECT로 이관한다.- 압축 테이블은 버퍼 풀에 압축 페이지와 해제 페이지가 이중으로 올라가므로, 쓰기가 계속 들어오는 원장 본체에는 걸지 않는다. 읽기만 하는 아카이브 테이블에는 적합하다.
KEY_BLOCK_SIZE는 실제 데이터 샘플로 압축률을 재보고 정한다. 너무 작으면 압축 실패와 페이지 분할이 반복되며 오히려 느려진다.
- DB 밖으로 내리는 경우 — S3 같은 객체 저장소에 Parquet이나 CSV+gzip으로 내린다. 조회 빈도가 거의 없고 보관 기간만 길다면 이쪽이 더 싸다.
해결 4 : 파티셔닝이 안 되어 있다면, DELETE는 나눠서 지운다
기존 테이블이라 파티션 DROP을 쓸 수 없을 때의 차선책이다. 대량 DELETE는 그 자체가 장애 요인이라 지키는 순서가 있다.
- 이관과 삭제를 한 트랜잭션으로 묶지 않는다 — 배치 단위로 이관 → 건수 검증 → 삭제 순서로 반복한다. 한 트랜잭션에 묶으면 undo 로그가 비대해지고 실패 시 롤백도 그만큼 오래 걸린다. 상세안 1과 같은 원칙이다. 함께 실패해야 하는 것만 묶는다.
- 검증 전에 지우지 않는다 — 아카이브 쪽 커밋과 건수 확인이 끝난 범위만 삭제 대상으로 삼는다.
- PK 범위나 LIMIT으로 나눠 지운다 — 한 번에 지우면 undo 로그가 커지고, row 기반 binlog에 삭제된 행이 전부 기록돼 복제 지연이 생긴다. 배치 사이에 쉬면서 복제 지연을 모니터링하고, 작은 배치로 시작해 크기를 조절한다.
- 삭제 조건이 인덱스를 타는지 확인한다 — 인덱스가 없으면 풀스캔에 잠금 범위도 넓어져서(gap lock 포함) 운영 쿼리가 대기한다.
- 지워도 디스크는 반환되지 않는다 — 공간 회수까지 필요하면
information_schema.TABLES의DATA_FREE로 단편화를 확인한 뒤 일회성으로OPTIMIZE TABLE을 돌린다. 테이블 전체를 복사하는 작업이라 트래픽 적은 시간대에, 긴 쿼리가 없는지 확인하고 실행한다. 정기 실행은 매번 전체 복사 비용을 내는 낭비가 되기 쉽다.
정리
- 보관 정책은 세 단계다. 이월 이벤트로 합산 근거를 남기고 → 파티션 DROP으로 지우고 → 지울 수 없으면 압축 아카이브로 이관한다.
- 원장을 처음 만들 때부터
created_at기준 RANGE 파티셔닝을 걸어둔다. 파티셔닝은 나중에 붙이려면 테이블 재구성이 필요해서, 미리 걸어두지 않으면 가장 싼 정리 수단인 파티션 DROP을 쓸 수 없다.
남은 질문
다음 스터디(스텝 1·2 심화, 아키텍처 발표)에서 확인할 것들.
- 재고 예약의 만료 구현 — 설계안 3의 만료 처리를 TTL 배치와 지연 큐 중 무엇으로 구현할 것인가. 만료 직후 결제 완료가 도착하는 경합의 처리 순서도 정할 것
- 트래픽 10배~100배 확장 — 읽기 레플리카, 원장 샤딩, 재고 버킷 분할을 어느 순서로 적용할 것인가
- 쿠폰 선착순 발급 — 재고 차감과 같은 구조로 풀 수 있는가, 정률·정액 할인 계산과 실패 처리는 어떻게 다른가