본문 바로가기
빌드노트 검색

빌드노트

PostgreSQL CHECK (price > 0)를 걸었는데 NULL이 저장된다

CHECK (price > 0)는 거짓인 0과 음수는 거부하지만 UNKNOWN인 NULL은 통과시킨다. PostgreSQL 18.6의 짧은 임시 테이블 비교로 이를 확인하고, 실제 열 정의를 읽어 필수 가격에는 NOT NULL도 선언하는 판단을 설명한다.

PostgreSQL의 가격 열에 CHECK (price > 0)를 선언했는데 NULL이 저장돼도 제약이 고장 난 것은 아니다. CHECK는 검사식이 거짓일 때 거부한다. NULL > 0은 거짓이 아니라 알 수 없음이므로 이 CHECK를 통과한다. 가격이 필수라면 price numeric NOT NULL CHECK (price > 0)처럼 NOT NULL도 선언해야 한다.

양수 조건과 필수 입력은 다르다

12 > 0은 참이고, 0 > 0과 -1 > 0은 거짓이다. 하지만 NULL은 비교할 값이 없으므로 NULL > 0의 결과도 NULL, 즉 UNKNOWN이다. PostgreSQL 18 비교 연산자 문서 (새 탭에서 열림)가 설명하는 일반 비교의 동작이다.

CHECK의 통과 조건 (새 탭에서 열림)은 참 또는 NULL이다. 따라서 이 양수 CHECK는 “가격이 있으면 양수여야 한다”를 표현하지만 “가격이 반드시 있어야 한다”까지 요구하지 않는다. 그 역할은 NOT NULL (새 탭에서 열림)이 맡는다. 반대로 NOT NULL만 선언하면 0과 음수는 막지 못한다.

현재 테이블부터 확인하려면 psql에서 \d public.products를 실행한다. public.products는 자신의 INSERT 대상 스키마와 테이블 이름으로 바꾼다. price 행의 Nullable에 not null이 있는지, Check constraints에 실제로 price > 0을 검사하는 식이 있는지 따로 읽는다. psql의 테이블 정의 조회 (새 탭에서 열림)는 열 속성과 관련 제약을 함께 보여 준다.

NULL이 저장되면서도 0은 거부된다

다음은 두 열 선언의 차이를 확인하는 작은 비교다. PostgreSQL 18 서버와 psql, 임시 테이블 생성 권한이 있는 전용 실험 DB가 필요하다. 그 DB에 새로 연결한 같은 세션에서 기본 자동 커밋 상태로 각 문장을 하나씩 실행한다. BEGIN으로 묶지 않으며, 의도한 INSERT 오류 뒤에는 다음 문장을 실행한다. 임시 테이블은 연결이 끝나면 사라진다.

CREATE TEMP TABLE check_only (price numeric CHECK (price > 0));
CREATE TEMP TABLE required_price (price numeric NOT NULL CHECK (price > 0));

INSERT INTO check_only VALUES (NULL);
INSERT INTO check_only VALUES (0);
INSERT INTO required_price VALUES (NULL);
INSERT INTO required_price VALUES (12);

SELECT price IS NULL AS saved_null FROM check_only;
SELECT price FROM required_price;

새 격리 컨테이너의 PostgreSQL·psql 18.6에서 위 SQL을 실행한 결과는 다음과 같았다.

  • check_only에 NULL: INSERT 0 1로 성공했다.
  • check_only에 0: violates check constraint 오류로 실패했다.
  • required_price에 NULL: violates not-null constraint 오류로 실패했다.
  • required_price에 12: INSERT 0 1로 성공했다.

마지막 두 조회의 실제 출력이다. t는 참이다. 첫 테이블에는 NULL 한 행, 두 번째에는 12 한 행이 남았다.

 saved_null
------------
 t
(1 row)

 price
-------
    12
(1 row)

핵심은 같은 CHECK가 0은 거부하고 NULL은 저장했다는 점이다. 별도 비교에서도 -1은 CHECK 위반으로 거부됐고, NOT NULL만 있는 열에는 0과 -1이 저장됐다. 오류 코드는 CHECK 위반이 23514, NOT NULL 위반이 23502였다. 공식 SQLSTATE 목록 (새 탭에서 열림)도 두 위반을 구별한다.

같은 예제에서 0까지 저장됐다면 실제 입력 대상과 CHECK 정의부터 대조한다. NULL이 거부됐다면 NOT NULL이나 NULL을 거부하는 다른 제약이 있는지 확인한다. 오류 뒤 조회까지 25P02로 실패했다면 자동 커밋 조건과 달리 여러 문장이 한 트랜잭션에 묶였는지 확인하고, 그 실패 상태를 먼저 롤백한다.

가격 미정 상태를 허용한다면 이 CHECK만 둘 수 있다. 필수 가격이라면 새 테이블의 열 정의나 스키마 변경안에 NOT NULL과 양수 CHECK를 함께 반영한다. 이미 쓰는 테이블은 기존 NULL을 어떻게 처리할지와 변경 중 쓰기·잠금 조건을 검토한 뒤 적용 절차를 정해야 한다.

이번 실행은 새 임시 테이블의 numeric 열에 명시적으로 넣은 12·0·-1·NULL을 확인했다. 기존 테이블의 제약 추가, 데이터 정리·이관, 운영 ALTER의 잠금 영향과 ORM 입력 경로는 검증하지 않았다.

← 전체 글 보기