숏컷 코드
CREATE TABLE posts (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL
);문법
외래 키는 관계의 존재 자체를 DB가 보장하게 만든다
외래 키 제약 없이 user_id를 저장하면, 존재하지 않는 user_id가 들어와도 DB는 이를 막지 않습니다. 외래 키를 선언하면 INSERT·UPDATE 시 참조 대상이 실제로 존재하는지 확인하고, 없으면 오류를 냅니다. 이 검사를 애플리케이션 코드에서만 수행하면 마이그레이션, 배치 작업, 직접 SQL 등 다른 경로로 데이터가 들어올 때 무결성이 깨질 수 있습니다. DB 레벨 제약은 어떤 경로로 데이터가 들어와도 일관성을 지켜줍니다.
-- 외래 키 위반 예시
INSERT INTO posts (user_id, title) VALUES (9999, '존재하지 않는 사용자');
-- ERROR: insert or update on table "posts" violates foreign key constraint
-- DETAIL: Key (user_id)=(9999) is not present in table "users".외래 키와 삭제 규칙은 같이 설계해야 한다
외래 키는 "부모가 실제로 존재해야 한다"를 보장하고, ON DELETE 규칙은 "부모가 사라질 때 자식을 어떻게 할지"를 정합니다. 둘은 따로가 아니라 한 관계의 두 부분이라고 보면 됩니다.
CREATE TABLE comments (
id BIGSERIAL PRIMARY KEY,
post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
body TEXT NOT NULL
);ON DELETE 규칙은 부모 삭제 시 자식의 운명을 결정한다
부모 행이 삭제될 때 자식 행을 어떻게 처리할지는 4가지 규칙으로 선택합니다. 각 규칙은 데이터의 중요도와 관계 특성에 따라 다르게 적용합니다.
| 규칙 | 동작 |
|---|---|
RESTRICT / NO ACTION (기본) | 자식이 있으면 부모 삭제를 거부 |
CASCADE | 부모 삭제 시 자식도 함께 삭제 |
SET NULL | 부모 삭제 시 자식의 외래 키를 NULL로 변경 |
SET DEFAULT | 부모 삭제 시 자식의 외래 키를 DEFAULT 값으로 변경 |
-- 사용자 삭제 시 게시글도 함께 삭제 (CASCADE)
CREATE TABLE posts (
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE
);
-- 작성자 삭제 시 게시글의 author_id를 NULL로 (SET NULL)
CREATE TABLE posts (
author_id BIGINT REFERENCES users(id) ON DELETE SET NULL
);CASCADE는 연쇄 삭제 범위를 먼저 파악해야 안전하다
ON DELETE CASCADE는 편리하지만 연쇄 범위가 생각보다 클 수 있습니다. users → posts → comments 가 모두 CASCADE라면, 사용자 한 명을 삭제할 때 그 사람의 모든 글과 모든 댓글이 즉시 삭제됩니다. 이 삭제는 롤백하지 않는 한 복구가 어렵습니다. 삭제 범위가 큰 데이터(결제 기록, 주문 이력 등)에는 CASCADE 대신 RESTRICT로 삭제를 막거나, 소프트 딜리트(deleted_at 컬럼)를 사용하는 편이 안전합니다.
-- 소프트 딜리트: CASCADE 대신 deleted_at으로 상태 관리
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMPTZ;
UPDATE users SET deleted_at = NOW() WHERE id = 42;
-- posts는 그대로 유지되고, 애플리케이션에서 deleted_at IS NULL 조건으로 필터링외래 키 컬럼에 인덱스를 따로 걸어야 하는 이유
PostgreSQL은 외래 키 제약을 선언해도 자식 테이블의 외래 키 컬럼에 자동으로 인덱스를 만들지 않습니다. 부모 행을 삭제하거나 업데이트할 때 PostgreSQL은 자식 테이블에서 해당 키를 가진 행이 있는지 확인하는데, 인덱스가 없으면 이 확인이 Seq Scan으로 처리됩니다. 자식 테이블이 크다면 부모 삭제 한 건이 느려지는 원인이 됩니다. 외래 키 컬럼에는 항상 인덱스를 명시적으로 생성하는 것이 좋습니다.
-- 외래 키 컬럼에 인덱스 추가
CREATE INDEX idx_posts_user_id ON posts (user_id);
CREATE INDEX idx_comments_post_id ON comments (post_id);
-- 인덱스 없는 경우 부모 삭제 시 Seq Scan 발생 가능
-- EXPLAIN DELETE FROM users WHERE id = 42;
-- → Seq Scan on posts (checking for referencing rows)선택 기준
| 상황 | 적합한 선택 |
|---|---|
| 부모 삭제 시 자식도 함께 없애도 될 때 | ON DELETE CASCADE |
| 부모가 삭제되면 자식의 참조를 지워도 될 때 | ON DELETE SET NULL |
| 자식이 있는 부모는 삭제 자체를 막으려면 | ON DELETE RESTRICT (기본 동작) |
| 삭제 범위가 크거나 복구가 필요한 데이터 | 소프트 딜리트 패턴 (deleted_at) |
주의할 점
CASCADE는 연쇄 삭제 범위가 예상보다 넓을 수 있습니다. 부모 한 행 삭제가 수천 건의 자식 데이터를
즉시 제거할 수 있으며, 이는 롤백하지 않으면 복구가 어렵습니다. 또한 PostgreSQL은 외래 키 컬럼에
인덱스를 자동 생성하지 않으므로, 자식 테이블의 외래 키 컬럼에 인덱스를 직접 생성해야 부모 삭제 시
불필요한 Seq Scan을 막을 수 있습니다.
DELETE FROM users WHERE id = 42;ON DELETE RESTRICT가 기본인 관계에서 자식 행이 남아 있으면, 위 삭제는 실패합니다. "왜 삭제가 안 되지?"가 아니라 "지금 관계를 지우지 말라는 규칙이 작동 중"이라고 보면 됩니다.
참고 링크
2 sources