테이블을 만들 때 PK와 FK를 생략해도 당장은 INSERT가 됩니다. 하지만 시간이 지나면 중복 ID, 고아 레코드, 삭제 사고가 쌓입니다. 키 제약을 정확히 설계하면 DB가 데이터 정합성을 대신 지켜줍니다.
기본키와 외래키는 관계형 데이터베이스의 무결성(Integrity)을 보장하는 핵심 메커니즘입니다. 잘못 설계하면 데이터 불일치, 고아(orphan) 레코드, 보안 문제로 이어집니다. 올바른 PK 전략과 FK 삭제 정책을 이해하면 스키마 설계 품질이 크게 향상됩니다.
- 1자연키와 대리키를 비교해 상황에 맞는 PK 전략을 선택할 수 있다
- 2SERIAL, AUTO_INCREMENT, UUID의 장단점을 이해하고 적합한 것을 고를 수 있다
- 3언제 여러 컬럼을 묶어 복합 기본키로 써야 하는지 판단할 수 있다
- 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()
);
실행 완료 또는 조회 결과가 표시됩니다.
- email을 PK로 SELECT하면 이메일이 바뀔 때 FK 연쇄 업데이트가 발생합니다. UPDATE users SET email = ... 실행 후 영향받은 행이 1개를 초과하면 이 PK를 참조하는 자식 테이블에도 연쇄 반영됩니다. 이게 자연키의 실제 비용입니다.
- INSERT 시 ERROR 23505 (unique_violation)가 나오면 이미 같은 email 값이 존재한다는 뜻입니다. 자연키 기반 PK에서 이 오류는 PK 중복이자 비즈니스 데이터 중복을 동시에 의미해 원인 파악이 복잡해집니다.
- VARCHAR 타입 PK의 인덱스 크기: \d+ users 결과에서 pg_size_pretty(pg_total_relation_size(테이블명))를 확인합니다. email VARCHAR(255) PK는 BIGINT PK 대비 3~5배 인덱스 크기가 커질 수 있습니다.
자연키가 현실에서 어떤 문제를 일으키는지 정리하면 대리키를 써야 하는 이유가 명확해집니다.
| 문제 | 설명 |
|---|---|
| 변경 가능성 | 이메일은 바뀔 수 있다. 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);로 추가합니다. 고아 레코드는 먼저 SELECT COUNT(*) FROM orders o WHERE NOT EXISTS (SELECT 1 FROM users u WHERE u.id = o.user_id);로 확인한 뒤, 백업과 트랜잭션을 준비하고 정책에 맞게 정리합니다. NOT IN은 서브쿼리에 NULL이 있으면 의도와 다르게 0건을 반환할 수 있어 사용하지 않습니다.
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
);
옵션 표만 보면 머릿속에 안 남습니다. 부모·자식을 만들고 부모를 삭제해 RESTRICT(거부)와 CASCADE(전파)가 실제로 어떻게 갈리는지 눈으로 확인합니다.
-- 1) 자식이 RESTRICT로 참조 중인 부모 삭제 → 거부되어야 정상
DELETE FROM products WHERE id = 10; -- order_items.product_id 가 RESTRICT
-- 2) CASCADE로 참조되는 부모 삭제 → 자식까지 함께 사라짐
DELETE FROM orders WHERE id = 5; -- order_items.order_id 가 CASCADE
SELECT count(*) FROM order_items WHERE order_id = 5; -- 0 이어야 함
-- 1) RESTRICT
ERROR: update or delete on table "products" violates foreign key constraint
"order_items_product_id_fkey" on table "order_items"
DETAIL: Key (id)=(10) is still referenced from table "order_items".
-- 2) CASCADE
DELETE 1
count
-------
0
DELETE FROM users WHERE id = 1;- RESTRICT 케이스에서 ERROR ... violates foreign key constraint 가 떴는지 먼저 본다 — 에러가 나야 정상(삭제가 막힌 것). 에러 없이 삭제되면 FK가 안 걸린 것이다
- DETAIL의 'still referenced from table ...'이 어느 자식 테이블이 막았는지 알려준다 — 여기를 보고 어떤 FK를 먼저 정리할지 판단한다
- CASCADE 케이스는 부모 DELETE 1 뒤 자식 count가 0이어야 한다. 0이 아니면 ON DELETE CASCADE가 아니라 기본(RESTRICT/NO ACTION)으로 걸린 것
- 운영에서 CASCADE 부모를 지우기 전에는 반드시 자식 건수를 먼저 SELECT count(*)로 확인한다 — 한 줄 삭제가 수천 건 연쇄 삭제로 번질 수 있다
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에 복사해 두면, 이후 상품 정보가 변경되어도 과거 주문 데이터의 일관성이 유지됩니다.
FK가 참조 무결성을 지키는 순간 — INSERT·UPDATE·DELETE에서 DB가 실제로 하는 검사
FK를 걸면 "존재하지 않는 부모를 가리키는 자식(고아)"을 DB가 막아준다고 배웠지만, 정확히 언제 무엇을 검사하는지는 흐릿합니다. FK 위반 에러가 뜨는 순간, CASCADE가 연쇄 삭제하는 순간, 부모 삭제가 갑자기 느려지는 순간 — 이 셋 모두 같은 무결성 검사에서 나옵니다. DML마다 DB가 어느 방향으로 무엇을 확인하는지 알면 에러 메시지와 성능 문제를 단계로 좁힐 수 있습니다.
자식 쪽 쓰기 — "내가 가리키는 부모가 실재하나?" (자식 → 부모 확인)
INSERT INTO orders(user_id=99999) 또는 UPDATE orders SET user_id=...
│
① 부모 users에 id=99999 가 있나? — 부모 PK 인덱스로 조회(빠름)
│ 있음 → 통과 / 없음 → 거부(ERROR 23503 foreign_key_violation)
▼
[저장] 주인 없는 주문(고아 레코드)이 애초에 생기지 않음
부모 쪽 쓰기 — "나를 아직 가리키는 자식이 남았나?" (부모 → 자식 확인)
DELETE FROM users WHERE id=1 또는 UPDATE users SET id=...
│
② id=1 을 참조하는 orders 행이 있나? — 자식 FK 컬럼을 조회
│ (자식 FK 인덱스 없으면 자식 테이블 전체 Seq Scan + 락)
│
③ 자식이 있으면 ON DELETE 정책 적용
│ RESTRICT / NO ACTION → 삭제 거부(ERROR 23503)
│ CASCADE → 그 자식 행도 함께 삭제(자식→손자로 연쇄)
│ SET NULL → 자식의 FK 컬럼을 NULL 로
▼
[정책대로 처리] 참조 무결성 유지
DML별로 DB가 확인하는 것과, 실수하면 생기는 일:
| DML | DB가 확인하는 것 (방향) | 결과 · 실수하면 |
|---|---|---|
| 자식 INSERT · FK UPDATE | 가리키는 부모 PK가 실재하나 (자식→부모) | 없으면 23503으로 거부 → 고아 방지 · 애초에 FK를 안 걸면 조용히 고아 누적 |
| 부모 DELETE — RESTRICT/NO ACTION | 나를 참조하는 자식이 남았나 (부모→자식) | 있으면 삭제 거부 · 기본 동작이므로 명시 안 하면 의도가 안 남음 |
| 부모 DELETE — CASCADE | 참조하는 자식을 찾아 함께 삭제 | 자식·손자까지 연쇄 · 규모 미확인 시 한 줄 삭제가 수천 건 삭제로 |
| 부모 DELETE — SET NULL | 참조하는 자식의 FK를 NULL로 | FK 컬럼이 nullable이어야 함 · NOT NULL이면 오류 |
| 부모 DELETE·PK UPDATE 시 자식 스캔(②) | 자식 FK 컬럼 조회 | 인덱스 없으면 Seq Scan+락 → 삭제 지연·동시 쓰기 경합 |
즉 FK 검사는 방향이 둘입니다 — 자식을 쓸 때는 위(부모)를 보고, 부모를 지울 때는 아래(자식)를 봅니다. 그래서 23503 에러가 INSERT에서 났다면 "가리킬 부모가 없는 것"이고, DELETE에서 났다면 "지우려는 부모를 자식이 아직 붙잡은 것"입니다 — 에러가 난 DML만 봐도 어느 방향 검사가 막았는지 압니다. 부모 삭제가 유독 느리면 ②의 자식 스캔이 인덱스를 못 타는 것이니 자식 FK 컬럼에 인덱스를 만듭니다(심화에서 이어집니다). 그리고 CASCADE로 걸린 부모를 지우기 전에는 반드시 자식 건수를 SELECT count(*)로 먼저 확인하는 습관이 대량 삭제 사고를 막습니다.
심화 — FK가 만드는 숨은 비용
심화: 자식 FK 컬럼의 인덱스 — 없으면 부모 삭제가 자식 전체 스캔이 된다
FK를 걸면 참조 무결성은 DB가 지켜 주지만, 그 무결성을 언제 어떻게 검사하는지까지 알아야 운영에서 놀라지 않습니다. 실전 체크리스트의 "FK 컬럼에도 인덱스 생성"은 단순 JOIN 성능 팁이 아니라, 삭제 성능과 락을 좌우하는 핵심입니다.
PostgreSQL은 부모의 PK에는 인덱스를 자동으로 만들지만, 자식 테이블의 FK 컬럼에는 인덱스를 자동으로 만들어 주지 않습니다. 그런데 FK 무결성 검사는 자식 쪽을 향합니다.
- 부모 삭제·PK 변경 시 자식을 뒤집니다.
users에서 한 행을 DELETE하면, DB는 그 사용자를 참조하는orders가 남아 있는지 확인해야 합니다. 자식 FK 컬럼(orders.user_id)에 인덱스가 없으면 이 확인을 위해 orders 전체를 Seq Scan합니다. - 스캔에는 락이 따릅니다. 이 검사 과정에서 자식 행에 잠금이 걸리므로, 자식 테이블이 크면 부모 한 행 삭제가 수 초에서 수 분씩 걸리고 그동안 자식 테이블 쓰기가 막힙니다. 동시에 자식으로 INSERT가 들어오면 락 경합·데드락으로 번지기도 합니다.
- CASCADE도 마찬가지입니다.
ON DELETE CASCADE는 연쇄 삭제 대상을 찾을 때 같은 자식 FK 컬럼을 조회하므로, 인덱스가 없으면 연쇄 삭제 전체가 느려집니다.
그래서 FK를 선언했다면 자식 FK 컬럼에 인덱스를 함께 만드는 것이 기본입니다 — 삭제·갱신 무결성 검사가 전체 스캔에서 인덱스 조회로 바뀌어, 락 시간과 삭제 시간이 함께 짧아집니다.
상황: users에서 사용자 한 명을 지우거나 관리자 화면에서 부모 레코드를 삭제하는데, 한 건 삭제가 비정상적으로 오래 걸립니다. 삭제가 도는 동안 그 자식 테이블(orders 등)로 들어오는 주문 INSERT가 함께 멈춥니다.
원인: 자식 테이블의 FK 컬럼(orders.user_id)에 인덱스가 없었습니다. DB는 부모 삭제 시 그 부모를 참조하는 자식이 남아 있는지 확인하려고 자식 테이블 전체를 Seq Scan하고 그 행들에 락을 겁니다. 자식이 수백만 건이면 한 건 삭제가 그 전체 스캔 시간만큼 걸리고, 락 때문에 동시 쓰기가 차단됩니다.
진단: pg_indexes(또는 \d 자식테이블)에서 FK 컬럼에 인덱스가 있는지 확인합니다. 삭제가 느릴 때 pg_stat_activity의 wait_event를 보거나 auto_explain으로 무결성 검사 쿼리가 Seq Scan인지 확인합니다. PK에는 인덱스가 있는데 자식 FK 컬럼에는 없다면 이 문제입니다.
해결: 자식 FK 컬럼에 인덱스를 만듭니다 — CREATE INDEX idx_orders_user_id ON orders(user_id). 운영 중 대용량 테이블이라면 락을 피하려고 CREATE INDEX CONCURRENTLY를 씁니다. 인덱스 생성 후에는 무결성 검사가 인덱스 조회로 바뀌어 부모 삭제가 빨라지고 락 시간도 줄어듭니다.
명령어·구문 빠른 참조
이 모듈에서 다룬 PK·FK·제약조건 구문을 실전 조합과 함께 모았습니다.
| 구문/명령 | 용도 | 예 |
|---|---|---|
BIGSERIAL PRIMARY KEY | 자동 증가 정수 대리키 | id BIGSERIAL PRIMARY KEY |
UUID ... DEFAULT gen_random_uuid() | 분산 환경 대리키 | id UUID PRIMARY KEY DEFAULT gen_random_uuid() |
... NOT NULL UNIQUE | 비즈니스 식별자 별도 관리 | email VARCHAR(255) NOT NULL UNIQUE |
PRIMARY KEY (a, b) | 복합 PK(M:N 중간 테이블) | PRIMARY KEY (user_id, role_id) |
FOREIGN KEY ... REFERENCES | 참조 무결성(고아 레코드 방지) | FOREIGN KEY (user_id) REFERENCES users(id) |
ON DELETE RESTRICT/CASCADE/SET NULL | 부모 삭제 시 자식 처리 전파 | REFERENCES users(id) ON DELETE CASCADE |
ON UPDATE CASCADE | 부모 PK 변경 시 자식 연쇄 갱신 | 자연키 변경 비용 대응 |
ALTER TABLE ADD/DROP CONSTRAINT | 기존 테이블에 FK 추가·재선언 | ADD CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id) |
DEFERRABLE INITIALLY DEFERRED | 커밋 시점에만 FK 검사(순환 참조) | SET CONSTRAINTS fk_manager DEFERRED |
soft delete (deleted_at) | CASCADE 대신 논리 삭제 | UPDATE users SET deleted_at = NOW() 후 WHERE deleted_at IS NULL |
CREATE INDEX ON child(fk_col) | 자식 FK 인덱스(부모 삭제 지연 방지) | CREATE INDEX idx_orders_user_id ON orders(user_id) |
DELETE ... WHERE NOT EXISTS (...) | NULL 영향을 피한 고아 레코드 정리(검토·백업 후) | DELETE FROM orders o WHERE NOT EXISTS (SELECT 1 FROM users u WHERE u.id = o.user_id) |
관련 모듈로 더 깊이:
- 제1·2·3정규화와 역정규화(De-normalization) 실전 적용 기준 — PK/FK로 분리한 테이블을 정규화 원칙으로 설계하는 법
- INNER, LEFT, RIGHT, FULL JOIN의 최적화 실행 조건 — FK로 연결된 테이블들을 JOIN으로 다시 조회하는 법
- B-Tree 인덱스의 작동 원리와 인덱스 설계의 핵심 조건 — FK 컬럼에 인덱스를 만들어 JOIN 성능을 확보하는 이유
다음 모듈에서는 제1·2·3정규화 원칙과 언제 역정규화를 선택해야 하는지 실전 기준을 다룹니다.