값이 지켜야 할 규칙을 DB 스키마에 못 박는 것. 애플리케이션이 여러 개여도, 배치가 직접 SQL을 쏴도 뚫리지 않는다.
종류
CREATE TABLE product (
id BIGINT PRIMARY KEY, -- 유일 + NOT NULL
sku VARCHAR(50) NOT NULL UNIQUE, -- 유일 (NULL 은 여러 개 가능)
price INT NOT NULL CHECK (price >= 0), -- 값 범위
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT'
CHECK (status IN ('DRAFT','ON_SALE','SOLD_OUT')),
seller_id BIGINT NOT NULL REFERENCES seller(id) -- 참조 무결성
);
왜 애플리케이션 검증만으로 부족한가
-
애플리케이션이 하나가 아니다 — 웹 · 배치 · 어드민 · 데이터 이관 스크립트
-
사람이 직접 SQL 을 쏜다 — 운영 중 수동 보정
-
버그는 반드시 난다 — 검증을 빠뜨린 경로가 생긴다
-
DB 제약은 모든 경로가 반드시 통과하는 단 하나의 지점이다
애플리케이션 검증은 UX를 위해(친절한 오류 메시지), DB 제약은 데이터를 위해 존재한다. 둘 다 필요하다.
운영 중 제약 추가 — 락을 조심한다
-- ❌ 기존 행을 전부 검사하는 동안 테이블이 잠긴다
ALTER TABLE product ADD CONSTRAINT chk_price CHECK (price >= 0);
-- ✅ 두 단계로 나눈다 (PostgreSQL)
ALTER TABLE product ADD CONSTRAINT chk_price CHECK (price >= 0) NOT VALID; -- 즉시. 이후 데이터만 검사
ALTER TABLE product VALIDATE CONSTRAINT chk_price; -- 약한 락으로 기존 행 검사
대형 테이블에서 이 차이가 서비스 중단 여부를 가른다.
유니크 인덱스를 온라인으로 만들기
-- PostgreSQL: 쓰기를 막지 않고 인덱스를 만든 뒤 제약으로 승격한다
CREATE UNIQUE INDEX CONCURRENTLY uq_sku ON product (sku);
ALTER TABLE product ADD CONSTRAINT uq_sku UNIQUE USING INDEX uq_sku;
조건부 유일성
-- 삭제되지 않은 행에서만 sku 가 유일해야 한다
CREATE UNIQUE INDEX uq_sku_alive ON product (sku) WHERE deleted_at IS NULL;
부분 유니크 인덱스는 소프트 삭제와 유일성을 함께 쓸 때의 표준 해법이다. MySQL에는 없어서 별도 칼럼 트릭을 쓴다.
MySQL의 CHECK는 늦게 왔다
- MySQL 8.0.16 미만 — CHECK 를 문법상 받아들이지만 무시했다 (조용히!)
- MySQL 8.0.16 이상 — 실제로 강제한다
옛 버전 스키마를 그대로 옮겨 왔다면 CHECK가 실제로 동작하는지 확인해 봐야 한다.
면접 함정
- ❌ "제약은 성능을 떨어뜨리니 빼는 게 좋다" → 검사 비용보다 데이터가 망가진 뒤의 복구 비용이 훨씬 크다.
- ❌ "애플리케이션에서 검증하니 DB 제약은 중복" → 경로가 하나뿐이라는 가정이 틀렸다.