본문으로 건너뛰기
wooncloud

string_agg 단언이 NULL 행을 조용히 흘린다 (SQL 마이그레이션 가드의 구멍)

문제 행 목록을 string_agg 로 만드는 사전 단언은 NULL 행을 건너뛰어 RAISE 가 발동하지 않습니다. 가드가 무력화되는 원리와 NOT NULL 제약, 빈 문자열 단언으로 막는 방법입니다.

5분 분량

운영 데이터를 바꾸는 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 이 어디로 빠지는지부터 확인해 둘 만합니다.

참고