MySQL ‐ Foreign Key & Strategic Patterns - woojin-playground/Backend-PlayGround GitHub Wiki

MySQL - Foreign Key & Strategic Patterns

Foreign Key Fundamentals: Data Consistency & Historical Context

  • 외래키란, 다른 테이블의 기본키를 참조하는 컬럼이다.
CREATE TABLE users (
    id BIGINT NOT NULL PRIMARY KEY,
    name VARCHAR(100) NOT NULL
);

CREATE TABLE posts (
    id BIGINT NULL AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

Enforcing Foreign Keys: Reference Options & Constraints

CREATE TABLE users (
    id INTEGER NOT NULL PRIMARY KEY,
    name VARCHAR(64) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE
);

CREATE TABLE posts (
    id INTEGER NOT NULL PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    body TEXT NOT NULL,
    user_id INTEGER NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT
);

CREATE TABLE comments (
    id INTEGER NOT NULL PRIMARY KEY,
    post_id INTEGER NOT NULL,
    user_id INTEGER NOT NULL,
    content TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT
);
  • ON DELETE CASCADE : 부모 테이블의 레코드가 삭제될 때 해당 레코드를 참조하는 자식 테이블의 레코드도 자동으로 함께 삭제된다.
  • ON DELETE RESTRICT : 부모 테이블의 레코드를 삭제하려 할 때 자식 테이블에 참조하는 레코드가 존재하면 삭제 자체를 거부한다. DB 레벨에서 에러를 던지는 방식이다.

❗선택 기준

  • 자식 데이터가 부모에 종속적인 생명주기를 가진다면 CASCADE, 자식 데이터가 독립적인 의미를 가지거나 실수 삭제를 방지해야 한다면 RESTRICT가 적합하다.
  • 참고로 MySQL InnoDB에서는 ON DELETE NO ACTIONON DELETE RESTRICT는 동작이 사실상 동일하지만, RESTRICT는 트랜잭션 내에서도 즉시 체크한다는 차이가 있다.
-- RESTRICT: 참조하는 레코드가 있을 때 삭제를 거부
CREATE TABLE posts (
    id INTEGER PRIMARY KEY,
    user_id INTEGER NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT
);

-- CASCADE: 참조하는 레코드도 함께 삭제
CREATE TABLE comments (
    id INTEGER PRIMARY KEY,
    post_id INTEGER NOT NULL,
    FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
);

-- SET NULL: 참조하는 컬럼을 NULL로 설정
CREATE TABLE orders (
    id INTEGER PRIMARY KEY,
    customer_id INTEGER,
    FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE SET NULL
);

-- SET DEFAULT: 참조하는 컬럼을 기본값으로 설정
CREATE TABLE products (
    id INTEGER PRIMARY KEY,
    category_id INTEGER DEFAULT 1,
    FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET DEFAULT
);

-- CASCADE: 기본키가 변경되면 외래키도 함께 변경
CREATE TABLE posts (
    id INTEGER PRIMARY KEY,
    user_id INTEGER NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE
);

-- SET NULL: 기본키가 변경되면 외래키를 NULL로 설정
CREATE TABLE orders (
    id INTEGER PRIMARY KEY,
    customer_id INTEGER,
    FOREIGN KEY (customer_id) REFERENCES customers(id) ON UPDATE SET NULL
);

Beyond the Basics: Is Foreign Key Always the Right Choice?

  • 외래키는 본질적으로 DB가 애플리케이션을 믿지 않겠다는 선언이다.
  • 애플리케이션 코드는 버그가 있고 여러 서비스가 같은 DB를 바라보고 누군가 직접 쿼리를 날리기도 한다. 외래키는 그 모든 경로에서 참조 무결성을 강제한다.
  • 이 보호막이 없어진다면 고아 데이터가 언제 어떤 경로로 들어왔는가 추적하기가 어렵다.
  • 하지만 외래키가 강제되어야만 한다는 것은 아니다. 외래키를 포기하는 현실적인 이유도 있다.

대규모 트래픽에서의 락 경합

INSERT INTO comments (post_id, ...) VALUES (1, ...)
         ↓
posts 테이블에서 id=1 존재 여부 확인
         ↓
posts 행에 공유 락(Shared Lock) 획득
         ↓
확인 완료 → 락 해제
  • 댓글 INSERT가 많을수록 posts 테이블에 락이 집중된다. 인기 게시글 하나에 동시 댓글 수백 건이 몰리면 이 오버헤드가 실측으로 드러나게 된다.

수평 확장(샤딩)과의 충돌

posts  → DB Shard A
comments → DB Shard B
  • 샤드가 분리되는 순간 DB 수준의 외래키는 물리적으로 불가능하다. 대형 서비스가 외래키를 제거하는 가장 흔한 이유이다.

이벤트 소싱 / MSA 아키텍처

PostDeleted 이벤트 발행
       ↓
Comment Service가 이벤트 구독
       ↓
관련 댓글 처리 (삭제 or 상태 변경)
  • 마이크로서비스 환경에서는 postscomments가 아예 다른 서비스, 다른 DB에 존재한다. 이 경우 참조 무결성은 DB가 아닌 이벤트와 애플리케이션 로직이 담당한다.

배치 / 마이그레이션 작업

SET FOREIGN_KEY_CHECKS = 0;
-- 대량 INSERT
SET FOREIGN_KEY_CHECKS = 1;
  • 대량 데이터를 INSERT할 때 외래키 체크는 건당 조회를 유발한다.
  • 결과적으로 외래키는 선택이지만 무결성은 선택이 아니라는 것을 말하고 싶다.