고객 데이터 다루기 · SQL 로 답 찾기 · 이론
가장 위험한 SQL 은 에러를 내지 않는다
한 줄 요약
SQL 에서 진짜 무서운 것은 문법 오류가 아니라, 에러 없이 그럴듯하게 틀린 결과를 돌려주는 세 가지 함정이다.
왜 이게 필요했나
문법이 틀린 쿼리는 즉시 알려 줍니다. 위험한 것은 실행되는 쿼리입니다. 고객 앞에서 숫자를 읽었는데 그 숫자가 틀렸다면, 그 프로젝트에서 이후에 말하는 모든 숫자가 의심받습니다.
FDE 가 특히 이 위험에 노출되는 이유가 있습니다. 남의 스키마이기 때문입니다. 어떤 열에 NULL 이 들어갈 수 있는지, 어떤 관계가 1:N 인지, 어떤 값이 논리적 삭제 표시인지를 모르는 채로 쿼리를 씁니다.
어떻게 동작하나
에러 없이 틀리는 대표적인 세 가지를 알아 두면 대부분을 피합니다.
첫째, NULL 과 NOT IN. 주문이 한 번도 없는 고객을 찾는다고 해 봅시다.
SELECT * FROM customers WHERE id NOT IN (SELECT customer_id FROM orders);orders.customer_id 에 NULL 이 한 건이라도 있으면 이 쿼리는 0 건을 반환합니다. 에러도 경고도 없습니다. 그리고 리포트를 쓰는 사람은 "주문 없는 고객이 한 명도 없다" 는 사업적으로 그럴듯한 결론을 그대로 적습니다.
원인은 3값 논리입니다. id NOT IN (1, 2, NULL) 은 참이 아니라 알 수 없음이 되고, 알 수 없음은 WHERE 를 통과하지 못합니다. 해법은 NOT EXISTS 입니다. 행이 반환되는지만 보므로 NULL 에 걸려 넘어지지 않습니다. 습관적으로 NOT EXISTS 를 쓰는 편이 안전합니다.
**둘째, COUNT(*) 와 COUNT(열).** 이 둘은 다릅니다. COUNT(*) 는 행을 세고, COUNT(열) 은 그 열이 NULL 이 아닌 행만 셉니다.
이 차이가 가장 자주 드러나는 곳이 LEFT JOIN 입니다. 티켓이 하나도 없는 고객은 조인 결과에서 티켓 쪽 열이 전부 NULL 인 행 하나로 나타납니다. 이때 COUNT(t.id) 는 0 을 주고 COUNT(*) 는 1 을 줍니다. 후자를 쓰면 티켓이 없는 고객이 티켓 1건을 가진 것으로 집계됩니다.
같은 이유로 COUNT(closed_at) 은 닫힌 티켓만 셉니다. 전체 티켓이 80건인데 이 값이 60이라면, 20건은 아직 열려 있다는 뜻입니다. 이것을 알고 쓰면 유용한 관용구이고, 모르고 쓰면 조용한 오답입니다.
셋째, LEFT JOIN 에 붙인 WHERE. 왼쪽 테이블을 전부 보존하려고 LEFT JOIN 을 써 놓고, WHERE 절에 오른쪽 테이블의 열 조건을 붙이면 그 순간 매칭 없는 행이 전부 탈락합니다. NULL 은 어떤 비교와도 참이 되지 않기 때문입니다. 결과적으로 INNER JOIN 과 같아집니다. 오른쪽 테이블에 대한 조건은 WHERE 가 아니라 ON 절에 넣어야 합니다.
현장에서 만나는 모습
여기에 sqlite 특유의 함정을 하나 더합니다. CSV 를 .import 로 적재하면 모든 값이 TEXT 로 들어갑니다. 그러면 WHERE id = 1001 이 0 건을 반환합니다. 저장된 것은 문자열 '1001' 이기 때문입니다. 조인도 마찬가지로 조용히 0 건이 됩니다.
CSV 를 적재했는데 조인 결과가 텅 비었다면 거의 항상 이 이야기입니다. 먼저 CREATE TABLE 로 타입을 정의하고 그다음에 적재하거나, 조회 시 CAST 를 씁니다.
마지막으로 실무 습관 하나. 집계 쿼리를 고객에게 보여 주기 전에 분모를 확인합니다. 전체가 몇 건인지, 그중 몇 건이 조건에 걸렸는지. 비율만 보고하면 5건 중 1건인지 5만 건 중 1만 건인지 알 수 없고, 두 상황의 의미는 완전히 다릅니다.
다음 실습에서 할 것
고객 지원 시스템 스냅샷을 sqlite 에 적재하고, 고객 수·확정 매출·최대 매출 고객 같은 평범한 질문부터 시작해, NULL 집계와 LEFT JOIN 이 필요한 질문까지 답을 냅니다. 마지막 질문의 답은 아마 예상과 다를 것입니다.