이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Primary Key, Foreign Key, Unique, Check는 각각 무엇을 보장하는가?
- 어떤 규칙을 애플리케이션 코드가 아니라 DB 제약으로 내려야 하는가?
- 제약 조건은 성능과 운영에 어떤 비용을 만드는가?
개요
제약 조건은 DB가 데이터 계약을 지키게 만드는 장치다. 애플리케이션 검증은 사용자 경험과 비즈니스 흐름을 담당하지만, DB 제약은 어떤 코드 경로로 들어와도 깨지면 안 되는 규칙을 막는다.
백엔드 개발자는 제약을 귀찮은 방해물로 보지 말고 운영 데이터의 마지막 안전장치로 봐야 한다.
원리
제약 조건은 “입력값을 한 번 더 검사하는 장치”가 아니라 데이터가 저장되는 마지막 순간에 불가능한 상태를 거부하는 계약이다. 애플리케이션 검증은 좋은 에러 메시지와 흐름 제어에 강하고, DB 제약은 동시성, 배치, 운영 쿼리, 장애 복구처럼 코드 경로가 흔들리는 상황에서 강하다.
실전 판단은 “제약을 걸 것인가 말 것인가”에서 끝나지 않는다. 어떤 컬럼을 식별자로 삼을지, 외부에 노출되는 번호와 내부 PK를 나눌지, FK 인덱스를 어떻게 둘지, 제약 위반을 어떤 API 에러로 변환할지까지 함께 설계해야 한다.
Primary Key
Primary Key는 행의 식별자다.
CREATE TABLE members (
id bigint PRIMARY KEY,
email varchar(255) NOT NULL
);PK는 안정적이어야 한다. 변경 가능한 이메일이나 닉네임을 PK로 쓰면 참조와 변경이 어려워진다.
외부 주문 번호처럼 사용자가 보는 식별자는 내부 PK와 분리하는 경우가 많다.
CREATE TABLE orders (
id bigint PRIMARY KEY,
order_no varchar(40) NOT NULL UNIQUE,
member_id bigint NOT NULL,
created_at timestamp NOT NULL
);id는 내부 조인과 FK에 쓰고, order_no는 고객센터, 결제사 연동, URL 노출에 쓴다. 외부 식별자는 추측 가능성, 길이, 정렬 의미, 재발급 가능성을 별도로 검토한다.
Foreign Key
Foreign Key는 참조 무결성을 보장한다.
CREATE TABLE orders (
id bigint PRIMARY KEY,
member_id bigint NOT NULL REFERENCES members(id)
);FK가 있으면 존재하지 않는 회원의 주문을 넣을 수 없다. FK를 쓰지 않는 선택도 가능하지만, 그 경우 애플리케이션과 운영 절차가 고아 데이터를 직접 책임져야 한다.
FK를 추가했다고 조회 성능까지 자동으로 해결되는 것은 아니다. PostgreSQL은 referencing column의 인덱스를 자동 생성하지 않으므로, 부모 row 삭제나 자식 조회 패턴이 있으면 child table의 FK 컬럼 인덱스를 별도로 검토해야 한다. MySQL InnoDB는 FK 검사에 필요한 인덱스를 요구하고 없으면 자동 생성할 수 있지만, 실제 조회 조건과 정렬에 맞는 인덱스인지는 여전히 확인해야 한다.
CREATE INDEX idx_orders_member_id_created_at
ON orders (member_id, created_at DESC);이 인덱스는 FK 검사용을 넘어 “회원의 주문 목록” 조회에도 맞춘 예다.
Unique
Unique는 중복을 막는다.
ALTER TABLE members
ADD CONSTRAINT uq_members_email UNIQUE (email);중복 가입, 중복 좋아요, 중복 쿠폰 발급처럼 동시성에서 깨지면 안 되는 규칙은 Unique가 강력하다.
CREATE TABLE post_likes (
post_id bigint NOT NULL,
member_id bigint NOT NULL,
PRIMARY KEY (post_id, member_id)
);Check
Check는 값의 범위를 제한한다.
ALTER TABLE orders
ADD CONSTRAINT chk_orders_total_price
CHECK (total_price >= 0);상태값도 제한할 수 있다.
ALTER TABLE orders
ADD CONSTRAINT chk_orders_status
CHECK (status IN ('CREATED', 'PAID', 'CANCELED'));복잡한 상태 전이는 애플리케이션이 관리하지만, 불가능한 값 자체는 DB가 막을 수 있다.
Check는 다른 테이블을 조회해야 하는 복잡한 규칙이나 시간에 따라 바뀌는 정책에는 잘 맞지 않는다. “금액은 음수가 아니다”, “상태값은 허용 목록 안에 있다”처럼 row 자체만 보고 판단할 수 있고 자주 바뀌지 않는 규칙에 적합하다.
비용과 판단
제약은 비용도 만든다.
- FK는 insert/update/delete 때 참조 검사를 한다.
- Unique는 인덱스를 만들고 쓰기 비용을 증가시킨다.
- Check는 값 검사를 수행한다.
- 잘못 설계한 제약은 migration과 테스트 데이터를 불편하게 만든다.
- 기존 운영 데이터에 제약을 추가할 때는 검증 쿼리와 lock, index 생성 방식, 실패 시 정정 계획이 필요하다.
- FK는 삭제 정책을 강제한다.
RESTRICT,CASCADE,SET NULL중 무엇이 도메인에 맞는지 명시해야 한다.
그러나 중요한 데이터 정합성 비용을 애플리케이션 코드만으로 감당하는 것보다 제약이 나은 경우가 많다.
실전 팁
- 중복 방지는 Unique로 끝맺는다.
- 참조가 중요한 도메인은 FK를 기본으로 검토한다.
- 금액, 수량, 상태값에는 Check를 검토한다.
- 제약 이름은 오류 분석이 가능하게 의미 있게 짓는다.
- 제약 위반 예외를 사용자 친화적 에러로 변환한다.
- FK 컬럼은 DBMS별 자동 인덱스 동작을 확인하고, 실제 조회/삭제 패턴 기준으로 인덱스를 설계한다.
- 외부에 노출되는 식별자와 내부 PK는 요구사항이 다르면 분리한다.
- 운영 테이블에 제약을 추가할 때는 먼저 중복, orphan row, null, 잘못된 status를 찾는 검증 SQL을 작성한다.
위험 신호!
- 모든 테이블에 PK만 있고 나머지 제약이 없다.
- 중복 방지를
exists검사에만 의존한다. - FK가 없는데 고아 데이터 점검도 없다.
- 상태값이 오타 문자열로 저장된다.
- 제약 위반 예외를 500으로 그대로 반환한다.
- PostgreSQL에서 FK를 만들었으니 child table 조회 인덱스도 생겼다고 착각한다.
- MySQL에서 자동 생성된 FK 보조 인덱스를 실제 API 조회에 맞는 인덱스 검토 없이 그대로 믿는다.
ON DELETE CASCADE를 편의로 붙여놓고 삭제 사고의 영향 범위를 계산하지 않는다.
확인 질문
- Unique 제약이 동시성에서 중요한 이유는 무엇인가?
- 동시에 여러 요청이 중복 검사를 통과해도 DB가 최종 저장 시점에서 중복을 막을 수 있기 때문이다.
- FK를 쓰지 않으면 어떤 책임이 생기는가?
- 참조 무결성, 삭제 순서, 고아 데이터 탐지, 운영 데이터 정리를 애플리케이션과 운영 절차에서 직접 책임져야 한다.
- Check 제약은 어떤 규칙에 적합한가?
- 금액이 음수면 안 된다거나 상태값이 허용된 값 중 하나여야 한다는 단순하고 안정적인 규칙에 적합하다.
- FK 컬럼 인덱스를 볼 때 DBMS별로 주의할 점은 무엇인가?
- PostgreSQL은 FK 선언만으로 referencing column 인덱스를 자동 생성하지 않으므로 별도 검토가 필요하고, MySQL InnoDB는 필요한 인덱스를 요구하거나 생성할 수 있지만 실제 조회 패턴에 맞는지는 따로 확인해야 한다.