🗄️ 데이터베이스

Foreign Key

외래 키

다른 테이블의 Primary Key를 참조하여 테이블 간 관계를 정의하는 제약조건입니다. 참조 무결성(Referential Integrity)을 보장하고 데이터의 일관성을 유지합니다.

📖 상세 설명

Foreign Key(외래 키)는 관계형 데이터베이스에서 두 테이블 사이의 관계를 설정하는 핵심 제약조건입니다. 한 테이블의 열(또는 열 집합)이 다른 테이블의 Primary Key를 참조하여 "부모-자식" 관계를 형성합니다. 예를 들어 주문(orders) 테이블의 customer_id가 고객(customers) 테이블의 id를 참조하면, 존재하지 않는 고객에 대한 주문을 생성할 수 없습니다.

참조 무결성(Referential Integrity)은 FK의 핵심 목적입니다. 자식 테이블에 데이터를 삽입할 때 부모 테이블에 해당 값이 존재해야 합니다. 이를 통해 "고아 레코드(orphan records)"가 생기는 것을 방지하고, 데이터베이스 전체의 논리적 일관성을 보장합니다.

참조 동작(Referential Actions)은 부모 레코드가 삭제/수정될 때의 동작을 정의합니다. CASCADE는 자식도 함께 삭제/수정, SET NULL은 자식의 FK를 NULL로 설정, RESTRICT/NO ACTION은 자식이 있으면 삭제 거부, SET DEFAULT는 기본값으로 설정합니다. 비즈니스 로직에 따라 적절한 동작을 선택해야 합니다.

인덱싱과 성능 측면에서, FK 열에는 인덱스를 생성하는 것이 권장됩니다. JOIN 쿼리와 참조 무결성 검사 시 성능이 크게 향상됩니다. MySQL InnoDB는 FK에 자동으로 인덱스를 생성하지만, PostgreSQL은 명시적으로 생성해야 합니다.

FK는 1:1, 1:N, N:M 관계를 모델링하는 데 사용됩니다. N:M 관계는 중간 테이블(junction table)을 통해 두 개의 FK로 구현합니다. 적절한 FK 설계는 정규화된 스키마와 효율적인 쿼리의 기반이 됩니다.

💻 코드 예제

-- Foreign Key 예제 - SQL DDL

-- ============================================
-- 1. 기본 Foreign Key 생성
-- ============================================
-- 부모 테이블 (참조 대상)
CREATE TABLE customers (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 자식 테이블 (FK 보유)
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    customer_id INTEGER NOT NULL,
    total_amount DECIMAL(10, 2) NOT NULL,
    status VARCHAR(20) DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

    -- Foreign Key 제약조건
    CONSTRAINT fk_orders_customer
        FOREIGN KEY (customer_id)
        REFERENCES customers(id)
        ON DELETE RESTRICT
        ON UPDATE CASCADE
);


-- ============================================
-- 2. 참조 동작(Referential Actions)
-- ============================================
-- CASCADE: 부모 삭제 시 자식도 삭제
CREATE TABLE order_items (
    id SERIAL PRIMARY KEY,
    order_id INTEGER NOT NULL,
    product_name VARCHAR(200) NOT NULL,
    quantity INTEGER NOT NULL,
    price DECIMAL(10, 2) NOT NULL,

    FOREIGN KEY (order_id)
        REFERENCES orders(id)
        ON DELETE CASCADE  -- 주문 삭제 시 아이템도 삭제
);

-- SET NULL: 부모 삭제 시 NULL로 설정
CREATE TABLE posts (
    id SERIAL PRIMARY KEY,
    author_id INTEGER,  -- NULL 허용
    title VARCHAR(200) NOT NULL,
    content TEXT,

    FOREIGN KEY (author_id)
        REFERENCES users(id)
        ON DELETE SET NULL  -- 사용자 삭제 시 author_id = NULL
);


-- ============================================
-- 3. 복합 Foreign Key
-- ============================================
CREATE TABLE enrollments (
    student_id INTEGER NOT NULL,
    course_id INTEGER NOT NULL,
    semester VARCHAR(10) NOT NULL,
    grade CHAR(2),

    -- 복합 Primary Key
    PRIMARY KEY (student_id, course_id, semester),

    -- 각각의 FK
    FOREIGN KEY (student_id) REFERENCES students(id),
    FOREIGN KEY (course_id) REFERENCES courses(id)
);


-- ============================================
-- 4. FK 인덱스 생성 (PostgreSQL)
-- ============================================
-- FK 열에 인덱스 추가 (성능 향상)
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);


-- ============================================
-- 5. FK 활용 JOIN 쿼리
-- ============================================
-- 고객별 주문 조회
SELECT
    c.name AS customer_name,
    c.email,
    COUNT(o.id) AS order_count,
    SUM(o.total_amount) AS total_spent
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.name, c.email
ORDER BY total_spent DESC;


-- ============================================
-- 6. FK 제약조건 관리
-- ============================================
-- 기존 테이블에 FK 추가
ALTER TABLE orders
ADD CONSTRAINT fk_orders_shipping_address
    FOREIGN KEY (shipping_address_id)
    REFERENCES addresses(id);

-- FK 제약조건 삭제
ALTER TABLE orders
DROP CONSTRAINT fk_orders_shipping_address;

-- FK 일시적으로 비활성화 (MySQL)
SET FOREIGN_KEY_CHECKS = 0;
-- 대량 데이터 작업...
SET FOREIGN_KEY_CHECKS = 1;

-- FK 일시적으로 비활성화 (PostgreSQL)
ALTER TABLE orders DISABLE TRIGGER ALL;
-- 대량 데이터 작업...
ALTER TABLE orders ENABLE TRIGGER ALL;
# Foreign Key 예제 - Python + SQLAlchemy
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey, Numeric, DateTime
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, sessionmaker
from datetime import datetime

Base = declarative_base()


# ============================================
# 1. 1:N 관계 모델 정의
# ============================================
class Customer(Base):
    """고객 모델 (부모)"""
    __tablename__ = 'customers'

    id = Column(Integer, primary_key=True)
    name = Column(String(100), nullable=False)
    email = Column(String(255), unique=True, nullable=False)
    created_at = Column(DateTime, default=datetime.utcnow)

    # 관계 정의 (역참조)
    orders = relationship('Order', back_populates='customer',
                         cascade='all, delete-orphan')

    def __repr__(self):
        return f""


class Order(Base):
    """주문 모델 (자식)"""
    __tablename__ = 'orders'

    id = Column(Integer, primary_key=True)
    customer_id = Column(Integer, ForeignKey('customers.id',
                                             ondelete='CASCADE',
                                             onupdate='CASCADE'),
                        nullable=False)
    total_amount = Column(Numeric(10, 2), nullable=False)
    status = Column(String(20), default='pending')
    created_at = Column(DateTime, default=datetime.utcnow)

    # 관계 정의
    customer = relationship('Customer', back_populates='orders')
    items = relationship('OrderItem', back_populates='order',
                        cascade='all, delete-orphan')


class OrderItem(Base):
    """주문 아이템 모델"""
    __tablename__ = 'order_items'

    id = Column(Integer, primary_key=True)
    order_id = Column(Integer, ForeignKey('orders.id', ondelete='CASCADE'),
                     nullable=False)
    product_name = Column(String(200), nullable=False)
    quantity = Column(Integer, nullable=False)
    price = Column(Numeric(10, 2), nullable=False)

    order = relationship('Order', back_populates='items')


# ============================================
# 2. N:M 관계 (다대다)
# ============================================
from sqlalchemy import Table

# 중간 테이블 (Association Table)
student_course = Table(
    'student_courses',
    Base.metadata,
    Column('student_id', Integer, ForeignKey('students.id', ondelete='CASCADE'),
           primary_key=True),
    Column('course_id', Integer, ForeignKey('courses.id', ondelete='CASCADE'),
           primary_key=True)
)


class Student(Base):
    __tablename__ = 'students'

    id = Column(Integer, primary_key=True)
    name = Column(String(100), nullable=False)

    # 다대다 관계
    courses = relationship('Course', secondary=student_course,
                          back_populates='students')


class Course(Base):
    __tablename__ = 'courses'

    id = Column(Integer, primary_key=True)
    title = Column(String(200), nullable=False)

    students = relationship('Student', secondary=student_course,
                           back_populates='courses')


# ============================================
# 3. FK 사용 예제
# ============================================
def main():
    # 데이터베이스 연결
    engine = create_engine('postgresql://localhost/mydb')
    Base.metadata.create_all(engine)

    Session = sessionmaker(bind=engine)
    session = Session()

    try:
        # 고객 생성
        customer = Customer(name='홍길동', email='hong@example.com')
        session.add(customer)
        session.flush()  # ID 할당

        # 주문 생성 (FK 자동 검증)
        order = Order(
            customer_id=customer.id,
            total_amount=50000
        )
        session.add(order)

        # relationship을 통한 접근
        order.items.append(OrderItem(
            product_name='노트북',
            quantity=1,
            price=50000
        ))

        session.commit()

        # FK를 통한 JOIN 쿼리
        result = session.query(Customer, Order)\
            .join(Order, Customer.id == Order.customer_id)\
            .filter(Customer.name == '홍길동')\
            .all()

        for customer, order in result:
            print(f"{customer.name}: 주문금액 {order.total_amount}")

        # 존재하지 않는 FK 삽입 시도 - 에러 발생
        # invalid_order = Order(customer_id=99999, total_amount=10000)
        # session.add(invalid_order)
        # session.commit()  # IntegrityError 발생!

    except Exception as e:
        session.rollback()
        print(f"에러: {e}")
    finally:
        session.close()


if __name__ == '__main__':
    main()
// Foreign Key 예제 - Node.js + Prisma

// ============================================
// 1. Prisma 스키마 (schema.prisma)
// ============================================
/*
model Customer {
  id        Int       @id @default(autoincrement())
  name      String
  email     String    @unique
  createdAt DateTime  @default(now())

  // 1:N 관계 (한 고객이 여러 주문)
  orders    Order[]
}

model Order {
  id          Int         @id @default(autoincrement())
  customerId  Int         // Foreign Key
  totalAmount Decimal     @db.Decimal(10, 2)
  status      String      @default("pending")
  createdAt   DateTime    @default(now())

  // FK 관계 정의
  customer    Customer    @relation(fields: [customerId], references: [id],
                                   onDelete: Cascade, onUpdate: Cascade)
  items       OrderItem[]

  @@index([customerId])  // FK에 인덱스 추가
}

model OrderItem {
  id          Int     @id @default(autoincrement())
  orderId     Int     // Foreign Key
  productName String
  quantity    Int
  price       Decimal @db.Decimal(10, 2)

  order       Order   @relation(fields: [orderId], references: [id],
                               onDelete: Cascade)

  @@index([orderId])
}

// N:M 관계 (다대다)
model Student {
  id      Int       @id @default(autoincrement())
  name    String
  courses Course[]  // 암묵적 다대다
}

model Course {
  id       Int       @id @default(autoincrement())
  title    String
  students Student[]
}
*/


// ============================================
// 2. Prisma Client 사용
// ============================================
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();

async function main() {
    // 고객과 주문을 함께 생성 (Nested Write)
    const customer = await prisma.customer.create({
        data: {
            name: '홍길동',
            email: 'hong@example.com',
            orders: {
                create: [
                    {
                        totalAmount: 50000,
                        status: 'completed',
                        items: {
                            create: [
                                { productName: '노트북', quantity: 1, price: 50000 }
                            ]
                        }
                    }
                ]
            }
        },
        include: {
            orders: {
                include: { items: true }
            }
        }
    });

    console.log('생성된 고객:', customer);


    // FK를 통한 관계 조회
    const ordersWithCustomer = await prisma.order.findMany({
        where: {
            customer: {
                name: '홍길동'
            }
        },
        include: {
            customer: true,
            items: true
        }
    });

    console.log('주문 목록:', ordersWithCustomer);


    // FK 무결성 테스트 - 존재하지 않는 고객 ID
    try {
        await prisma.order.create({
            data: {
                customerId: 99999,  // 존재하지 않는 ID
                totalAmount: 10000
            }
        });
    } catch (error) {
        console.error('FK 위반 에러:', error.message);
        // Foreign key constraint failed on the field: `Order_customerId_fkey`
    }


    // CASCADE 삭제 테스트
    // 고객 삭제 시 주문과 주문아이템도 함께 삭제
    await prisma.customer.delete({
        where: { id: customer.id }
    });
    console.log('고객 삭제 완료 (CASCADE로 주문도 삭제됨)');


    // N:M 관계 예제
    const student = await prisma.student.create({
        data: {
            name: '김학생',
            courses: {
                create: [
                    { title: '데이터베이스' },
                    { title: '알고리즘' }
                ]
            }
        },
        include: { courses: true }
    });

    // 기존 코스에 학생 연결
    await prisma.student.update({
        where: { id: student.id },
        data: {
            courses: {
                connect: [{ id: 1 }, { id: 2 }]  // 기존 코스 연결
            }
        }
    });
}

main()
    .catch(console.error)
    .finally(() => prisma.$disconnect());

🗣️ 실무에서 이렇게 말하세요

💬 데이터베이스 설계 회의에서
"주문 테이블에 customer_id를 Foreign Key로 걸어야 합니다. 그래야 존재하지 않는 고객에 대한 주문이 생성되는 걸 막을 수 있어요. ON DELETE는 RESTRICT로 하면 주문 있는 고객은 삭제가 안 되니까 데이터 무결성이 보장됩니다."
💬 성능 이슈 트러블슈팅 중
"FK 열에 인덱스가 없어서 JOIN이 느린 것 같습니다. PostgreSQL은 FK에 자동으로 인덱스를 안 만들어주거든요. orders 테이블의 customer_id에 인덱스 추가하면 조회 속도가 확 빨라질 겁니다."
💬 마이그레이션 작업 논의 중
"대량 데이터 마이그레이션할 때는 FK 체크를 일시적으로 끄는 게 좋습니다. MySQL은 SET FOREIGN_KEY_CHECKS = 0으로 비활성화하고, 작업 끝나면 다시 켜요. 안 그러면 매 INSERT마다 참조 검증해서 엄청 느립니다."

⚠️ 주의사항 & 베스트 프랙티스

❌
CASCADE DELETE 남용 금지

ON DELETE CASCADE는 편리하지만 의도치 않은 대량 삭제를 유발할 수 있습니다. 중요한 데이터는 RESTRICT를 사용하고, 삭제는 애플리케이션에서 명시적으로 처리하세요.

❌
FK 인덱스 누락 주의

PostgreSQL은 FK에 자동 인덱스를 생성하지 않습니다. FK 열에는 반드시 인덱스를 추가하세요. 없으면 JOIN과 DELETE 성능이 크게 저하됩니다.

❌
순환 참조 방지

테이블 A가 B를 참조하고 B가 다시 A를 참조하면 순환 참조가 됩니다. 삽입/삭제 순서 문제가 생기므로 설계 시 피해야 합니다.

✅
FK 베스트 프랙티스

FK 열 이름은 참조하는 테이블_id 형식(customer_id)으로 통일, 모든 FK에 인덱스 추가, 참조 동작은 비즈니스 로직에 맞게 선택, N:M은 중간 테이블로 구현.

🔗 관련 용어

📚 더 배우기