Foreign Key
외래 키
다른 테이블의 Primary Key를 참조하여 테이블 간 관계를 정의하는 제약조건입니다. 참조 무결성(Referential Integrity)을 보장하고 데이터의 일관성을 유지합니다.
외래 키
다른 테이블의 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마다 참조 검증해서 엄청 느립니다."
ON DELETE CASCADE는 편리하지만 의도치 않은 대량 삭제를 유발할 수 있습니다. 중요한 데이터는 RESTRICT를 사용하고, 삭제는 애플리케이션에서 명시적으로 처리하세요.
PostgreSQL은 FK에 자동 인덱스를 생성하지 않습니다. FK 열에는 반드시 인덱스를 추가하세요. 없으면 JOIN과 DELETE 성능이 크게 저하됩니다.
테이블 A가 B를 참조하고 B가 다시 A를 참조하면 순환 참조가 됩니다. 삽입/삭제 순서 문제가 생기므로 설계 시 피해야 합니다.
FK 열 이름은 참조하는 테이블_id 형식(customer_id)으로 통일, 모든 FK에 인덱스 추가, 참조 동작은 비즈니스 로직에 맞게 선택, N:M은 중간 테이블로 구현.