Skip to content
isdnetworks
Go back

재고가 마이너스인 상품이 팔리고 있었다

품절 처리가 안 된 상품이 있다는 얘기를 들었다. 주문이 들어왔는데 보낼 물건이 없다고 한다. 재고 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 절을 파싱만 하고 무시하므로 걸어도 안 걸린 것과 같다. UNSIGNEDTRIGGER 로 가야 했고 이건 값을 정리한 뒤에 따로 볼 일로 남겼다.

당장은 새 조건을 쓸 때 = 0 이 아니라 <= 0 으로 쓰고 범위를 가정할 때는 COUNT 로 한 번 세어 보며 빼는 쿼리에는 조건을 같이 거는 것으로 정했다.

정리


Share this post on:

Previous Post
로그인 방식이 배포 형태를 정했다
Next Post
인덱스를 넣었더니 다른 데가 느려졌다