재고가 음수가 되는 문제가 있었다. 증상이 나올 때마다 그 자리의 UPDATE 에 조건을 하나씩 붙여 막았다. 세 번째로 막고 나서야 막는 방식 자체를 바꿨다.
Table of contents
Open Table of contents
증상 — 재고가 음수가 됐다
처음에는 주문 처리에서 났다. 확인하고 빼는 순서였다.
if ($stock < $qty) {
throw new Exception('재고 부족');
}
$this->db->query("UPDATE product SET stock = stock - ? WHERE no = ?", [$qty, $no]);
그런데 여전히 음수가 났다. 확인과 빼는 것 사이에 다른 주문이 끼어든 것이었다.
$this->db->query("UPDATE product SET stock = stock - ? WHERE no = ? AND stock >= ?",
[$qty, $no, $qty]);
if ($this->db->affected_rows() === 0) {
throw new Exception('재고 부족');
}
조건을 쿼리에 넣고 affected_rows 로 성패를 봤다. 이걸로 끝난 줄 알았는데 며칠 뒤 또 음수가 났다.
세 번 다 다른 자리였다
다른 경로가 있었다.
// 관리자 화면
$this->db->query("UPDATE product SET stock = ? WHERE no = ?", [$newStock, $no]);
관리자가 재고를 직접 넣는 자리는 조건이 없었고 취소할 때 되돌리는 자리도 따로 있었다.
$ grep -rn "stock" --include=*.php application/models/ | grep -i update
재고를 고치는 자리가 일곱 군데였다. 각각을 막을 때는 그 수정으로 끝난 줄 알고 넘어갔고 다음에 다른 자리에서 같은 증상이 나오고서야 그것이 아니었다는 것을 알았다. grep -rn 한 번을 안 해 본 것이다.
세 자리의 WHERE 가 조금씩 다르게 적혀 있기도 했다. 한 곳은 >= 0 이고 다른 곳은 > 0 이었으며 svn blame 을 떠 보니 셋이 다른 리비전에서 다른 사람 손으로 들어간 것이었다.
조치 — 한 자리로 모으기
일곱 군데를 각각 막는 대신 한 자리로 모았다.
class StockModel {
public function change($productNo, $delta, $reason, $refNo = null) {
$this->db->trans_start();
$this->db->query("UPDATE product SET stock = stock + ? WHERE no = ? AND stock + ? >= 0",
[$delta, $productNo, $delta]);
if ($this->db->affected_rows() === 0) {
$this->db->trans_rollback();
return false;
}
$this->db->insert('stock_log', [
'product_no' => $productNo, 'delta' => $delta,
'reason' => $reason, 'ref_no' => $refNo,
'reg_date' => date('Y-m-d H:i:s'),
]);
$this->db->trans_complete();
return true;
}
}
빼는 것과 더하는 것을 delta 하나로 다룬다. 조건이 change 안의 WHERE 한 곳에 있고 새 자리가 생겨도 이 함수를 부르면 조건이 함께 붙는다.
직접 고치는 자리의 제거
모아 놓고 나서 직접 고치는 자리가 남았는지 다시 훑었다.
$ grep -rn "UPDATE product SET stock" --include=*.php application/
application/models/StockModel.php:14
한 곳만 남았고 나머지는 전부 change 를 부르게 바꿨다. 관리자가 직접 넣는 자리도 값이 아니라 차이로 바꿨다.
$delta = $newStock - $currentStock;
$this->stock->change($no, $delta, 'ADMIN_ADJUST', $adminNo);
현재 값과의 차이를 delta 로 만들어 넘긴다. 사람이 넣는 값이라고 검사를 빼면 사람이 실수하므로 예외를 두지 않는 것이 이 작업의 핵심이었다.
기록이 남으니 원인이 보였다
stock_log 가 쌓이고 나서 음수가 한 번 더 났다. 이번에는 무엇 때문인지 바로 봤다.
SELECT * FROM stock_log WHERE product_no = 8812 ORDER BY no DESC LIMIT 10;
delta reason ref_no
-2 ORDER 41022
-2 ORDER 41022 ← 같은 주문이 두 번
+2 ORDER_CANCEL 41022
같은 ref_no 가 두 번 뺐다. 결제 콜백이 두 번 온 것이었다.
if ($this->db->where(['ref_no' => $refNo, 'reason' => $reason])->count_all_results('stock_log') > 0) {
return true; // 이미 처리했다
}
ref_no 와 reason 으로 이미 처리한 것인지 보고 있으면 그냥 돌아간다. 세 번 막는 동안 원인을 못 봤던 것은 기록이 없었기 때문이었다.
두 번 막았으면 세 번째는 구조를 보는 것으로 기준을 잡았다. 고치기 전에 svn log 로 그 파일이 최근에 몇 번 같은 이유로 바뀌었는지 보고 두 번까지는 우연일 수 있지만 세 번이면 우연이 아니다.
정리
- 같은 증상을 반복해서 막으면 막는 방식이 안 맞는 것이다
grep -rn으로 그 컬럼을 건드리는 자리가 몇 군데인지 센다- 자리마다 막으면 새 자리에서 또 빠진다
- 흩어진
WHERE는 한 곳이>= 0이고 다른 곳이> 0이 된다 change하나로 모아 조건이 한 곳에 있게 하고affected_rows로 성패를 낸다- 직접
UPDATE하는 자리를grep으로 찾아 없앤다 - 관리자 입력도 값이 아니라
delta로 바꿔 같은 경로를 타게 한다 stock_log가 있으니 같은ref_no가 두 번 뺀 것이 바로 보였다svn blame을 뜨면 흩어진 조건이 언제 누구 손에 들어갔는지 나온다svn log로 그 파일이 같은 이유로 몇 번 바뀌었는지 보고 구조를 판단한다