Skip to content
isdnetworks
Go back

같은 곳을 세 번 임시로 막았다

재고가 음수가 되는 문제가 있었다. 증상이 나올 때마다 그 자리의 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_noreason 으로 이미 처리한 것인지 보고 있으면 그냥 돌아간다. 세 번 막는 동안 원인을 못 봤던 것은 기록이 없었기 때문이었다.

두 번 막았으면 세 번째는 구조를 보는 것으로 기준을 잡았다. 고치기 전에 svn log 로 그 파일이 최근에 몇 번 같은 이유로 바뀌었는지 보고 두 번까지는 우연일 수 있지만 세 번이면 우연이 아니다.

정리


Share this post on:

Previous Post
다시 처리했는데 지연 목록에 남았다
Next Post
정리 작업이 끝나지 않았다