테이블을 만들 때 PK와 FK를 생략해도 당장은 INSERT가 됩니다. 하지만 시간이 지나면 중복 ID, 고아 레코드, 삭제 사고가 쌓입니다. 키 제약을 정확히 설계하면 DB가 데이터 정합성을 대신 지켜줍니다.
기본키와 외래키는 관계형 데이터베이스의 무결성(Integrity)을 보장하는 핵심 메커니즘입니다. 잘못 설계하면 데이터 불일치, 고아(orphan) 레코드, 보안 문제로 이어집니다. 올바른 PK 전략과 FK 삭제 정책을 이해하면 스키마 설계 품질이 크게 향상됩니다.
- 1자연키 vs 대리키 — 어떤 PK 전략을 선택해야 하는가
- 2SERIAL, AUTO_INCREMENT, UUID — 각각의 장단점과 적합한 상황
- 3복합 기본키 — 언제 여러 컬럼을 묶어 PK로 쓰는가
- 4FOREIGN KEY 선언과 참조 무결성의 의미
- 5ON DELETE CASCADE / RESTRICT / SET NULL — 삭제 전파 전략 선택법
- 6DEFERRABLE — 트랜잭션 내 일시적 제약 완화
PK, FK, 제약조건 — 데이터 무결성 보장의 원리
데이터베이스에서 잘못된 데이터가 저장되는 것을 막는 가장 강력한 수단은 제약조건(Constraints)입니다. 그 중 기본키(PRIMARY KEY)와 외래키(FOREIGN KEY)는 관계형 데이터베이스의 핵심 설계 요소입니다. 이 모듈에서는 단순한 문법을 넘어, 각 선택의 이유와 실전 트레이드오프를 깊이 이해합니다.
PRIMARY KEY — 자연키 vs 대리키(Surrogate Key)
주민등록번호를 기본키로 쓰던 테이블이 있었습니다. 몇 년 후 개인정보보호법이 강화되면서 주민번호를 저장할 수 없게 됐습니다. 기본키를 바꾸려면 이 컬럼을 참조하는 모든 외래키, 인덱스, 애플리케이션 코드를 함께 바꿔야 합니다. 처음부터 대리키를 쓰지 않으면 나중에 이 마이그레이션 비용이 엄청납니다.

기본키란 무엇인가
기본키(PRIMARY KEY)는 테이블의 각 행을 고유하게 식별하는 컬럼(또는 컬럼 집합)입니다. DB는 자동으로 이 컬럼에 UNIQUE 인덱스를 생성하고 NOT NULL을 강제합니다.
자연키(Natural Key)의 문제
자연키는 데이터 자체에서 고유성을 갖는 값(이메일, 주민번호 등)을 PK로 사용하는 방식입니다. 직관적으로 보이지만 운영 환경에서 여러 문제를 일으킵니다.
CREATE TABLE users (
email VARCHAR(255) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
실행 완료 또는 조회 결과가 표시됩니다.
- PK 유일성—중복 ID가 들어가지 않고 각 행을 안정적으로 식별하는지 확인합니다.
- FK 정합성—참조 대상 없는 고아 레코드가 차단되는지 봅니다.
- 삭제 정책—CASCADE, RESTRICT, SET NULL이 비즈니스 규칙과 맞는지 점검합니다.
자연키가 현실에서 어떤 문제를 일으키는지 정리하면 대리키를 써야 하는 이유가 명확해집니다.
| 문제 | 설명 |
|---|---|
| 변경 가능성 | 이메일은 바뀔 수 있다. PK가 바뀌면 모든 FK도 연쇄 업데이트 필요 |
| PII 노출 | 이메일·주민번호가 PK이면 조인 쿼리, 로그, URL에 개인정보 노출 |
| 길이 문제 | 문자열 PK는 인덱스 크기가 커져 성능 저하 |
| 외부 의존성 | 주민번호 형식이 바뀌면 스키마 전체 수정 필요 |
대리키(Surrogate Key) — 현대적 표준
대리키는 데이터와 무관하게 시스템이 자동 생성하는 인위적 식별자입니다. 비즈니스 식별자(이메일, 사원번호)는 PK 대신 UNIQUE 제약으로 별도 관리합니다.
PostgreSQL에서는 BIGSERIAL(자동 증가 정수)과 UUID 두 가지가 주로 쓰입니다. MySQL/MariaDB에서는 AUTO_INCREMENT를 사용합니다.
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(200) NOT NULL,
price NUMERIC(10, 2) NOT NULL CHECK (price >= 0),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
total DECIMAL(10, 2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
BIGSERIAL vs UUID — 어떤 대리키를 선택할까
| 기준 | BIGSERIAL / AUTO_INCREMENT | UUID (v4) |
|---|---|---|
| 생성 방식 | DB 시퀀스 (중앙집중) | 각 노드에서 독립 생성 |
| 저장 크기 | 8 bytes | 16 bytes (또는 36자 문자열) |
| 정렬 가능 | 자연 정렬 (삽입 순) | 랜덤 — B-Tree 단편화 발생 |
| 분산 시스템 | 충돌 위험 (샤딩 시) | 충돌 없이 독립 생성 가능 |
| 보안 (추측 가능성) | 쉽게 추측 가능 (1,2,3...) | 추측 불가 (128비트 랜덤) |
| 가독성 | 짧고 읽기 쉬움 | 길고 복잡함 |
| 적합한 상황 | 단일 DB, 단순 서비스 | 분산 DB, 마이크로서비스, 보안 중요 |
실전 팁: UUID v7은 시간 순으로 정렬되어 B-Tree 단편화 문제를 해결합니다. PostgreSQL 17+에서
gen_random_uuid()대신 UUID v7 함수를 사용할 수 있습니다.
복합 기본키(Composite Primary Key)
두 개 이상의 컬럼을 묶어 PK로 사용합니다. 주로 다대다(M:N) 관계의 중간 테이블에 적합하며, 별도의 id 컬럼 없이도 중복 삽입을 방지할 수 있습니다.
CREATE TABLE user_roles (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id BIGINT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
granted_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE order_items (
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INT NOT NULL CHECK (quantity > 0),
unit_price NUMERIC(10, 2) NOT NULL,
PRIMARY KEY (order_id, product_id)
);
orders 테이블에 user_id 컬럼만 있고 FK 제약조건을 선언하지 않았습니다. 회원 탈퇴 처리 시 users 테이블에서 해당 사용자를 삭제했지만, orders 테이블에는 해당 user_id를 가진 수천 건의 주문이 남아 있는 고아 레코드가 발생했습니다. 조인 쿼리에서 사용자 정보가 NULL로 나타나 결제 정산 오류로 이어졌습니다.
원인: FK 제약조건이 없으면 DB가 참조 무결성을 검사하지 않습니다. 애플리케이션 코드에서 삭제 순서를 보장하지 않으면 언제든 고아 레코드가 생깁니다.
해결 방법: 설계 단계에서 모든 참조 관계에 FOREIGN KEY를 명시적으로 선언합니다. 기존 테이블에는 ALTER TABLE orders ADD CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id);로 추가합니다. 고아 레코드 정리는 DELETE FROM orders WHERE user_id NOT IN (SELECT id FROM users);로 수행합니다.
FOREIGN KEY와 CASCADE — 참조 무결성과 삭제 전파
사용자를 삭제했는데 그 사용자의 주문 내역이 테이블에 남아 있습니다. 이제 주문 내역의 user_id가 존재하지 않는 사용자를 가리킵니다. 외래키 제약이 없으면 이런 고아 데이터가 조용히 쌓입니다. 외래키와 CASCADE 옵션을 이해해야 데이터 무결성을 DB 레벨에서 보장할 수 있습니다.

외래키(Foreign Key)란
외래키는 한 테이블의 컬럼 값이 반드시 다른 테이블의 PK(또는 UNIQUE 컬럼)에 존재해야 함을 보장합니다. 이를 **참조 무결성(Referential Integrity)**이라 합니다. FK 제약조건을 선언할 때는 ON DELETE 정책을 반드시 명시해야 합니다. 기본값에 의존하면 팀원이 의도를 파악하기 어렵습니다.
운영 데이터에 적용하면 되돌리기 어려운 변경입니다. 실행 전 대상 테이블, WHERE 조건, 백업 또는 롤백 경로를 반드시 확인하세요.
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT fk_orders_user
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE RESTRICT
ON UPDATE CASCADE
);
ALTER TABLE order_items
ADD CONSTRAINT fk_order_items_product
FOREIGN KEY (product_id)
REFERENCES products(id)
ON DELETE RESTRICT;
ON DELETE 옵션 — 부모 삭제 시 자식 처리 방법
부모 레코드(참조 대상)를 삭제할 때 자식 레코드(FK를 가진 쪽)를 어떻게 처리할지 정의합니다.
| 옵션 | 동작 및 사용 시점 |
|---|---|
RESTRICT | 자식이 있으면 부모 삭제 거부 (기본값과 유사) |
NO ACTION | RESTRICT와 동일하나 DEFERRABLE과 함께 쓸 때 차이 |
CASCADE | 부모 삭제 시 자식도 자동 삭제 |
SET NULL | 부모 삭제 시 자식의 FK 컬럼을 NULL로 설정 (컬럼이 nullable이어야) |
SET DEFAULT | 부모 삭제 시 자식의 FK를 기본값으로 설정 |
각 옵션의 사용 원칙을 이해하는 것이 중요합니다. CASCADE는 "부모 없이는 자식도 의미 없는" 강한 종속 관계에만 써야 합니다. SET NULL은 부모가 사라져도 자식 레코드가 독립적으로 의미를 가질 때 적합합니다. RESTRICT는 실수로 인한 삭제를 방지해야 할 때 사용합니다.
CREATE TABLE cart_items (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
product_id BIGINT NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
quantity INT NOT NULL DEFAULT 1
);
CREATE TABLE posts (
id BIGSERIAL PRIMARY KEY,
author_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
title VARCHAR(500) NOT NULL,
content TEXT NOT NULL
);
CREATE TABLE order_items (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id BIGINT NOT NULL REFERENCES products(id) ON DELETE RESTRICT
);
CASCADE 남용 방지 — 소프트 삭제 패턴
users → orders → order_items → reviews처럼 CASCADE 체인이 여러 단계 연결되어 있으면, 사용자 1명을 삭제했을 때 수천 건의 주문·상품·리뷰가 모두 삭제되는 사고가 발생할 수 있습니다. 데이터 복구도 불가능합니다.
실제 삭제 대신 deleted_at 타임스탬프를 기록하는 소프트 삭제(Soft Delete) 패턴이 안전한 대안입니다.
운영 데이터에 적용하면 되돌리기 어려운 변경입니다. 실행 전 대상 테이블, WHERE 조건, 백업 또는 롤백 경로를 반드시 확인하세요.
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP NULL;
UPDATE users SET deleted_at = NOW() WHERE id = 42;
SELECT * FROM users WHERE deleted_at IS NULL;
DEFERRABLE — 트랜잭션 내 일시적 제약 완화
순환 참조나 복잡한 삽입 순서 문제가 있을 때 유용합니다. DEFERRABLE INITIALLY DEFERRED는 각 SQL 실행 직후가 아닌 COMMIT 시점에만 FK 제약을 검사합니다. 아래 예시에서 두 직원은 서로를 manager로 참조하므로 순서대로 삽입하면 첫 번째 INSERT가 FK 오류를 냅니다. DEFERRED 선언으로 이를 해결합니다.
운영 데이터에 적용하면 되돌리기 어려운 변경입니다. 실행 전 대상 테이블, WHERE 조건, 백업 또는 롤백 경로를 반드시 확인하세요.
ALTER TABLE employees
ADD CONSTRAINT fk_manager
FOREIGN KEY (manager_id)
REFERENCES employees(id)
DEFERRABLE INITIALLY DEFERRED;
BEGIN;
SET CONSTRAINTS fk_manager DEFERRED;
INSERT INTO employees (id, name, manager_id) VALUES (1, '김대표', 2);
INSERT INTO employees (id, name, manager_id) VALUES (2, '이부장', 1);
COMMIT;
실전 체크리스트
□ PK는 대리키(BIGSERIAL 또는 UUID) 사용 권장
□ 비즈니스 식별자(이메일, 사원번호)는 UNIQUE 제약으로 별도 관리
□ FK에는 명시적으로 ON DELETE 정책 지정 (기본값 의존 금지)
□ CASCADE는 진짜로 "함께 삭제되어야 하는" 관계에만 사용
□ 대용량 테이블에 FK 추가 시 잠금(lock) 시간 고려
□ FK 컬럼에도 인덱스 생성 (JOIN 성능)
테스트 환경에서 users 테이블의 특정 사용자를 삭제했는데, 연결된 orders, order_items, reviews 테이블에서 수만 건의 데이터가 함께 삭제되었습니다. 프로덕션에 동일 스키마가 적용되어 있었고, 운영 중 사용자 1명을 삭제했을 때 연관 주문 데이터가 복구 불가능하게 사라지는 사고가 발생했습니다.
원인: users → orders → order_items까지 CASCADE 체인이 연결되어 있었습니다. 각 테이블에 CASCADE를 추가할 때 전체 체인의 파급 효과를 고려하지 않았습니다.
해결 방법: 주문처럼 비즈니스적으로 중요한 데이터는 CASCADE 대신 RESTRICT를 사용해 삭제를 차단합니다. 사용자 삭제가 필요한 경우 소프트 삭제(deleted_at) 패턴을 도입합니다. 기존 테이블에서 CASCADE를 제거하려면 ALTER TABLE orders DROP CONSTRAINT fk_orders_user; ALTER TABLE orders ADD CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT;로 재선언합니다.
주문 서비스(Order Service)와 상품 서비스(Product Service)가 각각 독립된 데이터베이스를 사용하는 마이크로서비스 아키텍처에서는 DB 레벨 FK를 선언할 수 없습니다. order_items.product_id가 Product DB의 products.id를 참조하더라도 두 DB는 연결되지 않기 때문입니다.
이 경우 참조 무결성을 애플리케이션 레벨에서 관리합니다. 주문 생성 시 Product Service API를 호출해 상품 존재 여부를 검증하고, 상품이 삭제될 때 이벤트(Kafka, RabbitMQ 등)를 발행해 Order Service가 해당 상품을 포함한 미완료 주문을 처리하도록 합니다. 또한 주문 생성 시점에 unit_price, product_name 같은 상품 속성을 스냅샷으로 order_items에 복사해 두면, 이후 상품 정보가 변경되어도 과거 주문 데이터의 일관성이 유지됩니다.
다음 모듈에서는 제1·2·3정규화 원칙과 언제 역정규화를 선택해야 하는지 실전 기준을 다룹니다.