LabHub
배우기 러닝패스 코스

멱등성 — 두 번 눌러도 한 번만 결제되게 · 마지막 재고를 두 사람이 예약했다 · 이론

마지막 재고를 두 사람이 예약했다의 설계 원리

LabHub 에서 이어서 보기

한 줄 요약

재고 차감·예약 id·취소를 원자적으로 묶고 초과 판매를 막습니다.

개념 지도: 한 줄 요약 · 왜 이게 필요했나 · 어떻게 동작하나 · 계약을 읽고 실패를 예측하는 워크시트

왜 이게 필요했나

재고가 하나 남았는데 두 요청이 동시에 조회해 모두 예약에 성공했다. 이후 같은 취소 메시지가 두 번 도착하자 재고가 원래보다 늘었다. 예약의 중복 제거와 취소의 중복 제거는 서로 다른 상태 전이이며, 남은 수량 검사도 차감과 같은 트랜잭션에 있어야 한다.

어떻게 동작하나

stock은 현재 남은 수량, reservations는 예약 id·상품·수량·취소 여부를 보관한다. 같은 예약 id와 같은 내용의 재요청은 False로 끝나고, 다른 내용은 충돌이다. 최초 예약에서만 재고를 차감하며 취소는 active에서 cancelled로 바뀔 때 한 번만 재고를 돌려준다. 취소된 예약 id를 같은 예약으로 재사용해도 새로운 예약이 생기지 않는다.

재고 검사 + 예약 삽입 + 차감 → COMMIT예약 active → 취소 + 재고 반환 → cancelled → 재취소 무동작

계약을 읽고 실패를 예측하는 워크시트

다음은 구현을 통째로 외우는 답안이 아니라 단계별 코드 리뷰입니다. 각 변경 조각은 의도적으로 계약을 깨뜨립니다. 변경 후에도 정상 사례가 통과할 수 있다는 점에 주의하세요. 실행 전에 어느 입력·예외·상태를 관측하면 차이가 드러날지 예상하고, 구현 후에는 그 예상과 결과를 비교합니다.

1. 수량을 정수 계약으로 고정한다

quantity(value)는 bool 제외 양의 int만 반환하고 그 외 ValueError입니다.

판단의 근거: 음수 예약이 재고를 늘리지 못하게 합니다.

리뷰할 잘못된 변경 조각:

not isinstance(value,int)

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

2. 재고와 예약을 나누어 저장한다

init_db(path)는 stock(sku TEXT PRIMARY KEY,available INTEGER NOT NULL)와 reservations(id TEXT PRIMARY KEY,sku TEXT NOT NULL,qty INTEGER NOT NULL,cancelled INTEGER NOT NULL DEFAULT 0)를 멱등 생성합니다.

판단의 근거: 취소된 예약도 남겨야 같은 취소와 재예약을 구별할 수 있습니다.

리뷰할 잘못된 변경 조각:

CREATE TABLE reservations

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

3. 상품 초기화를 재고 변경과 구별한다

add_stock(path,sku,amount)는 bool 제외 0 이상 int를 검사하고 새 상품만 INSERT합니다. 중복 상품은 IntegrityError입니다.

판단의 근거: 같은 초기화 명령이 사용 중인 재고를 덮지 않게 합니다.

리뷰할 잘못된 변경 조각:

INSERT OR REPLACE INTO stock VALUES

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

4. 남은 수량을 조회한다

available(path,sku)는 저장된 수량을 반환하고 상품이 없으면 KeyError입니다.

판단의 근거: 없는 상품을 품절과 구별해 잘못된 상품 id를 숨기지 않습니다.

리뷰할 잘못된 변경 조각:

return 0

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

5. 예약과 재고 차감을 묶는다

reserve(path,rid,sku,qty,fault=lambda:None)는 수량을 검증합니다. 기존 예약 id는 같은 상품/수량이면 False, 다르면 ValueError입니다. 새 예약은 상품 존재와 재고를 확인해 차감→fault→예약 삽입 후 True입니다. 부족·없는 상품은 ValueError입니다.

판단의 근거: 검사와 차감을 같은 트랜잭션에 두고 장애 때 모두 롤백합니다.

리뷰할 잘못된 변경 조각:

db.commit()        fault()

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

6. 취소도 한 번만 반영한다

cancel(path,rid)는 없는 예약·이미 취소된 예약이면 False입니다. active 예약이면 같은 트랜잭션에서 cancelled=1과 재고 반환을 수행해 True입니다.

판단의 근거: 취소 메시지 재전송으로 재고가 계속 증가하지 않아야 합니다.

리뷰할 잘못된 변경 조각:

if row is None:

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

7. 예약 상태를 확인한다

reservation(path,rid)는 (sku,qty,cancelled) 튜플 또는 None을 반환합니다.

판단의 근거: 반환값뿐 아니라 취소 상태가 DB에 남았는지 읽습니다.

리뷰할 잘못된 변경 조각:

return db.execute("SELECT sku,qty,0

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

8. 마지막 재고 경쟁을 재현한다

compete(path,sku)는 reserve를 서로 다른 id first/second, 수량 1로 두 스레드에서 실행합니다. ValueError만 False로 바꾼 입력 순서 결과 리스트를 반환합니다.

판단의 근거: 재고 1과 재고 2인 두 상황을 비교해야 무조건 하나만 성공시키는 구현도 거를 수 있습니다.

리뷰할 잘못된 변경 조각:

["first","first"]

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

현장에서 만나는 모습

결제 승인과 재고 예약을 하나의 분산 트랜잭션으로 묶는 실습은 아니다. 예약 만료와 결제 실패의 보상은 별도 흐름이다. 조회 시점의 재고 숫자를 믿지 않고 쓰는 트랜잭션 안에서 확인하는 원리를 작은 SQLite DB로 배운다.

다음 실습에서 할 것

여덟 단계가 하나의 실행 가능한 결과물로 이어집니다. 수량을 정수 계약으로 고정한다 → 재고와 예약을 나누어 저장한다 → 상품 초기화를 재고 변경과 구별한다 → 남은 수량을 조회한다 → 예약과 재고 차감을 묶는다 → 취소도 한 번만 반영한다 → 예약 상태를 확인한다 → 마지막 재고 경쟁을 재현한다.

각 단계는 함수나 파일이 존재한다는 사실이 아니라 실제 반환값·예외·상태 변화를 검사합니다. 정답을 본 뒤에는 일부러 경계 비교나 정리 코드를 바꾸어 어떤 시험이 실패하는지 확인하세요. 앞선 시험이 다음 단계에서도 유지되는 이유를 설명하고, 이 실습이 보장하지 않는 운영 조건을 한 가지 적어 보세요.