빌드노트
PostgreSQL DEFAULT가 있는데 명시적 NULL은 그대로 저장된다
PostgreSQL에서 열 생략·DEFAULT·명시적 NULL·지정값을 RETURNING으로 비교한다. NOT NULL 대조를 통해 기본값 요청과 NULL 거부를 구별하고 INSERT 입력 계약을 정한다.
PostgreSQL 열에 DEFAULT 'pending'이 있는데 INSERT 결과가 NULL이라면, 먼저 실제 INSERT의 열 목록과 전달한 값을 확인한다. 열을 생략하거나 값 자리에 DEFAULT를 써야 기본값이 적용된다. 열을 포함하고 NULL을 보내면, NULL을 허용하는 열에는 그대로 NULL이 저장된다.
예를 들어 INSERT INTO insert_defaults (case_name, state) VALUES ($1, $2)에서 두 번째 매개변수가 NULL이면 state 열을 생략한 것이 아니다. 애플리케이션 객체에 값이 없다는 사실만으로 판단하지 말고, DB에 보낸 열 목록과 매개변수 값을 함께 본다. 기본값 선언을 다시 추가하는 것보다 이 입력을 구분하는 일이 먼저다.
같은 열에 네 가지 방식으로 넣어 본다
다음은 새 PostgreSQL 18.6 DB에서 실행한 비교다. 상태를 저장하는 state 열에 기본값만 선언하고 입력 방식만 바꿨다. 기본값 설명 (새 탭에서 열림)과 INSERT 문서 (새 탭에서 열림)에 정의된 열 생략·DEFAULT·직접 지정의 차이를 확인한다.
직접 확인하려면 PostgreSQL 서버와 psql, 임시 테이블 생성 권한이 있는 연습용 DB가 필요하다. 새 psql 연결의 기본 자동 커밋 상태에서 아래를 실행한다. \pset은 NULL을 빈칸 대신 [NULL]로 표시하는 psql 명령이다. 임시 테이블은 연결이 끝나면 사라진다. 각 INSERT의 RETURNING으로 실제 저장된 상태를 바로 확인한다.
\pset null '[NULL]'
CREATE TEMP TABLE insert_defaults (
case_name text,
state text DEFAULT 'pending'
);
INSERT INTO insert_defaults (case_name)
VALUES ('omitted') RETURNING case_name, state;
INSERT INTO insert_defaults (case_name, state)
VALUES ('keyword', DEFAULT) RETURNING case_name, state;
INSERT INTO insert_defaults (case_name, state)
VALUES ('explicit-null', NULL) RETURNING case_name, state;
INSERT INTO insert_defaults (case_name, state)
VALUES ('provided', 'ready') RETURNING case_name, state;
각 RETURNING의 두 열을 제목 없이 |로 구분한 실제 출력이다. 일반 psql 화면에서는 열 제목과 행 수가 함께 표시될 수 있다.
omitted|pending
keyword|pending
explicit-null|[NULL]
provided|ready
omitted는 INSERT의 열 목록에 state가 없다. keyword는 열을 포함하되 값 자리에 SQL 키워드 DEFAULT를 썼다. 둘 다 테이블에 선언된 pending을 받았다. 반면 explicit-null은 값을 NULL로 지정했으므로 기본값을 요청하지 않았다. 별도로 state IS NULL도 참인 것을 확인했으며, 출력용 문자열 [NULL]이 저장된 것은 아니다. provided의 ready도 기본값으로 덮어쓰지 않았다.
별도로 SQL PREPARE (새 탭에서 열림)로 같은 두 열에 VALUES ($1, $2)를 넣는 문장을 준비했다. 두 번째 값으로 NULL을 전달하자 NULL이 반환됐다. 문자열 'DEFAULT'를 전달한 대조에서는 문자 그대로 DEFAULT가 저장됐다. SQL 키워드 DEFAULT와 문자열은 다르다. 이는 PostgreSQL의 준비된 문장으로 확인한 결과이며, 특정 ORM이나 드라이버의 SQL 생성 동작을 실행한 것은 아니다.
자기 환경에서 결과가 다르면 실제 INSERT 대상의 열 정의와 최종 SQL·바인딩 값을 대조한다. 위와 같은 nullable 일반 열에 명시적 NULL을 넣었는데 다른 값이 저장된다면, 트리거나 입력 변환 등 이 예제에 없는 경로도 확인한다.
NOT NULL은 기본값 대입이 아니라 거부 조건이다
NULL 상태를 금지하면서 생략한 상태는 pending으로 채우려면 state text NOT NULL DEFAULT 'pending'처럼 두 역할을 함께 선언한다. NOT NULL 제약 (새 탭에서 열림)은 NULL 입력을 기본값으로 바꾸지 않고 거부한다.
대조 실험에서는 같은 case_name text 열을 두고 state에만 NOT NULL을 더한 별도의 빈 임시 테이블을 사용했다. 명시적 NULL을 넣자 violates not-null constraint 오류와 SQLSTATE `23502` (새 탭에서 열림)가 나왔고, pending으로 대체된 행은 없었다. 이 입력은 BEGIN으로 시작한 트랜잭션에서 실패했으며 ROLLBACK으로 끝냈다.
그 뒤 생략·DEFAULT·'ready' 입력을 각각 새 트랜잭션에서 실행해 커밋했다. 반환값은 차례로 pending, pending, ready였고, 최종 3행에 NULL은 없었다. 실패한 NULL 입력과 성공 입력을 한 트랜잭션에 묶지 않은 결과다. 자신의 코드에서도 트랜잭션 안에서 이 오류가 났다면 먼저 롤백한 뒤 다음 입력을 확인해야 한다.
INSERT를 만드는 쪽에서 정할 입력 계약
위 관찰을 입력 정책으로 옮기면 다음 세 경우를 구분할 수 있다.
- 값을 주지 않았을 때 DB 기본값을 쓰려면 해당 열을 INSERT에서 생략하거나 값 자리에
DEFAULT를 쓴다. 문자열'DEFAULT'를 보내는 방식과 구분한다. - NULL이 “아직 정해지지 않음”이라는 유효한 상태라면 nullable 열에 명시적 NULL을 저장할 수 있다. 그 입력까지 기본값으로 치환하면 NULL의 의미가 사라진다.
- NULL 상태를 금지하되 생략은 허용하려면, 이 예제처럼 NULL이 아닌 기본값과
NOT NULL을 함께 사용한다. 입력을 만드는 쪽은 생략 시 기본값을 요청하고, 명시적 NULL은 오류로 처리한다.
외부 입력의 NULL도 “기본값을 원함”으로 취급할 정책이라면, 그 변환은 INSERT를 만드는 단계에서 명시적으로 정해야 한다. DB의 DEFAULT 선언이 알아서 두 입력을 합쳐 주지는 않는다. ORM을 사용 중이면 실제 SQL의 열 목록과 바인딩 값을 확인하고, 그 ORM이 열 생략을 지원하는 방식은 별도로 검증한다.
이번 비교는 PostgreSQL·psql 18.6에서 트리거 없는 임시 테이블과 고정된 text 기본값으로 실행했다. ORM 매핑, 기존 행의 NULL 정리, 운영 테이블의 제약 추가·잠금 영향은 검증하지 않았다. 이미 쓰는 테이블에 NOT NULL을 추가하는 작업은 기존 NULL의 처리 방침과 적용 절차를 따로 정해야 한다.