백엔드 데이터 인프라 15편. 외래 키로 참조 무결성을 강제하는 표준 패턴과 ON DELETE·ON UPDATE 옵션, 운영 함정까지 풀어쓴 학습 노트.
이 글은 백엔드 데이터 인프라 시리즈 70편 중 15편이에요. 7편 관계형 모델 에서 외래 키를 "행 사이 관계" 라고 짧게 소개했죠. 이번 15편은 그 외래 키를 운영 표준으로 깊이 풀어 갑니다.
외래 키가 보장하는 것 — 참조 무결성
users orders
+----+-------+ +----+---------+--------+
| id | name | ← | id | user_id | amount |
+----+-------+ +----+---------+--------+
| 1 | Alice | | 1 | 1 | 10000 |
+----+-------+ | 2 | 1 | 5000 |
| 3 | 999 | 3000 | ← 999는 존재 안 함!
+----+---------+--------+
orders #3의 user_id 999가 users에 없으면 참조 무결성 위반입니다. 외래 키가 없으면 이런 고아 데이터가 박히지만, 외래 키를 걸어두면 DB가 직접 거부해요.
INSERT INTO orders (user_id, amount) VALUES (999, 3000);
-- ERROR: insert or update on table "orders" violates foreign key constraint
외래 키 선언
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
amount INTEGER NOT NULL
);
REFERENCES 테이블(컬럼) 이 외래 키 선언이고, 컬럼 정의 안에 인라인으로 박는 형태입니다. 별도 제약으로 빼서 쓸 수도 있어요.
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
amount INTEGER NOT NULL,
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id)
);
기존 테이블에는 ALTER 로 추가합니다.
ALTER TABLE orders
ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id);
다중 컬럼 외래 키
CREATE TABLE order_items (
order_id BIGINT,
line_no INTEGER,
product_id BIGINT,
quantity INTEGER,
PRIMARY KEY (order_id, line_no),
FOREIGN KEY (order_id) REFERENCES orders(id)
);
복합 키 + 복합 외래 키 구조입니다. JPA(자바 ORM 표준 스펙) 에서는 @EmbeddedId 로 매핑합니다.
ON DELETE — 부모 삭제 시 자식
14편 DELETE 에서 다룬 5가지 옵션을 자세히 풀어 봅니다.
NO ACTION (기본)
user_id BIGINT REFERENCES users(id) ON DELETE NO ACTION
자식이 있으면 부모 삭제를 거부합니다. 가장 안전하고, 운영 표준입니다.
RESTRICT
ON DELETE RESTRICT
NO ACTION 과 거의 같지만 즉시 검증한다는 차이가 있어요. NO ACTION 은 트랜잭션 끝에 검증하기 때문에, deferrable 옵션이 들어가면 둘의 동작이 갈립니다.
CASCADE
ON DELETE CASCADE
부모를 삭제하면 자식도 함께 삭제됩니다. 강력하지만 위험해요 — 사용자 한 명 삭제로 주문 1만 건이 사라질 수 있습니다.
적합한 시나리오: - 부모와 자식이 "한 묶음 객체" — 예: order ↔ order_items - 부모 없으면 자식 의미 없음
부적합: - 비즈니스 데이터 — 운영·감사 차원에서 위험
SET NULL
user_id BIGINT REFERENCES users(id) ON DELETE SET NULL
부모를 삭제하면 자식 컬럼이 NULL 로 바뀝니다. 부모가 없어졌어도 자식은 남겨야 할 때 — 사용자 탈퇴 후 주문 기록 보존 같은 경우에 씁니다. 단, 외래 키 컬럼이 NOT NULL 이면 SET NULL 은 쓸 수 없어요.
SET DEFAULT
user_id BIGINT NOT NULL DEFAULT 0 REFERENCES users(id) ON DELETE SET DEFAULT
지정된 기본값으로 바꿉니다. 실무에서는 거의 안 씁니다.
ON UPDATE — 부모 키 변경 시
옵션은 동일하지만, 부모의 키 변경 자체가 실무에 거의 없어요 (인공 키를 쓰면 변할 일이 없으니까). 자연 키를 쓸 때만 의미가 생깁니다.
ON UPDATE CASCADE -- 부모 ID 변경 → 자식도 함께
DEFERRABLE — 트랜잭션 끝에 검증
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id)
DEFERRABLE INITIALLY DEFERRED
기본은 매 SQL 마다 즉시 검증이지만, DEFERRABLE(제약 검증 지연 옵션) 을 박으면 트랜잭션 끝(COMMIT 직전) 까지 검증을 미룹니다. 순환 참조나 복잡한 데이터 이관 작업에서 유용해요.
BEGIN;
SET CONSTRAINTS ALL DEFERRED;
-- 순환 의존 INSERT (일시 무결성 위반 OK)
INSERT INTO ...;
INSERT INTO ...;
COMMIT; -- 여기서 한 번에 검증
외래 키와 인덱스
PG 는 외래 키 컬럼에 자동으로 인덱스를 만들지 않습니다. 부모의 PK 는 자동이지만 자식 FK 는 수동으로 박아야 해요.
-- 부모 PK = 자동 인덱스 (users.id)
-- 자식 FK = 수동 인덱스 필요
CREATE INDEX idx_orders_user_id ON orders(user_id);
인덱스가 없으면 부모 삭제 시 "자식이 어디 있나" 풀스캔이 돕니다. 큰 테이블에서는 락이 폭증하고 시간도 폭주해요. 결국 외래 키 컬럼에는 무조건 인덱스를 박는다는 룰이 됩니다. JOIN 성능과 삭제 성능 모두 영향을 받으니까요.
Spring JPA 의 외래 키
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
}
@JoinColumn(name = "user_id") 이 REFERENCES users(id) 매핑입니다. Hibernate(JPA 구현체) 가 ddl-auto=update 모드에서 FK 제약을 자동으로 만들어요. 단, ON DELETE 옵션은 @OnDelete 로 명시해야 합니다.
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "user_id")
private User user;
외래 키 비활성화 — 임시
데이터 이관이나 테스트 시에 일시 비활성화가 필요할 때가 있어요.
ALTER TABLE orders DROP CONSTRAINT fk_user;
-- ... 작업 ...
ALTER TABLE orders ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id);
트랜잭션 안에서 DEFERRED 로 쓰는 방법도 있습니다. 운영에서 영구 비활성화는 금물 — 무결성이 깨집니다.
외래 키 vs "앱 코드로 무결성"
오랜 논쟁입니다.
- FK 박기 = DB 가 강제, 안전, 단 성능 일부 손해
- FK 안 박기 + 앱 코드 검증 = 유연, 단 "코드 버그 = 데이터 깨짐"
한국 회사 일반은 FK 무조건 박기 쪽입니다. 비용 작은 안전망이니까요. FK 를 빼서 성능을 얻는 시나리오는 수십억 행급 극단적 대용량이거나 분산 시스템에서 나옵니다.
함정 5가지
(1) FK 컬럼 인덱스 누락
가장 흔한 함정. 무조건 인덱스를 박아 둡니다.
(2) CASCADE 남용
운영 데이터에 CASCADE 를 박으면 작은 실수가 큰 사고가 됩니다. NO ACTION 으로 두고 코드에서 처리하는 쪽이 낫습니다.
(3) FK 빼고 시작
빨리 개발한다고 FK 를 빼면 운영 단계에서 고아 데이터가 박혀 무결성이 깨집니다. 복구는 매우 어려워요.
(4) Soft Delete + FK
DELETE FROM users WHERE id = 1;
-- 실제로는 deleted_at UPDATE
-- → orders.user_id 는 그대로 = OK
Soft Delete 는 FK 제약과 충돌하지 않습니다. 다만 deleted_at IS NULL 필터 일관성은 챙겨야 해요.
(5) 운영 ALTER ADD CONSTRAINT
큰 테이블에 사후 FK 를 추가하면 풀스캔이 돕니다. NOT VALID(기존 데이터 검증 보류) 옵션으로 단계를 나누면 됩니다.
-- 1. 새 데이터에만 검증
ALTER TABLE orders ADD CONSTRAINT fk_user
FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID;
-- 2. 기존 데이터 검증 (별도 작업)
ALTER TABLE orders VALIDATE CONSTRAINT fk_user;
NOT VALID 는 새 데이터에만 즉시 적용되고, 기존은 백그라운드로 검증해요. 운영 다운타임이 0 으로 떨어집니다.
(1) 무조건 박기. (2) FK 컬럼 인덱스 필수. (3) 운영 표준 = NO ACTION. (4) CASCADE는 의존 객체만 (order_items 같은). 한국 회사 99% 적용.
한 줄 정리 — 외래 키 = 참조 무결성 강제. REFERENCES + ON DELETE 옵션 5가지 (NO ACTION·CASCADE·SET NULL·SET DEFAULT·RESTRICT). FK 컬럼은 무조건 인덱스. CASCADE 신중. 운영 추가는 NOT VALID + VALIDATE 2단계.
시험 직전 한 번 더 — 외래 키 입문자가 매번 헷갈리는 것
- REFERENCES users(id) = FK 선언
- 인라인 vs CONSTRAINT 별도
- 다중 컬럼 =
FOREIGN KEY (a, b) REFERENCES t(a, b) - ON DELETE 옵션 5개 = NO ACTION·RESTRICT·CASCADE·SET NULL·SET DEFAULT
- 운영 표준 = NO ACTION
- CASCADE 신중 = 의존 객체만 (order_items)
- SET NULL = 부모 없어져도 자식 보존
- ON UPDATE = 인공 키 사용 시 의미 X
- DEFERRABLE INITIALLY DEFERRED = 트랜잭션 끝 검증
- 순환 참조 데이터 이관에 활용
- FK 컬럼 인덱스 자동 X — 수동 박기 필수
- 인덱스 없으면 = 부모 삭제 시 풀스캔
- @ManyToOne + @JoinColumn = JPA FK 매핑
- @OnDelete(action) = Hibernate FK 옵션
- 외래 키 일시 비활성화 = DROP CONSTRAINT
- NOT VALID + VALIDATE = 운영 ALTER 2단계
- FK 빼고 시작 = 위험 (고아 데이터)
- Soft Delete + FK = 충돌 X
- 한국 회사 99% = FK 박기
- 분산 시스템 = FK 빼는 경우 있음
- 1차 캐시·N+1 = FK 인덱스 영향 큼
- 면접 단골 = ON DELETE 옵션 차이
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 10편 — INSERT 데이터 입력 표준 패턴
- 11편 — SELECT 데이터 조회 표준 패턴
- 12편 — JOIN 여러 테이블 합치기
- 13편 — UPDATE 데이터 수정 표준 패턴
- 14편 — DELETE 데이터 삭제와 소프트 삭제 패턴
다음 글: