最も危険な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만 건인지 알 수 없고, 두 상황의 의미는 완전히 다릅니다.
숫자를 내놓기 전에 하는 확인
앞의 세 함정을 피해도, 고객 앞에서 읽을 숫자라면 한 단계가 더 남는다. 그 숫자가 맞다는 것을 어떻게 보일 것인가이다.
다른 방법으로 한 번 더 센다. 조인으로 낸 합계를 조인 없이 하위 질의로도 내 보고 두 값이 같은지 본다. 두 방법이 같은 답을 주면 실수했을 가능성이 크게 줄고, 다르면 그 차이 자체가 무엇을 놓쳤는지 알려 준다. 1:N 관계를 조인한 뒤 합계를 내면 한쪽이 부풀려지는 사고가 여기서 잡힌다.
분모와 분자를 함께 적는다. 앞에서 말한 것과 같은 이야기인데, 보고서에 쓸 때는 한 걸음 더 나간다. "전환율 20%" 가 아니라 "5건 중 1건(20%)" 으로 적으면 읽는 사람이 그 숫자를 얼마나 믿을지 스스로 판단한다.
기간과 기준 시각을 적는다. "지난달 매출" 은 사람마다 다르게 읽는다. 어느 열을 기준으로 잘랐는지(주문 시각인지 결제 시각인지), 시간대가 무엇인지, 경계를 포함하는지까지 적어야 다음 사람이 같은 숫자를 다시 낼 수 있다.
제외한 것을 적는다. 시험 계정, 취소된 주문, 내부 직원의 요청 같은 것을 뺐다면 그 사실과 건수를 함께 적는다. 빼는 것 자체는 대개 옳지만, 적지 않으면 다른 사람이 낸 숫자와 어긋났을 때 원인을 찾는 데 몇 시간이 간다.
마지막으로 쿼리 자체를 남긴다. 결과만 전달하면 그 숫자는 몇 주 뒤에 재현할 수 없는 값이 된다. 쿼리와 실행 시각을 함께 남겨 두면 나중에 누가 "이 수치 어떻게 나온 거죠" 라고 물었을 때 몇 초 만에 답할 수 있고, 무엇보다 본인이 다시 볼 때 무엇을 가정했는지 알 수 있다.
다음 실습에서 할 것
고객 지원 시스템 스냅샷을 sqlite 에 적재하고, 고객 수·확정 매출·최대 매출 고객 같은 평범한 질문부터 시작해, NULL 집계와 LEFT JOIN 이 필요한 질문까지 답을 냅니다. 마지막 질문의 답은 아마 예상과 다를 것입니다.