string_agg 단언이 NULL 행을 조용히 흘린다 (SQL 마이그레이션 가드의 구멍)
문제 행 목록을 string_agg 로 만드는 사전 단언은 NULL 행을 건너뛰어 RAISE 가 발동하지 않습니다. 가드가 무력화되는 원리와 NOT NULL 제약, 빈 문자열 단언으로 막는 방법입니다.
운영 데이터를 바꾸는 SQL 스크립트에 사전 단언을 여러 겹 달았습니다. 대상 표(임시 테이블)가 비어 있으면 중단, 행 수가 선언한 개수와 다르면 중단, 대상이 실재하지 않으면 중단하는 식입니다.
전부 DO $$ … RAISE EXCEPTION 블록이고, 오류 메시지에는 string_agg 로 만든 "문제 행 목록"을 띄웠습니다. 그런데 어느 칸이 NULL 인 행은 이 단언들을 전부 그냥 통과했습니다.
NULL 행은 어디로 빠질까요
NULL || '…' 은 NULL 이고, string_agg 는 NULL 입력을 건너뜁니다. 그러면 문제 행 목록이 NULL 이 되고, IF bad IS NOT NULL THEN RAISE 는 발동하지 않습니다.
개수 검사만 통과한 채 나머지 행만 바뀐 상태로 커밋됩니다. 앞서 넣은 개수 가드가 이 경로로 정확히 무력화되는 거죠.
단언을 늘릴수록 안전해 보이지만, 목록을 문자열 연결로 만드는 순간 NULL 은 검사 대상이 아니라 검사의 빈칸이 됩니다.
두 겹으로 막았습니다
먼저 대상 표의 칸을 NOT NULL 로 선언합니다. 임시 테이블이라도 제약은 걸 수 있습니다. 제약 옆에는 "이 제약을 풀려면 아래 단언들을 먼저 NULL 안전하게 고칠 것"이라고 적어 둡니다. 제약이 단언의 전제라는 사실이 코드에 남아야 하니까요.
NOT NULL 이 못 막는 빈 문자열은 별도 단언으로 잡습니다(btrim(칸) = ''). 뒤쪽의 "대상 실재" 단언이 결국 잡긴 하지만, 실수한 자리를 가리키는 메시지가 앞에 있어야 합니다.
단언 순서는 싼 것, 구체적인 것부터 둡니다. 빈 표, 개수, 중복, 빈 값, 실재, 상태 일치 순입니다. 그래야 실수한 사람이 자기 실수를 가리키는 메시지를 먼저 봅니다.
-- 나쁜 예: NULL 행이 목록에서 빠져 단언이 침묵한다
SELECT string_agg(c.target || ' -> ' || c.new_value, ', ') INTO bad
FROM _cfg c WHERE NOT EXISTS (SELECT 1 FROM targets t WHERE t.id = c.target);
IF bad IS NOT NULL THEN RAISE EXCEPTION '없는 대상: %', bad; END IF;
-- 고친 예: 제약으로 NULL 을 원천 차단하고, 빈 문자열은 따로 잡는다
CREATE TEMP TABLE _cfg (
target text NOT NULL, -- 아래 단언들의 string_agg 가 NULL 을 건너뛰므로 이 제약이 전제다
new_value text NOT NULL
);
IF EXISTS (SELECT 1 FROM _cfg WHERE btrim(target) = '' OR btrim(new_value) = '') THEN
RAISE EXCEPTION '빈 값이 있다 (NOT NULL 은 빈 문자열을 막지 않는다)';
END IF;가드는 "통과했다"가 아니라 "검사했다"를 보장해야 합니다. 목록으로 말하는 단언을 쓸 때는 NULL 이 어디로 빠지는지부터 확인해 둘 만합니다.
참고
- PostgreSQL, 집계 함수의 NULL 처리: https://www.postgresql.org/docs/current/functions-aggregate.html
- PostgreSQL, 문자열 함수와 연산자 (
||의 NULL 전파): https://www.postgresql.org/docs/current/functions-string.html