SQL 쿼리·DDL 스키마의 null
목차
slecs_sample, todo, mber - 이 세 테이블명이 들어간 SQL 쿼리와 DDL 스키마 파일 5개를 건드린 날이었다. 표면상은 단순한 버그 수정이었는데, 파고들수록 null을 어떻게 다루느냐에 대한 오래된 빚이 쌓여 있었다는 게 드러났음.
재현 조건이 있었다
처음엔 간헐적 이슈인 줄 알았다. 정상 케이스에선 아무 문제 없이 돌아가다가, 특정 상황에서만 쿼리 결과가 엉뚱하게 나왔으니까. 로그를 뒤지면서 패턴을 추적하다 보니 재현 조건은 결국 세 갈래로 좁혀졌음.
재현 조건:
1. 특정 컬럼값이 null 또는 빈 값('')인 경우
2. 경계값 입력 시 (0, 음수, 최대값 등)
3. 타임아웃 상황에서의 상태 불일치
골치 아팠던 건, 이 조건들이 단독으로는 문제를 안 일으킨다는 점이었다. null 입력만 테스트하면 괜찮고, 경계값만 넣어도 괜찮은데 둘이 겹치면 동작이 달라지는 케이스가 있었음. 복합 조건 버그는 단위 테스트로는 잘 안 잡힌다. 케이스 조합이 기하급수적으로 늘어나서 커버하기 어렵고, "각 조건 따로따로 테스트했으니 괜찮겠지"라는 암묵적 가정이 끼어드는 탓도 있다.
SQL에서 null이 특히 까다로운 건, 일반 프로그래밍 언어의 null과 다르게 작동하기 때문이다. NULL = NULL은 TRUE가 아니라 NULL이고, NULL != NULL도 역시 NULL이다. WHERE col = NULL 같은 조건은 아무 행도 반환하지 않는다. 알면서도 잠깐 잊으면, 쿼리 논리를 아무리 들여다봐도 맞는 것 같은데 결과가 다르게 나오는 상황을 마주하게 됨.
이번 케이스에서 실제로 발생한 건 null 체크 누락과 잘못된 기본값 처리가 동시에 작용하는 구조였다. null 체크가 없으니 COALESCE나 IFNULL 같은 안전망 없이 null이 연산에 끼어들고, 기본값 처리도 느슨하니 빈 문자열이 0으로 캐스팅되거나 의도치 않은 타입 변환이 발생함. 어느 한 쪽만 고쳐선 해결이 안 됐던 이유가 여기 있다.
수정 방향 - DDL부터 방어하고, 쿼리에서 마무리
| 항목 | 내용 |
|---|---|
| 수정 파일 | 5개 |
| 주요 영역 | SQL 쿼리, DDL 스키마 |
| 발생 조건 | 경계값/null 입력 |
| 수정 방식 | 방어 코드 + null-safe 처리 |
DDL 스키마 쪽은 컬럼 정의 단계에서 막을 수 있는 건 최대한 막는 방향으로 갔다. NOT NULL DEFAULT ''처럼 빈 값은 허용하되 null은 DB 레벨에서 차단하거나, 반대로 nullable이 의미상 맞는 컬럼은 명시적으로 NULL을 표기해서 의도를 드러내는 방식. 스키마가 모호하면 쿼리 작성자가 null 가능성을 놓친다. DDL이 계약서 역할을 해줘야 쿼리 레이어에서 방어 코드가 줄어든다.
쿼리 레벨에선 null-safe 함수를 일관되게 적용했다.
-- 수정 전: null이 끼어들면 전체 표현식이 null로 전파됨
SELECT *
FROM slecs_sample
WHERE some_col = :input_val
AND another_col > 0;
-- 수정 후: null-safe 처리 + 경계값 명시적 처리
SELECT *
FROM slecs_sample
WHERE COALESCE(some_col, '') = COALESCE(:input_val, '')
AND COALESCE(another_col, 0) > 0;
mber와 todo 관련 쿼리도 같은 패턴으로 맞췄음. 다만 이 방식에 트레이드오프가 없는 건 아닌데, COALESCE를 컬럼에 씌우면 해당 컬럼에 걸린 인덱스를 못 타는 경우가 생긴다. 인덱스가 있는 컬럼이라면 조건 구조를 다르게 가져가야 한다.
-- 인덱스를 고려한 대안: null 케이스를 OR로 분리
SELECT *
FROM slecs_sample
WHERE (some_col = :input_val OR (some_col IS NULL AND :input_val IS NULL))
AND another_col > 0;
성능 민감한 쿼리는 이 방식이 낫다. 이번 수정 대상에 고부하 쿼리가 포함됐다면 이 부분까지 챙겼겠지만, 해당 케이스는 아니었으니 일단 가독성 우선으로 갔음.
경계값 케이스는 쿼리보다 상위 레이어에서 먼저 튕겨내는 게 의도 표현상 깔끔하다. 0이나 음수가 유효하지 않은 값이라면, 그걸 DB에까지 내려보내지 않고 호출 계층에서 차단하는 게 더 명시적임. 이번엔 쿼리 쪽 방어 코드를 우선 정리했고, 상위 레이어 검증 강화는 별도 태스크로 남겨뒀다.
로그 보강도 같이 했다. 버그 재발 시 원인을 빠르게 좁히려면 어떤 값이 쿼리에 들어왔는지 흔적이 남아야 한다. 기존엔 쿼리 실행 결과만 로깅하고 파라미터는 안 남기는 곳이 있었는데, 입력값도 함께 찍도록 수정했음. null이 들어오는 경로를 추적하는 데 이게 결정적으로 도움이 됐고, 다음에 비슷한 문제가 생겨도 시간을 많이 아낄 수 있게 됐다.
디버깅 순서를 어긴 대가
이번에도 처음엔 원인을 잘못 짚었다. 관계없는 코드를 꽤 오래 들여다봤음. 결국 제자리로 돌아와서 로그부터 다시 확인하고 데이터 흐름을 따라가는 방식으로 찾아냈는데, 순서를 처음부터 지켰으면 시간을 절반은 아꼈을 거라는 생각이 든다.
개인적으로 유효하다고 느끼는 순서는 이렇다:
- 로그부터 - 스택 트레이스와 입출력 값을 먼저 본다. 추측 전에 사실 확인.
- 재현 조건 확정 - 어떤 입력에서 발생하는지를 못 박아야 이진 탐색이 가능해진다.
- 범위를 절반씩 줄이기 - 코드베이스 전체가 의심 대상일 때, 어느 경계 이후에서 이상해지는지 찾는다.
- 가정 검증 - '아마 이럴 것이다'가 아니라 실제로 값을 찍어서 확인한다. null 관련 버그는 조용하게 틀린 값을 반환하는 경우가 많아서, 직접 확인하지 않으면 넘어가기 쉽다.
이 순서를 건너뛰고 "아마 여기겠지"로 시작하면, 코드를 많이 봐도 원인을 못 찾거나 엉뚱한 걸 고치는 일이 생긴다. 경험적으로 그랬음.
수정 후엔 동일 조건으로 재테스트했고, 유사 패턴이 있는 다른 쿼리도 같이 점검했다. 같은 실수가 여러 곳에 남아 있으면 고친 의미가 반감된다. 전부 잡았다는 보장은 없지만, slecs_sample, todo, mber 관련 주요 경로는 정리됐음.
SQL 표준에서 null은 "없는 값"이 아니라 "알 수 없는 값"으로 정의한다. 그 차이를 코드와 스키마에 일관되게 반영하는 게, 생각보다 꽤 오래 들여다봐야 하는 작업이더라.
댓글 0
첫 댓글 달아줘.