주문서에서 배송 요청사항이 저장이 안 됐다. 화면에서는 입력했는데 DB에 없었다.
Table of contents
Open Table of contents
증상 — 저장이 안 되던 요청사항
화면에서 보내는 쪽은 이렇다.
<input type="text" name="deliveryMemo" />
서버에서 받는 쪽은 이렇다.
$memo = $this->input->post('delivery_memo');
deliveryMemo 와 delivery_memo 가 안 맞는다.
브라우저가 보낸 본문에는 값이 제대로 실려 있었다. 나가는 쪽과 받는 쪽 사이에서 deliveryMemo 라는 이름 하나 때문에 끊긴 상태였다.
화면을 만든 사람과 서버를 만든 사람이 각자의 관례대로 이름을 적은 결과로 보인다. 한쪽은 낙타 표기를 쓰고 다른 쪽은 밑줄 표기를 써서 둘 다 자기 코드 안에서는 일관돼 있었다.
원인 — 오류가 안 나는 자리
없는 키를 꺼내면 그 자리가 빈 값이 된다.
$data = [
'delivery_memo' => $this->input->post('delivery_memo'),
...
];
$this->db->insert('orders', $data);
delivery_memo 컬럼이 NULL 을 허용해서 INSERT 도 정상으로 끝난다.
실행이 어디에서도 안 멈추고 화면도 성공으로 돌아온다. 사용자가 값이 없다고 말해야 비로소 드러난다.
조용히 빈 값이 되는 자리가 어긋남을 감추고 있었다. 오류가 났으면 배포 당일에 알았을 것을 두 달이 지나서야 알게 된 셈이다.
빈 문자열을 허용하는 컬럼과 없는 키를 조용히 넘기는 함수가 겹치면 이런 상태가 만들어진다. 둘 다 각각은 편의를 위한 설계인데 겹치는 자리에서는 어긋남을 감추는 쪽으로 작동했다.
어긋난 것을 더 찾았다
같은 일이 다른 자리에도 있는지 양쪽 이름을 뽑아서 맞춰 봤다.
$ grep -oh 'name="[^"]*"' application/views/order/*.php | sed 's/name="//;s/"//' | sort -u > sent.txt
$ grep -oh "post('[^']*')" application/controllers/Order.php | sed "s/post('//;s/')//" | sort -u > recv.txt
$ comm -23 sent.txt recv.txt
deliveryMemo
giftMessage
agreeMarketing
보내는데 안 받는 것이 셋이었고 deliveryMemo 말고 둘이 더 있었다.
반대 방향도 봤다.
$ comm -13 sent.txt recv.txt
coupon_no
coupon_no 는 화면에서 지웠는데 서버 코드가 남아 있는 경우였다.
두 방향의 증상이 다르다. 보내는데 안 받으면 사용자가 입력한 값이 사라지고 받는데 안 보내면 그 항목이 언제나 빈 값으로 남는다.
한 방향만 보면 절반을 놓치므로 comm 을 두 번 돌려 양쪽을 다 꺼냈다. coupon_no 처럼 화면에서 먼저 지운 항목은 서버 코드에 남아 있어도 아무 증상이 없어서 더 오래 남는다.
조치 — 규격을 한 곳에 두었다
이름이 두 곳에 따로 적혀 있으니 어긋난다.
final class OrderForm {
public const FIELDS = [
'receiver_name' => ['required' => true, 'max' => 50],
'receiver_phone' => ['required' => true, 'max' => 20],
'delivery_memo' => ['required' => false, 'max' => 200],
];
}
FIELDS 하나에 이름과 필수 여부와 길이를 같이 뒀다.
화면도 이 목록으로 그리게 했다.
foreach (OrderForm::FIELDS as $name => $rule) {
echo '<input name="' . $name . '" ' . ($rule['required'] ? 'required' : '') . '>';
}
이름이 한 곳에서 나오니 어긋날 자리가 없어진다.
전부 이렇게 그리지는 못했다. 배치가 복잡한 화면은 손으로 두되 이름만 상수를 쓰게 했다.
<input name="<?= OrderForm::DELIVERY_MEMO ?>" />
손으로 적는 자리가 남아 있어도 그 값이 OrderForm 한 곳에서 온다는 점은 같다.
이름을 바꿀 일이 생기면 FIELDS 한 줄을 고치는 것으로 화면과 서버가 함께 따라온다. 두 곳을 각각 고치면서 한쪽을 빠뜨리는 경로 자체가 없어진 것이 이 변경의 값어치였다.
검증 — 모르는 항목과 필수 항목
서버가 모르는 항목이 오면 남기게 했다.
$known = array_keys(OrderForm::FIELDS);
$unknown = array_diff(array_keys($_POST), $known);
if ($unknown) {
log_message('warning', '모르는 항목: ' . implode(',', $unknown));
}
며칠 돌리니 두 개가 더 나왔고 둘 다 다른 화면에서 온 것이었다.
필수와 길이도 받는 자리에서 확인하게 했다.
foreach (OrderForm::FIELDS as $name => $rule) {
$v = $this->input->post($name);
if ($rule['required'] && ($v === null || $v === '')) {
$errors[$name] = '필수 항목입니다';
}
if ($v !== null && mb_strlen($v) > $rule['max']) {
$errors[$name] = "{$rule['max']}자를 넘습니다";
}
}
이름이 어긋나 있으면 필수 항목 쪽에서 걸린다.
앞에서 찾은 셋 중 하나가 이 검사를 넣자마자 걸렸다. 조용히 넘어가던 것이 요청을 받는 그 자리에서 멈추게 된 셈이다.
required 와 max 를 같은 목록에 둔 덕에 검사하는 코드가 항목 수와 무관하게 한 벌로 끝났다. 항목이 늘어도 FIELDS 에 한 줄을 더하면 검사까지 함께 붙는다.
이미 들어간 빈 값과 형태의 어긋남
지나간 것도 세어 봤다.
SELECT COUNT(*) FROM orders WHERE delivery_memo = '' AND reg_date >= '2017-09-01';
8,204
두 달간 8천 건이 요청사항 없이 들어갔다. 실제로 안 적은 사람도 있겠지만 전부가 그렇지는 않다.
되살릴 방법은 없어서 언제부터 언제까지였는지를 적어 CS 쪽에 알렸다.
이름이 맞는데 형태가 다른 것도 있었다. 전화번호를 어떤 화면은 하이픈을 넣어 보내고 서버는 숫자만 기대했다.
$phone = $this->input->post('receiver_phone'); // "010-1234-5678"
$this->db->insert('orders', ['receiver_phone' => $phone]);
그대로 넣으니 DB에 두 형태가 섞였다.
SELECT COUNT(*) FROM orders WHERE receiver_phone LIKE '%-%';
절반쯤이 하이픈을 달고 있어서 번호로 찾는 기능이 그 절반에서만 맞고 있었다. 보내는 쪽이 여럿이면 각각을 고치는 것보다 받는 쪽에서 형태를 맞추는 편이 확실했다.
화면 다섯 곳을 전부 고쳐도 새로 만드는 여섯 번째가 또 다르게 보낼 수 있다. 받는 자리에서 한 번 정규화하면 보내는 쪽이 몇이든 receiver_phone 에 들어가는 형태는 하나가 된다.
정리
- 화면의
name과 서버가 꺼내는 키가 다르면 조용히 빈 값이 된다 - 실행이 안 멈추고
INSERT도 정상으로 끝나 어긋남이 감춰진다 - 보내는 것과 받는 것을 뽑아
comm으로 양쪽 방향을 다 본다 - 보내는데 안 받으면 값이 사라지고 받는데 안 보내면 늘 빈 값이다
- 이름을
FIELDS한 곳에 두고 화면과 서버가 그것을 쓴다 - 손으로 그리는 화면도 이름만은 상수를 쓰게 한다
- 모르는 항목이 오면 기록에 남긴다
- 필수와 길이를 받는 자리에서 확인해 빈 값으로 넘어가지 않게 한다
- 이미 들어간 빈 값이 얼마인지 세고 알린다
- 보내는 쪽이 여럿이면 형태는 받는 쪽에서 맞춘다