품절 처리가 안 된 상품이 있다는 얘기를 들었다. 주문이 들어왔는데 보낼 물건이 없다고 한다. 재고 0인 상품을 판매중지로 바꾸는 배치가 매일 새벽에 도는데 그 배치가 왜 이 상품을 안 잡았는지 봤다.
Table of contents
Open Table of contents
원인 — 조건이 등호였다
배치의 쿼리는 이랬다.
UPDATE products
SET status = 'stop'
WHERE status = 'sale'
AND stock = 0;
문제의 상품을 조회했다.
SELECT id, name, stock FROM products WHERE id = 84213;
stock: -3
-3 이라 = 0 에 안 걸린다. 그런데 판매는 계속된다.
stock <= 0 으로 바꾸는 것은 간단한 수정이었다. 더 큰 물음은 왜 음수가 생겼느냐는 것이었다. 재고는 0보다 작아질 수 없다고 생각하고 있었다.
음수가 생긴 경로
재고를 깎는 코드를 봤다.
$this->db->query(
"UPDATE products SET stock = stock - ? WHERE id = ?",
array($qty, $productId)
);
빼기만 하고 결과가 음수인지 보지 않는다. 동시에 두 주문이 들어오면 이렇게 된다.
재고 1
주문 A: 1개 → stock = 0
주문 B: 1개 → stock = -1
주문 B 는 재고를 확인하고 들어왔다. 확인하던 시점에는 1이었고 확인과 차감 사이에 A 가 끼어들었다.
한 건씩 들어올 때는 안 보이고 특가로 주문이 몰릴 때만 생긴다. 그래서 로그를 봐도 재현이 안 됐다.
$this->db->query(
"UPDATE products SET stock = stock - ?
WHERE id = ? AND stock >= ?",
array($qty, $productId, $qty)
);
if ($this->db->affected_rows() === 0) {
// 재고가 모자라 아무 행도 안 바뀌었다
return false;
}
빼기 전에 조건을 같이 넣으면 DB 가 한 문장 안에서 검사와 차감을 한다. 중간에 다른 주문이 끼어들 틈이 없고 재고가 모자라면 affected_rows 가 0이 되므로 그것으로 실패를 안다.
얼마나 있는지 세어 봤다
한 건이 우연인지 계속 쌓이고 있었는지부터 확인했다.
SELECT COUNT(*), MIN(stock)
FROM products
WHERE status = 'sale' AND stock < 0;
COUNT(*): 17
MIN(stock): -9
열일곱 건이 판매 중이고 최솟값이 -9였다. 하나가 아니라 계속 새고 있었다.
그럴 리 없는 값이 있는지는 세어 봐야 안다. 코드를 아무리 읽어도 여기서 음수가 될 수 있다는 것을 놓치면 없는 것으로 결론이 난다.
값의 범위를 내 머릿속으로 판단하고 있었던 것이 문제였다. 그 뒤로 이런 가정이 있을 때는 먼저 세어 보게 됐다.
같은 조건이 복사돼 있었다
stock = 0 을 검색하니 세 군데가 더 나왔다.
상품 목록에서 품절 표시를 붙이는 곳
관리자 화면의 품절 상품 수
재고 알림 메일 대상 조회
셋 다 음수를 못 본다. 목록에서는 품절 표시 없이 노출되고 관리자 화면의 숫자는 실제보다 적게 나오며 알림은 안 간다.
한 곳에서 잘못된 조건은 보통 복사돼 있다. 고칠 때 같은 문자열을 검색해 전부 봤다.
stock 에 음수가 못 들어가게 막는 것도 봤다.
ALTER TABLE products MODIFY stock INT UNSIGNED NOT NULL DEFAULT 0;
UNSIGNED 면 음수가 안 들어간다. 다만 지금 넣으면 음수인 행이 있어서 변환에 걸리고 차감 쿼리가 음수로 갈 때 조용히 0이 되거나 오류가 나면서 다른 문제가 생긴다.
여기서 걸리는 것이 하나 더 있었다. 그 판의 MySQL 은 CHECK 절을 파싱만 하고 무시하므로 걸어도 안 걸린 것과 같다. UNSIGNED 나 TRIGGER 로 가야 했고 이건 값을 정리한 뒤에 따로 볼 일로 남겼다.
당장은 새 조건을 쓸 때 = 0 이 아니라 <= 0 으로 쓰고 범위를 가정할 때는 COUNT 로 한 번 세어 보며 빼는 쿼리에는 조건을 같이 거는 것으로 정했다.
정리
= 0은 음수를 놓친다. 0으로 판정하는 조건은<= 0으로 쓴다- 확인과 차감 사이에 다른 요청이 끼어든다
UPDATE ... WHERE id = ? AND stock >= ?한 문장으로 합친다affected_rows가 0인지로 실패를 판정한다- 그럴 리 없는 값은
COUNT(*)로 세어 보면 나온다. 코드를 읽어서는 안 나온다 - 잘못된 조건은 복사돼 있다. 같은 문자열을 검색해 전부 고친다
- 그 판의 MySQL 은
CHECK를 파싱만 하고 무시한다 UNSIGNED나TRIGGER로 범위를 지키게 한다- 값의 범위는 내 머릿속이 아니라 제약이나 실제 자료로 확인한다