중복 주문은 왜 동시에 실행하면 재고를 두 번 차감할까
주문 API에 아래 같은 코드가 있다.
1. 같은 주문이 있는지 조회한다.
2. 없으면 주문을 만든다.
3. 재고를 차감한다. 평소에는 잘 동작한다. 그런데 결제가 끝난 뒤 응답이 끊겼다. 사용자는 주문이 실패했다고 생각해 다시 누른다. 앱이 타임아웃 뒤 같은 요청을 재시도할 수도 있다. 두 요청이 거의 동시에 첫 조회를 통과하면, 둘 다 아직 주문이 없다고 판단한다. 유일성 제약 없이 사전 조회에만 의존하는 삽입은 이 경쟁을 막지 못한다.[1]
그 결과는 주문이 두 개 생기는 데서 끝나지 않는다. 재고가 두 번 차감된다.
이 문제를 "버튼을 두 번 눌렀다"로 축소하면 해결책도 버튼 비활성화에 머문다. 하지만 실제로는 같은 주문 시도인지 식별하는 책임, 주문 하나만 확정하는 책임, 재고를 한 번만 차감하는 책임, 다음 시스템에서도 다시 실행되지 않게 하는 책임이 서로 다르다.
같은 주문 재시도와 서로 다른 주문은 다르다#
먼저 두 장면을 구분해야 한다.
- 같은 주문 시도의 재전송: 응답 유실, 타임아웃, 앱 재시작, 중복 클릭으로 하나의 논리적 주문 요청이 여러 번 서버에 도착한다. 멱등성은 바로 이런 재시도를 중복 부수효과 없이 처리하기 위한 API 성질이다.[2][5]
- 서로 다른 정상 주문의 경쟁: 마지막 재고 하나를 두 고객이 동시에 주문한다. 이 경우에는 요청 키가 아니라 재고 행의 동시 변경을 제어해야 한다.[4]
둘 다 재고 문제처럼 보이지만 같은 실패가 아니다. 같은 주문 재시도는 한 번만 처리해야 하고, 서로 다른 주문은 공정하게 경쟁하되 재고보다 많이 팔면 안 된다.
멱등성 키는 "이 요청이 이미 처리한 같은 주문인가"를 묻는다. 잠금과 조건부 갱신은 "지금 이 재고를 바꿔도 되는가"를 묻는다.
하나가 다른 하나를 대체하지 않는다.
같은 주문 시도에는 식별자가 필요하다#
POST /orders는 같은 요청을 두 번 보내면 주문을 두 개 만들기 쉬운 변경 API다. 주문 버튼을 누르는 순간 클라이언트는 idempotencyKey를 만들고, 그 주문 시도를 재시도하는 동안에는 같은 키를 보내야 한다.
Idempotency-Key는 POST나 PATCH 같은 변경 요청을 다시 보내도 같은 작업을 한 번만 수행하게 만드는 방법이다. 같은 키는 같은 재시도에만 쓰고, 사용자가 새 주문을 시작하면 새 키를 써야 한다.[5]
이 구분이 없으면 흔히 이런 규칙을 만든다.
같은 고객이 같은 상품을 5분 안에 다시 주문하면 중복으로 본다.
안전해 보이지만 정상 주문도 막는다. 같은 상품을 두 번 사는 사용자는 존재한다. 장바구니를 나눠 결제하거나 선물용으로 한 번 더 주문하는 것도 가능하다. 고객·상품·짧은 시간창은 중복을 추측할 뿐, 사용자의 의도를 증명하지 못한다.
멱등성 키는 반대로 사용자가 시작한 주문 시도에 이름을 붙인다. 다만 키만 저장해서는 부족하다. 같은 키로 수량이나 배송지를 바꿔 새 주문을 보내는 실수도 막아야 한다. 요청의 핵심 필드를 지문으로 보관하고, 같은 키에 다른 요청이 오면 오류로 처리하는 정책이 필요하다.[5]
processing은 실패도 성공도 아니다#
같은 키로 들어온 요청에는 최소한 네 가지 결과가 필요하다.
| 상태 | 의미 | 두 번째 요청에 줄 응답 |
|---|---|---|
processing | 첫 요청이 아직 진행 중 | 새 주문을 만들지 않고 처리 중임을 알린다 |
confirmed | 주문과 재고 예약·차감이 확정됨 | 기존 주문 결과를 돌려준다 |
rejected | 재고 부족 또는 주문 규칙 위반 | 같은 거절 결과를 돌려준다 |
unknown | 외부 결제·통신 오류로 결과를 확정하지 못함 | 새 주문을 만들지 않고 결과 확인 경로를 제공한다 |
이 상태를 구분하지 않으면 두 번째 요청에 성공을 지어내거나, 반대로 새 주문을 만들어 버리기 쉽다.
특히 타임아웃은 곧 실패가 아니다. 서버가 주문과 재고 차감을 끝냈지만 응답만 잃었을 수 있다. 외부 결제 요청이 결제사에 도달했는지 알 수 없을 수도 있다. Stripe도 네트워크 오류 뒤에는 같은 키와 같은 파라미터로 재시도하고, 원래 결과가 불명확하면 새 작업을 시작하기 전에 먼저 결과를 조정·확인하라고 안내한다.[7]
그래서 processing과 unknown은 UI에서 불편해 보여도 숨기면 안 된다. 사용자는 "주문 접수 여부를 확인 중입니다"를 보고 주문 상태를 다시 조회할 수 있어야 한다. 시스템은 그 사이 새 주문을 만들지 않아야 한다.[2][7]
조회 후 생성은 동시 요청 앞에서 보장이 아니다#
멱등성 키를 받더라도 이 구현은 안전하지 않다.
SELECT * FROM orders WHERE idempotency_key = :key
-- 결과가 없으면
INSERT INTO orders (...) 두 트랜잭션이 동시에 조회하면 둘 다 결과가 없다고 볼 수 있다. "중복이 없음을 확인했다"는 현재 관찰일 뿐, 바로 다음 순간에도 중복이 없다는 보장은 아니다.
주문 하나만 확정하는 규칙은 DB가 강제해야 한다.
CREATE UNIQUE INDEX uq_order_request
ON orders (customer_id, idempotency_key); 그리고 충돌은 DB가 판정하게 한다.
INSERT INTO orders (customer_id, idempotency_key, request_hash, status)
VALUES (:customer_id, :key, :request_hash, 'processing')
ON CONFLICT (customer_id, idempotency_key) DO NOTHING; 삽입에 성공한 요청만 다음 주문 처리로 간다. 충돌한 요청은 기존 행을 조회해 processing, confirmed, rejected, unknown 중 무엇을 반환할지 결정한다.
PostgreSQL은 ON CONFLICT로 유일성 충돌 때의 대체 동작을 지정할 수 있다고 설명한다.[1] 여기서 유일성 제약의 역할은 명확하다. 같은 주문 요청 키를 가진 주문이 두 개 확정되는 것을 막는 것이다.[1]
이 인덱스를 customer_id + product_id에 걸면 안 된다. 그 조합은 "같은 상품을 두 번 사면 안 된다"는 전혀 다른 제품 정책을 강제한다.
재고 차감은 별도의 정합성 규칙이다#
이제 서로 다른 두 고객이 마지막 재고 하나를 주문한다고 가정해 보자. 두 요청의 멱등성 키는 다르다. 그러므로 둘을 중복으로 처리하면 안 된다.
재고는 애플리케이션에서 읽어 둔 수량을 나중에 저장하는 방식보다, DB가 현재 재고와 차감 조건을 한 번에 판정하도록 만드는 편이 안전하다.
UPDATE inventory
SET available_quantity = available_quantity - :quantity
WHERE product_id = :product_id
AND available_quantity >= :quantity; 영향받은 행이 하나면 차감 성공이다. 영이면 재고 부족이다. 이 조건부 갱신은 현재 상태를 변경 조건에 넣어, 재고가 이미 바뀐 뒤의 결정을 저장소에서 다시 판정한다.[4]
비관적 잠금이나 낙관적 잠금도 선택지다. 재고 예약, 한 주문의 여러 품목, 수량 계산처럼 규칙이 복잡하면 한 재고 행을 잠가 순서대로 처리해야 할 수 있다. PostgreSQL도 MVCC만으로 원하는 동작을 만들기 어려운 경우 명시적 잠금을 사용할 수 있다고 설명한다.[4]
하지만 잠금만으로는 같은 주문 요청의 재실행을 막지 못한다. 첫 요청이 재고를 차감하고 끝난 뒤 같은 요청이 다시 오면, 두 번째 요청도 잠금을 얻어 순서대로 재고를 또 차감할 수 있다.
잠금은 재고 경쟁을 다루고, 멱등성 키는 요청 중복을 다룬다.
주문과 재고를 어디까지 한 트랜잭션으로 묶을 것인가#
주문 행 생성과 재고 차감이 같은 DB에 있다면, 둘은 하나의 트랜잭션으로 묶는 것이 출발점이다.
1. 멱등성 키로 processing 주문 행을 하나만 확보한다.
2. 조건부 재고 차감을 실행한다.
3. 성공하면 주문을 confirmed로 바꾼다.
4. 재고가 부족하면 주문을 rejected로 바꾼다.
5. 트랜잭션을 커밋한다. 이 흐름은 "주문은 성공인데 재고 차감은 실패" 또는 "재고는 줄었는데 주문이 없다"는 중간 상태를 줄인다.
다만 결제사가 별도 시스템이라면 모든 일을 하나의 DB 트랜잭션으로 끝낼 수 없다. 결제 승인 시점에 재고를 예약할지, 결제 성공 뒤에 차감할지, 예약이 만료되면 언제 되돌릴지는 데이터베이스 기능이 아니라 판매 정책이다. 이 정책이 정해지지 않으면 기술적으로 맞는 잠금 방식을 골라도 주문 경험은 흔들린다.
주문 API가 한 번 안전해도 다음 단계가 남는다#
주문을 저장한 뒤 재고 서비스, 결제 서비스, 배송 생성, 알림 발송으로 이벤트를 보낸다면 중복 위험은 다음 시스템으로 이동한다.
DB 커밋은 성공했는데 이벤트를 발행하기 전에 프로세스가 멈출 수 있다. 반대로 이벤트가 나갔는데 DB 작업이 롤백될 수도 있다. transactional outbox는 주문 변경과 이벤트 기록을 같은 DB 트랜잭션에 넣어 이 이중 쓰기 문제를 줄이는 패턴이다.[6]
그렇다고 outbox가 모든 작업을 정확히 한 번 실행하게 만들지는 않는다. 메시지는 중복 전달될 수 있다.[3][6] 재고 예약, 결제 처리, 배송 생성 같은 소비자는 처리한 이벤트 ID를 영속적으로 기록하고, 같은 이벤트를 다시 받으면 기존 결과를 재사용하거나 건너뛰어야 한다.[3]
주문 API의 멱등성과 이벤트 소비자의 멱등성은 서로 다른 계층의 계약이다.
"Redis 락만 쓰면 되지 않나"라는 반론#
Redis 같은 분산 락으로 상품 단위 처리를 한 번에 만들 수는 있다. 하지만 이것만을 최종 정합성 장치로 두면 락 만료, 네트워크 단절, 소유권 상실, 락을 잡은 뒤 DB 반영 전 실패 같은 더 어려운 상태를 다뤄야 한다.
분산 락은 특정 임계 구역의 부하를 줄이는 보조 수단이 될 수 있다. 하지만 주문 하나의 확정은 DB 유일성 제약으로, 재고 수량의 하한은 조건부 갱신이나 DB 잠금으로 보장하는 편이 더 직접적이다. 락은 그 규칙을 대신하기보다 보완해야 한다.
무엇을 한 번만 하게 만들 것인가#
중복 주문을 막는 설계는 기술 하나를 고르는 일이 아니다. 아래 질문에 각각 답해야 한다.
- 같은 주문 시도에 재시도 내내 유지되는 식별자가 있는가?
- 그 식별자가 DB 유일성 제약으로 보호되는가?
- 같은 키가 처리 중·성공·거절·결과 미상일 때 각각 다른 결과를 돌려주는가?
- 서로 다른 주문이 경쟁해도 재고가 음수가 되지 않도록 조건부 갱신 또는 적절한 잠금이 있는가?
- 주문 확정과 재고 차감이 원자적으로 묶이는가?
- 결제·배송·알림 이벤트가 다시 전달돼도 같은 부수효과를 반복하지 않는가?
- 새 주문에는 반드시 새 키가 만들어지는가?
중복 주문을 막는 코드는 버튼을 비활성화하는 코드가 아니다. 같은 주문인지, 새 주문인지, 재고가 남았는지, 후속 작업이 이미 실행됐는지를 시스템이 각자 증명하게 만드는 코드다.