infra
Platform

모듈 맵

[Database] PK, FK 제약조건과 Cascade 설정이 주는 영향

0 / 37 완료

펼치기
0 / 37 완료0%

데이터베이스 설계 & 튜닝 · 05 / 37

[Database] PK, FK 제약조건과 Cascade 설정이 주는 영향

기본키와 외래키의 역할을 이해하고 제약조건으로 잘못된 데이터 저장을 방지합니다

🚨INCIDENT ALERT
HIGH

테이블을 만들 때 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 — 자연키(Natural Key) vs 대리키(Surrogate Key). 자연키는 이메일·사업자번호처럼 업무상 의미가 있는 값을 PK로 쓰는 방식으로 추가 컬럼이 없지만 값이 바뀌면 참조하는 모든 곳을 함께 고쳐야 하고 노출 위험이 있다. 대리키는 AUTO_INCREMENT·UUID처럼 의미 없는 인조 식별자를 PK로 두는 방식으로 값이 안 바뀌고 조인이 단순해 실무에서 널리 쓴다확대

기본키란 무엇인가

기본키(PRIMARY KEY)는 테이블의 각 행을 고유하게 식별하는 컬럼(또는 컬럼 집합)입니다. DB는 자동으로 이 컬럼에 UNIQUE 인덱스를 생성하고 NOT NULL을 강제합니다.

자연키(Natural Key)의 문제

자연키는 데이터 자체에서 고유성을 갖는 값(이메일, 주민번호 등)을 PK로 사용하는 방식입니다. 직관적으로 보이지만 운영 환경에서 여러 문제를 일으킵니다.

SQL
CREATE TABLE users (
    email      VARCHAR(255) PRIMARY KEY,
    name       VARCHAR(100) NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);
OUTPUT
실행 완료 또는 조회 결과가 표시됩니다.
🔍실행 후 확인할 것
  • 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를 사용합니다.

SQL
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_INCREMENTUUID (v4)
생성 방식DB 시퀀스 (중앙집중)각 노드에서 독립 생성
저장 크기8 bytes16 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 컬럼 없이도 중복 삽입을 방지할 수 있습니다.

SQL
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와 CASCADE — 참조 무결성과 삭제 전파. users(부모)-orders(자식)-order_items(손자)가 FK로 연결될 때 부모 행 삭제 시 ON DELETE 옵션이 동작을 결정한다: CASCADE(자식 행도 함께 삭제, 연쇄)·RESTRICT(자식이 존재하면 삭제 거부, 안전한 기본값)·SET NULL(FK를 NULL로 변경)·SET DEFAULT(기본값으로 변경)·NO ACTION(RESTRICT 유사, 지연 체크). FK가 참조 무결성을 지켜 존재하지 않는 부모를 가리키는 고아 레코드를 막는다확대

외래키(Foreign Key)란

외래키는 한 테이블의 컬럼 값이 반드시 다른 테이블의 PK(또는 UNIQUE 컬럼)에 존재해야 함을 보장합니다. 이를 **참조 무결성(Referential Integrity)**이라 합니다. FK 제약조건을 선언할 때는 ON DELETE 정책을 반드시 명시해야 합니다. 기본값에 의존하면 팀원이 의도를 파악하기 어렵습니다.

위험 명령어

운영 데이터에 적용하면 되돌리기 어려운 변경입니다. 실행 전 대상 테이블, WHERE 조건, 백업 또는 롤백 경로를 반드시 확인하세요.

SQL
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 ACTIONRESTRICT와 동일하나 DEFERRABLE과 함께 쓸 때 차이
CASCADE부모 삭제 시 자식도 자동 삭제
SET NULL부모 삭제 시 자식의 FK 컬럼을 NULL로 설정 (컬럼이 nullable이어야)
SET DEFAULT부모 삭제 시 자식의 FK를 기본값으로 설정

각 옵션의 사용 원칙을 이해하는 것이 중요합니다. CASCADE는 "부모 없이는 자식도 의미 없는" 강한 종속 관계에만 써야 합니다. SET NULL은 부모가 사라져도 자식 레코드가 독립적으로 의미를 가질 때 적합합니다. RESTRICT는 실수로 인한 삭제를 방지해야 할 때 사용합니다.

SQL
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
);
ON DELETE 동작 선택 + CASCADE 위험 판단 기준
기본값 — 부모를 함부로 못 지우게 (대부분의 운영)ON DELETE RESTRICT/NO ACTION — 자식이 있으면 부모 삭제를 막음. 실수 대량삭제 방지 1순위
부모 삭제 시 자식도 당연히 사라져야 (예: 게시글→댓글)ON DELETE CASCADE — 단 전파 규모를 먼저 계산(아래 위험 기준)
자식 FK를 NULL로 끊고 보존 (예: 탈퇴 후 주문 익명화)ON DELETE SET NULL — FK 컬럼이 nullable이어야 함
CASCADE 전파 규모가 큰가? (users 1건 → orders 5 → order_items 15 = 21건)누적 삭제가 10,000건 이상이면 수십 초+ 트랜잭션·Lock 경합 위험 — 동기 CASCADE 지양
대용량 삭제 필요 (계정 정리·GDPR 등)soft delete(deleted_at) + 배치 물리삭제(야간·청크 단위)로 분리 — 한 방 CASCADE 삭제는 운영 중 위험
1ON DELETE 동작을 직접 검증 — RESTRICT는 막고 CASCADE는 전파

옵션 표만 보면 머릿속에 안 남습니다. 부모·자식을 만들고 부모를 삭제해 RESTRICT(거부)와 CASCADE(전파)가 실제로 어떻게 갈리는지 눈으로 확인합니다.

SQL
-- 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 이어야 함
OUTPUT
-- 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 조건, 백업 또는 롤백 경로를 반드시 확인하세요.

SQL
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 조건, 백업 또는 롤백 경로를 반드시 확인하세요.

SQL
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;로 재선언합니다.

💼
실무 맥락마이크로서비스에서 DB FK 대신 앱 레벨 무결성 관리
현업 패턴

주문 서비스(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가 어느 방향으로 무엇을 확인하는지 알면 에러 메시지와 성능 문제를 단계로 좁힐 수 있습니다.

TEXT
자식 쪽 쓰기 — "내가 가리키는 부모가 실재하나?"   (자식 → 부모 확인)
  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가 확인하는 것과, 실수하면 생기는 일:

DMLDB가 확인하는 것 (방향)결과 · 실수하면
자식 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정규화 원칙과 언제 역정규화를 선택해야 하는지 실전 기준을 다룹니다.

지식 확인

퀴즈 — 8문제

Q1

자연키(Natural Key) 사용의 단점으로 가장 적절한 것은?

Q2

분산 시스템에서 PK로 SERIAL보다 UUID가 더 적합한 주된 이유는?

Q3

쇼핑몰에서 회원 탈퇴 시 해당 고객의 주문(orders)과 주문 아이템(order_items)도 모두 자동으로 삭제되어야 한다. orders.user_id FK와 order_items.order_id FK에 설정할 삭제 정책은?

Q4

복합 기본키(Composite Primary Key)를 사용하기 가장 적합한 경우는?

Q5

employees 테이블에 manager_id FK가 employees(id)를 자기 참조한다. CEO를 INSERT할 때 manager_id는 아직 INSERT 안 된 자기 자신 id를 가리켜야 해서 일반 FK로는 오류가 난다. 이를 해결하는 방법은?

Q6

orders.user_id에 users(id)를 가리키는 FOREIGN KEY를 걸어두면 DB가 보장해 주는 것은?

Q7

[심화] PostgreSQL에서 부모의 PK에는 인덱스가 자동 생성되지만, 자식 테이블의 FK 컬럼에는 인덱스가 자동으로 생기지 않는다. 이 컬럼에 인덱스가 없으면 특히 무엇이 느려지는가?

Q8

[심화] 부모 테이블에서 행 하나를 DELETE했을 뿐인데 몇 초에서 몇 분씩 걸리고, 그동안 자식 테이블 쓰기가 멈춘다. 가장 유력한 원인과 해결은?

0 / 8 답변

🧪 실습으로 확인하기

PostgreSQL 설치 및 기본 설정

초급

Linux 서버에 PostgreSQL을 설치하고, 데이터베이스와 사용자를 생성한 뒤 외부 접속이 가능하도록 설정한다.

50📋 5단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요