다른 테이블(또는 자기 자신)의 기본키·유니크 키를 가리키는 속성. 이걸로 참조 무결성이 지켜진다.
무엇을 막아 주나
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
member_id BIGINT NOT NULL REFERENCES member(id)
);
INSERT INTO orders (id, member_id) VALUES (1, 99999);
-- ERROR: insert or update on table "orders" violates foreign key constraint
-- Key (member_id)=(99999) is not present in table "member".
존재하지 않는 회원의 주문이 만들어질 수 없다. 애플리케이션 코드가 아무리 많아도 이 한 줄이 최종 방어선이다.
삭제·갱신 시 동작을 정한다
CREATE TABLE orders (
member_id BIGINT REFERENCES member(id)
ON DELETE CASCADE -- 회원을 지우면 그 주문도 함께 지운다
ON UPDATE CASCADE -- 회원 id 가 바뀌면 따라 바꾼다
);
| 옵션 | 부모를 지울 때 |
|---|---|
NO ACTION / RESTRICT | 거부한다 (기본값) |
CASCADE | 자식도 함께 지운다 |
SET NULL | 자식의 참조를 NULL로 만든다 |
SET DEFAULT | 기본값으로 바꾼다 |
CASCADE는 신중해야 한다. 회원 하나를 지웠는데 주문·결제·배송이 연쇄로 사라질 수 있고, 그 연쇄가 어디까지 가는지 SQL만 봐서는 잘 안 보인다. 실무에서는 RESTRICT로 막고 소프트 삭제(deleted_at 칼럼)를 쓰는 경우가 많다.
성능에서 자주 데는 지점 — FK에 인덱스가 자동으로 안 생긴다
-- PostgreSQL: FK 를 만들어도 자식 쪽에는 인덱스가 생기지 않는다
CREATE INDEX idx_orders_member ON orders (member_id); -- 직접 만들어야 한다
인덱스가 없으면 부모를 지울 때마다 자식 테이블을 풀 스캔해 참조를 확인한다. 그리고 그동안 자식 테이블에 락이 걸린다.
-- 인덱스 없는 FK 찾아내기 (PostgreSQL)
SELECT c.conrelid::regclass AS child, a.attname AS column
FROM pg_constraint c
JOIN unnest(c.conkey) k ON true
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k
WHERE c.contype = 'f'
AND NOT EXISTS (SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid AND a.attnum = i.indkey[0]);
MySQL InnoDB는 FK 생성 시 인덱스를 자동으로 만들어 준다 — 두 엔진의 차이라 이식할 때 놓치기 쉽다.
FK를 아예 안 거는 선택
안 거는 쪽의 논리 대량 쓰기 성능 · 샤딩 · 마이그레이션 유연성
- 거는 쪽의 논리 — 애플리케이션 버그가 데이터를 망가뜨리는 걸 DB 가 막는다
대규모 서비스에서 FK를 빼는 사례가 있지만, 그건 정합성 검증을 애플리케이션과 배치로 옮겨 오는 거래다. 검증 체계 없이 그냥 빼면 고아 행이 쌓인다.
-- 고아 행 점검 (FK 를 안 걸었다면 주기적으로 돌려야 한다)
SELECT o.* FROM orders o
LEFT JOIN member m ON o.member_id = m.id
WHERE m.id IS NULL;
면접 함정
- ❌ "FK를 걸면 인덱스가 생긴다" → PostgreSQL은 안 생긴다. MySQL은 생긴다.
- ❌ "FK가 있으면 애플리케이션 검증은 불필요" → 사용자에게 보여줄 오류 메시지는 애플리케이션 몫이다. FK는 최후의 방어선이지 UX가 아니다.