본문 바로가기
TYLER SONGBlog
← Blog index
CS10 min read

Foreign Key: 테이블 사이의 참조 무결성을 보장하는 외래 키 제약 조건

외래 키(Foreign Key)는 한 테이블의 열이 다른 테이블의 기본 키(Primary Key)나 고유 키(Unique Key)를 참조하도록 제한한다. PostgreSQL은 데이터를 삽입·수정·삭제할 때 참조 대상의 존재 여부를 검사해 고아 행(Orphan Row)을 방지한다. 참조 열에는 인덱스가 자동 생성되지 않으므로 조회 패턴과 삭제·수정 비용을 고

By 송민성

1. 개념

외래 키(Foreign Key)는 테이블 사이의 관계를 정의하는 제약 조건(Constraint)이다. 자식 테이블의 외래 키 값은 부모 테이블에서 참조하는 키 값과 일치하거나, 널(NULL)이 허용된 경우 널이어야 한다.

text
users                         orders

id | name                     id  | user_id | amount
---|------                    ----|---------|-------
1  | 민수                     101 | 1       | 25000
2  | 지수                     102 | 1       | 18000

orders.user_id가 users.id를 참조하면 다음 관계가 성립한다.

text
orders.user_id ──참조──> users.id
  • users: 참조 대상인 부모 테이블(Referenced Table)
  • orders: 외래 키를 가진 자식 테이블(Referencing Table)
  • users.id: 참조되는 키(Referenced Key)
  • orders.user_id: 참조하는 키(Referencing Key)

외래 키가 있으면 users.id에 존재하지 않는 값을 orders.user_id에 저장할 수 없다. 이 규칙을 참조 무결성(Referential Integrity)이라고 한다.

외래 키는 기본 키와 목적이 다르다.

  • 기본 키(Primary Key): 현재 테이블의 각 행을 유일하게 식별한다.
  • 외래 키(Foreign Key): 다른 행과의 관계가 유효한지 보장한다.

하나의 열이 기본 키이면서 동시에 외래 키일 수도 있다. 일대일 관계(One-to-One Relationship)를 표현할 때 사용한다.

2. 왜 사용하는가

외래 키는 애플리케이션 코드만으로는 완전히 막기 어려운 잘못된 관계를 데이터베이스에서 차단한다.

외래 키가 없으면 존재하지 않는 사용자를 참조하는 주문이 저장될 수 있다.

text
orders

id  | user_id | amount
----|---------|-------
101 | 999     | 25000

user_id = 999인 사용자가 없다면 이 주문은 부모가 없는 고아 행(Orphan Row)이다.

애플리케이션에서 먼저 사용자를 조회한 뒤 주문을 저장해도 경쟁 조건(Race Condition)이 발생할 수 있다.

text
트랜잭션 A: 사용자 1이 존재하는지 확인
트랜잭션 B: 사용자 1 삭제
트랜잭션 A: 사용자 1의 주문 저장

외래 키가 없으면 마지막 저장이 성공할 수 있다. 외래 키가 있으면 PostgreSQL이 트랜잭션과 잠금 규칙에 따라 관계를 검사하므로 잘못된 참조가 커밋되지 않는다.

외래 키가 제공하는 효과는 다음과 같다.

  • 존재하지 않는 부모 행 참조 방지
  • 부모 행 삭제·수정 시 처리 규칙 통일
  • 테이블 관계를 스키마에 명시
  • 여러 애플리케이션이 같은 데이터베이스를 사용할 때 동일한 무결성 규칙 적용

3. 동작 원리

참조 대상 조건

PostgreSQL의 외래 키는 기본 키, 고유 제약 조건(Unique Constraint), 널을 서로 다르지 않은 값으로 처리하는 고유 제약 조건, 또는 비부분 고유 인덱스(Non-partial Unique Index)의 열을 참조할 수 있다.

가장 일반적인 형태는 부모 테이블의 기본 키를 참조하는 것이다.

sql
CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL ); CREATE TABLE orders ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint NOT NULL REFERENCES users(id), amount numeric(12, 2) NOT NULL );

orders에 행을 삽입하면 PostgreSQL은 users.id에 같은 값이 존재하는지 검사한다.

text
orders.user_id 저장 요청
├─ users.id에 같은 값 존재: 저장 허용
└─ users.id에 같은 값 없음: 외래 키 위반 오류

참조 열과 대상 열의 개수와 순서는 일치해야 하며, 각 열의 데이터 타입도 비교 가능한 형태여야 한다.

널 처리

기본값인 MATCH SIMPLE에서는 복합 외래 키의 열 중 하나라도 널이면 해당 행의 참조 검사를 통과한다.

sql
CREATE TABLE child_rows ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, parent_group_id bigint, parent_item_id bigint, FOREIGN KEY (parent_group_id, parent_item_id) REFERENCES parent_items(group_id, item_id) );

(parent_group_id, parent_item_id)가 (1, NULL)이어도 기본 설정에서는 참조 대상 존재 여부를 검사하지 않는다.

관계가 필수라면 각 외래 키 열에 NOT NULL을 지정해야 한다.

삭제·수정 동작

부모 키가 자식 행에서 사용 중일 때의 처리 방식은 ON DELETE와 ON UPDATE로 정의한다.

  • NO ACTION: 기본값. 제약 조건 검사 시점에 참조가 남아 있으면 거부한다.
  • RESTRICT: 참조 행이 있으면 즉시 작업을 거부한다.
  • CASCADE: 부모의 삭제나 키 변경을 자식 행에 전파한다.
  • SET NULL: 자식의 외래 키를 널로 바꾼다.
  • SET DEFAULT: 자식의 외래 키를 기본값으로 바꾼다.

SET NULL을 사용하려면 외래 키 열이 널을 허용해야 한다. SET DEFAULT를 사용해도 변경된 기본값이 유효한 부모 행을 참조해야 한다.

지연 검사

외래 키는 기본적으로 각 SQL 문이 끝날 때 검사된다. DEFERRABLE로 정의하면 검사를 트랜잭션 종료 시점까지 미룰 수 있다.

sql
CREATE TABLE nodes ( id bigint PRIMARY KEY, parent_id bigint, CONSTRAINT nodes_parent_fk FOREIGN KEY (parent_id) REFERENCES nodes(id) DEFERRABLE INITIALLY DEFERRED );

순환 관계를 한 트랜잭션에서 구성해야 할 때 유용하다. 트랜잭션이 커밋되는 시점에는 모든 참조가 유효해야 한다.

4. 코드 예제

다음 SQL은 사용자, 주문, 주문 항목 관계를 생성하고 외래 키 동작을 확인한다.

sql
DROP TABLE IF EXISTS order_items; DROP TABLE IF EXISTS orders; DROP TABLE IF EXISTS products; DROP TABLE IF EXISTS users; CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL ); CREATE TABLE products ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, price numeric(12, 2) NOT NULL CHECK (price >= 0) ); CREATE TABLE orders ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint NOT NULL, ordered_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT ); CREATE TABLE order_items ( order_id bigint NOT NULL, product_id bigint NOT NULL, quantity integer NOT NULL CHECK (quantity > 0), PRIMARY KEY (order_id, product_id), CONSTRAINT order_items_order_fk FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE, CONSTRAINT order_items_product_fk FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE RESTRICT ); CREATE INDEX orders_user_id_idx ON orders(user_id); CREATE INDEX order_items_product_id_idx ON order_items(product_id); INSERT INTO users (name) VALUES ('민수'); INSERT INTO products (name, price) VALUES ('키보드', 85000), ('마우스', 45000); INSERT INTO orders (user_id) VALUES (1); INSERT INTO order_items (order_id, product_id, quantity) VALUES (1, 1, 1), (1, 2, 2); SELECT o.id AS order_id, u.name AS user_name, p.name AS product_name, oi.quantity, p.price * oi.quantity AS line_total FROM orders AS o JOIN users AS u ON u.id = o.user_id JOIN order_items AS oi ON oi.order_id = o.id JOIN products AS p ON p.id = oi.product_id ORDER BY o.id, p.id;

존재하지 않는 사용자를 참조하면 삽입이 실패한다.

sql
INSERT INTO orders (user_id) VALUES (999);

사용 중인 상품은 ON DELETE RESTRICT 때문에 삭제할 수 없다.

sql
DELETE FROM products WHERE id = 1;

주문을 삭제하면 ON DELETE CASCADE에 따라 해당 주문 항목도 삭제된다.

sql
DELETE FROM orders WHERE id = 1; SELECT * FROM order_items;

복합 외래 키(Composite Foreign Key)는 여러 열의 조합을 참조한다.

sql
DROP TABLE IF EXISTS warehouse_stock; DROP TABLE IF EXISTS warehouse_products; CREATE TABLE warehouse_products ( warehouse_id bigint NOT NULL, product_id bigint NOT NULL, product_name text NOT NULL, PRIMARY KEY (warehouse_id, product_id) ); CREATE TABLE warehouse_stock ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, warehouse_id bigint NOT NULL, product_id bigint NOT NULL, quantity integer NOT NULL CHECK (quantity >= 0), FOREIGN KEY (warehouse_id, product_id) REFERENCES warehouse_products(warehouse_id, product_id) ON DELETE CASCADE ); INSERT INTO warehouse_products ( warehouse_id, product_id, product_name ) VALUES (1, 100, '키보드'); INSERT INTO warehouse_stock ( warehouse_id, product_id, quantity ) VALUES (1, 100, 20); SELECT * FROM warehouse_stock;

warehouse_id와 product_id 각각이 아니라 두 값의 조합이 참조 대상이다.

5. 시간 복잡도 또는 성능 특성

외래 키 검사의 실제 비용은 인덱스 종류, 데이터 분포, 캐시 상태, 동시 실행 트랜잭션, 참조 행 수에 따라 달라진다.

자식 행 삽입·수정

자식 테이블에 외래 키 값을 삽입하거나 수정하면 PostgreSQL은 부모 테이블에서 참조 대상 값을 조회한다.

부모의 참조 대상에는 고유 인덱스가 있으므로 비트리(B-tree) 인덱스 기준 탐색 비용은 일반적으로 O(log n)이다. 여기서 n은 부모 테이블의 인덱스 항목 수다.

외래 키 검사 외에도 다음 비용이 발생한다.

  • 자식 행 저장
  • 자식 테이블의 각 인덱스 갱신
  • 잠금 획득과 동시성 제어
  • 미리 쓰기 로그(WAL) 기록

부모 행 삭제·키 수정

부모 행을 삭제하거나 참조 대상 키를 수정하면 PostgreSQL은 자식 테이블에 해당 값을 참조하는 행이 있는지 확인한다.

PostgreSQL은 외래 키를 생성할 때 자식의 참조 열에 인덱스를 자동 생성하지 않는다.

자식 열에 인덱스가 없으면 참조 행 확인에 자식 테이블 전체를 검사하는 순차 스캔(Sequential Scan)이 발생할 수 있다. 자식 행 수를 m이라고 하면 검사 비용은 일반적으로 O(m)이다.

sql
CREATE INDEX orders_user_id_idx ON orders(user_id);

비트리 인덱스가 있으면 참조 행 탐색은 일반적으로 O(log m + k)다. k는 해당 부모를 참조하는 자식 행 수다.

ON DELETE CASCADE는 k개의 자식 행을 실제로 삭제하므로 최소 O(k)에 해당하는 작업이 필요하다. 각 자식 행의 다른 인덱스와 연쇄 외래 키도 추가 비용을 만든다.

쓰기 비용

외래 키와 관련 인덱스는 데이터 무결성과 조회 성능을 높이지만 쓰기 비용을 늘린다.

  • INSERT: 부모 키 존재 여부 검사
  • 외래 키 UPDATE: 새 부모 키 존재 여부 검사
  • 부모 키 UPDATE: 자식 참조 확인 또는 갱신
  • 부모 DELETE: 자식 참조 확인 또는 연쇄 처리
  • 자식 인덱스 유지: 저장 공간과 인덱스 갱신 비용 증가

외래 키 열의 인덱스는 자동으로 필요하다고 단정할 수 없다. 부모 삭제·수정, 외래 키 기반 조인, 자식 행 조회가 자주 발생하는지 확인해 추가한다.

6. 실무 사용 사례

일대다 관계

사용자 한 명이 여러 주문을 가질 수 있다.

sql
CREATE TABLE app_users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, email text NOT NULL UNIQUE ); CREATE TABLE app_orders ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint NOT NULL REFERENCES app_users(id), amount numeric(12, 2) NOT NULL CHECK (amount >= 0) );

다대다 관계

학생과 강의처럼 양쪽 모두 여러 상대와 연결될 수 있으면 연결 테이블(Junction Table)을 사용한다.

sql
CREATE TABLE students ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL ); CREATE TABLE courses ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, title text NOT NULL ); CREATE TABLE enrollments ( student_id bigint NOT NULL REFERENCES students(id) ON DELETE CASCADE, course_id bigint NOT NULL REFERENCES courses(id) ON DELETE CASCADE, enrolled_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (student_id, course_id) );

복합 기본 키가 같은 학생의 같은 강의 중복 수강 등록을 막는다.

선택적 관계

게시물이 작성자를 선택적으로 참조하고, 작성자가 삭제되면 작성자 정보만 제거하도록 만들 수 있다.

sql
CREATE TABLE authors ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL ); CREATE TABLE posts ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, author_id bigint REFERENCES authors(id) ON DELETE SET NULL, title text NOT NULL );

자기 참조 관계

조직도나 댓글 답글은 같은 테이블의 다른 행을 참조할 수 있다.

sql
CREATE TABLE employees ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, manager_id bigint REFERENCES employees(id) ON DELETE SET NULL, name text NOT NULL );

최상위 직원은 manager_id가 널이다.

테넌트 경계 보호

다중 테넌트(Multi-tenant) 시스템에서는 테넌트 식별자를 포함한 복합 외래 키로 다른 테넌트의 데이터를 잘못 참조하는 상황을 막을 수 있다.

sql
CREATE TABLE tenant_projects ( tenant_id bigint NOT NULL, project_id bigint NOT NULL, name text NOT NULL, PRIMARY KEY (tenant_id, project_id) ); CREATE TABLE tenant_tasks ( tenant_id bigint NOT NULL, task_id bigint NOT NULL, project_id bigint NOT NULL, title text NOT NULL, PRIMARY KEY (tenant_id, task_id), FOREIGN KEY (tenant_id, project_id) REFERENCES tenant_projects(tenant_id, project_id) ON DELETE CASCADE );

7. 주의할 점

연쇄 삭제 범위 확인

ON DELETE CASCADE는 편리하지만 관계가 여러 단계로 연결되면 많은 행을 한 번에 삭제할 수 있다.

text
users
└─ orders
   └─ order_items
      └─ item_options

상위 사용자 삭제가 모든 하위 데이터 삭제로 이어질 수 있다. 결제, 감사 로그, 법적 보관 데이터에는 RESTRICT나 소프트 삭제(Soft Delete)가 더 적합할 수 있다.

외래 키 열 인덱스 직접 생성

부모 키의 인덱스는 참조 대상 제약 조건으로 확보되지만 자식 외래 키 열의 인덱스는 자동 생성되지 않는다.

sql
CREATE INDEX orders_user_id_idx ON orders(user_id);

부모 삭제·수정 또는 user_id 기반 조회가 자주 발생한다면 인덱스가 유리하다.

타입 일관성 유지

참조하는 열과 참조되는 열은 같은 데이터 타입을 사용하는 편이 안전하다.

sql
-- 부모 id bigint PRIMARY KEY -- 자식 user_id bigint REFERENCES users(id)

한쪽은 integer, 다른 쪽은 bigint처럼 다르게 정의하면 범위 차이와 스키마 관리 문제가 생긴다.

널과 선택적 관계 구분

외래 키가 널을 허용하면 관계가 선택적이라는 뜻이다. 관계가 필수라면 NOT NULL을 함께 선언한다.

sql
user_id bigint NOT NULL REFERENCES users(id)

외래 키만 선언하고 NOT NULL을 생략하면 널 저장은 허용된다.

외래 키가 업무 규칙 전체를 보장하지는 않음

외래 키는 참조 대상의 존재만 보장한다. 주문의 소유자와 결제 수단의 소유자가 같은지 같은 복합 업무 규칙은 별도 제약 조건, 트리거(Trigger), 또는 애플리케이션 로직이 필요할 수 있다.

기존 데이터에 제약 조건 추가

기존 테이블에 외래 키를 추가하면 PostgreSQL이 기존 행의 유효성을 검사한다. 큰 테이블에서는 검사 시간과 잠금 영향을 확인해야 한다.

먼저 새 데이터에만 제약 조건을 적용한 뒤 별도로 검증할 수 있다.

sql
ALTER TABLE orders ADD CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID; ALTER TABLE orders VALIDATE CONSTRAINT orders_user_fk;

NOT VALID로 추가해도 이후 삽입·수정되는 행에는 제약 조건이 적용된다. VALIDATE CONSTRAINT가 기존 행을 검사한다.

8. 핵심 정리

  • 외래 키(Foreign Key)는 자식 테이블의 값이 유효한 부모 키를 참조하도록 제한한다.
  • 외래 키는 고아 행과 경쟁 조건으로 인한 잘못된 관계를 데이터베이스에서 차단한다.
  • ON DELETE와 ON UPDATE로 거부, 연쇄 처리, 널 설정 같은 동작을 정의한다.
  • PostgreSQL은 부모의 참조 대상에 고유 인덱스를 요구하지만 자식의 외래 키 열에는 인덱스를 자동 생성하지 않는다.
  • 자식 외래 키 인덱스가 없으면 부모 삭제·수정 시 순차 스캔이 발생할 수 있다.
  • NOT NULL, 복합 외래 키, 지연 검사, NOT VALID를 관계의 필수성·구조·배포 방식에 맞게 사용해야 한다.