NOT NULL, DEFAULT, CHECK 제약 — 데이터 품질을 DB에서 보장하는 방법

NOT NULL, DEFAULT, CHECK 제약의 정확한 동작 방식과 NULL의 세값 논리, 제약에 이름을 붙여야 하는 이유를 실무 예시와 함께 정리합니다.

· 6 min read · PALDYN Team

지난 글에서 날짜/시간 타입을 살펴봤습니다. 이번에는 열 제약 중 가장 기본이자 가장 많이 오용되는 NOT NULL, DEFAULT, CHECK를 자세히 다룹니다. 이 세 가지 제약을 제대로 이해하면 “애플리케이션에서 검증하면 되지 않나요?”라는 질문에 답할 수 있게 됩니다.

왜 DB에서 제약을 걸어야 하나

애플리케이션 레이어에서도 데이터를 검증하지만, DB 제약이 별도로 필요한 이유가 있습니다.

  1. 다중 진입점: API, 배치 잡, 마이그레이션 스크립트, 관리 도구 등 데이터가 들어오는 경로는 하나가 아닙니다.
  2. 버그 방어선: 애플리케이션 버그로 검증이 누락되어도 DB가 마지막 방어선 역할을 합니다.
  3. 자체 문서화: 스키마를 보면 어떤 값이 허용되는지 바로 알 수 있습니다.

열 제약의 역할과 검사 시점

NOT NULL

NOT NULL은 해당 열에 NULL 값이 들어오는 것을 차단합니다.

CREATE TABLE users (
    id         BIGINT      GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email      VARCHAR(200) NOT NULL,         -- 필수
    name       VARCHAR(100) NOT NULL,         -- 필수
    bio        TEXT,                          -- 선택 (NULL 허용)
    login_count INT         NOT NULL DEFAULT 0  -- 필수, 기본값 0
);

“이 열이 항상 값을 가져야 하는가?” — YES라면 NOT NULL. NO라면 NULL 허용(기본값).

NULL을 허용하는 열은 IS NULL, IS NOT NULL로 쿼리하고, COALESCE로 기본값을 제공합니다.

SELECT name, COALESCE(bio, '소개 없음') AS bio
FROM users;

NULL의 세값 논리 (가장 중요한 함정)

SQL의 NULL은 “값 없음”이 아니라 “알 수 없음(UNKNOWN)“입니다. NULL과의 비교 결과는 항상 UNKNOWN이므로, 일반 비교 연산자(=, !=, <)로는 NULL을 찾을 수 없습니다.

NULL의 세값 논리

-- 잘못된 쿼리: 항상 0행 반환
SELECT * FROM orders WHERE deleted_at = NULL;
SELECT * FROM orders WHERE deleted_at != NULL;

-- 올바른 쿼리
SELECT * FROM orders WHERE deleted_at IS NULL;
SELECT * FROM orders WHERE deleted_at IS NOT NULL;

WHERE 절에서 UNKNOWN은 행을 제외합니다. 이 때문에 NULL이 있는 열에 인덱스를 사용할 때도 주의가 필요합니다.

DEFAULT

DEFAULT는 INSERT 시 해당 열의 값이 생략되면 자동으로 지정한 값을 사용합니다.

CREATE TABLE orders (
    status     VARCHAR(20) NOT NULL DEFAULT 'pending',
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    retry_count INT        NOT NULL DEFAULT 0,
    is_active  BOOLEAN     NOT NULL DEFAULT TRUE
);

-- created_at, status, is_active 생략 가능
INSERT INTO orders (customer_id, total) VALUES (1, 50000);

DEFAULT 값으로 다음을 사용할 수 있습니다.

유형예시
리터럴DEFAULT 0, DEFAULT 'pending', DEFAULT TRUE
함수DEFAULT now(), DEFAULT gen_random_uuid()
표현식DEFAULT CURRENT_DATE + INTERVAL '30 days'

주의: DEFAULT는 열 값이 명시적으로 생략될 때만 적용됩니다. INSERT INTO t (col) VALUES (NULL)처럼 명시적으로 NULL을 넣으면 NOT NULL 제약을 위반합니다.

CHECK

CHECK는 임의의 불리언 표현식으로 허용 값을 제한합니다. 표준 SQL에서 지원하며 대부분의 DBMS가 구현합니다.

CREATE TABLE products (
    id      BIGINT  GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name    VARCHAR(200) NOT NULL,
    price   NUMERIC(12,2) NOT NULL CHECK (price >= 0),
    stock   INT           NOT NULL DEFAULT 0 CHECK (stock >= 0),
    rating  NUMERIC(3,1)           CHECK (rating BETWEEN 0 AND 5),
    status  VARCHAR(20)   NOT NULL
            CHECK (status IN ('draft', 'published', 'archived'))
);

체크 제약에 이름을 붙이면 오류 추적이 쉬워집니다.

-- 이름 없는 제약: 오류 메시지 "check constraint violated"
-- 이름 있는 제약: 오류 메시지 "check_price_non_negative violated"
CONSTRAINT check_price_non_negative CHECK (price >= 0)

테이블 제약으로 여러 열 조합 검증

열 제약은 해당 열 하나만 참조할 수 있습니다. 여러 열을 조합해 검증해야 할 때는 테이블 제약을 사용합니다.

CREATE TABLE discount_rules (
    id         INT PRIMARY KEY,
    start_date DATE NOT NULL,
    end_date   DATE NOT NULL,
    discount   NUMERIC(5,2) NOT NULL,
    CONSTRAINT check_date_range   CHECK (end_date > start_date),
    CONSTRAINT check_discount_pct CHECK (discount > 0 AND discount <= 100)
);

ALTER TABLE로 제약 추가/제거

기존 테이블에 제약을 추가하거나 제거할 수 있습니다.

-- 제약 추가
ALTER TABLE products
    ADD CONSTRAINT check_stock_non_negative CHECK (stock >= 0);

ALTER TABLE users
    ALTER COLUMN name SET NOT NULL;

ALTER TABLE orders
    ALTER COLUMN status SET DEFAULT 'pending';

-- 제약 제거
ALTER TABLE products
    DROP CONSTRAINT check_stock_non_negative;

ALTER TABLE users
    ALTER COLUMN bio DROP NOT NULL;

대규모 테이블에서 NOT NULL 추가는 기존 NULL 값을 먼저 업데이트해야 하므로 주의가 필요합니다.

정리

제약목적핵심 규칙
NOT NULLNULL 차단IS NULL / IS NOT NULL으로 비교
DEFAULT기본값 자동 삽입생략 시만 적용, 명시적 NULL에는 적용 안 됨
CHECK값 범위·패턴 검증이름 부여 권장, 다중 열은 테이블 제약

다음 글에서는 기본 키 설계의 원칙과 자연 키 vs 대리 키 논쟁을 다룹니다.


지난 글: 날짜와 시간 데이터 타입 — TIMESTAMP, DATE, INTERVAL 완전 정복

다음 글: 기본 키 설계 원칙


읽어주셔서 감사합니다. 😊