database-schema-design
Database Schema Design
When to use this skill
이 스킬을 트리거해야 하는 구체적인 상황을 나열합니다:
- 신규 프로젝트: 새 애플리케이션의 데이터베이스 스키마 설계
- 스키마 리팩토링: 기존 스키마를 성능이나 확장성을 위해 재설계
- 관계 정의: 테이블 간 1:1, 1:N, N:M 관계 구현
- 마이그레이션: 스키마 변경사항을 안전하게 적용
- 성능 문제: 느린 쿼리를 해결하기 위한 인덱스 및 스키마 최적화
입력 형식 (Input Format)
사용자로부터 받아야 할 입력의 형식과 필수/선택 정보:
필수 정보
- 데이터베이스 종류: PostgreSQL, MySQL, MongoDB, SQLite 등
- 도메인 설명: 어떤 데이터를 저장할 것인지 (예: 전자상거래, 블로그, SNS)
- 주요 엔티티: 핵심 데이터 객체들 (예: User, Product, Order)
선택 정보
- 예상 데이터량: 작음(<10K rows), 중간(10K-1M), 대용량(>1M) (기본값: 중간)
- 읽기/쓰기 비율: Read-heavy, Write-heavy, Balanced (기본값: Balanced)
- 트랜잭션 요구사항: ACID 필요 여부 (기본값: true)
- 샤딩/파티셔닝: 대용량 데이터 분산 필요 여부 (기본값: false)
입력 예시
전자상거래 플랫폼의 데이터베이스를 설계해줘:
- DB: PostgreSQL
- 엔티티: User(사용자), Product(상품), Order(주문), Review(리뷰)
- 관계:
- User는 여러 Order를 가질 수 있음
- Order는 여러 Product를 포함 (N:M)
- Review는 User와 Product에 연결
- 예상 데이터: 10만 사용자, 1만 상품
- 읽기 중심 (상품 조회 빈번)
Instructions
단계별로 정확하게 따라야 할 작업 순서를 명시합니다.
Step 1: 엔티티 및 속성 정의
핵심 데이터 객체와 그 속성을 식별합니다.
작업 내용:
- 비즈니스 요구사항에서 명사 추출 → 엔티티
- 각 엔티티의 속성(칼럼) 나열
- 데이터 타입 결정 (VARCHAR, INTEGER, TIMESTAMP, JSON 등)
- Primary Key 지정 (UUID vs Auto-increment ID)
예시 (전자상거래):
Users (사용자)
- id: UUID PRIMARY KEY
- email: VARCHAR(255) UNIQUE NOT NULL
- username: VARCHAR(50) UNIQUE NOT NULL
- password_hash: VARCHAR(255) NOT NULL
- created_at: TIMESTAMP DEFAULT NOW()
- updated_at: TIMESTAMP DEFAULT NOW()
Products (상품)
- id: UUID PRIMARY KEY
- name: VARCHAR(255) NOT NULL
- description: TEXT
- price: DECIMAL(10, 2) NOT NULL
- stock: INTEGER DEFAULT 0
- category_id: UUID REFERENCES Categories(id)
- created_at: TIMESTAMP DEFAULT NOW()
Orders (주문)
- id: UUID PRIMARY KEY
- user_id: UUID REFERENCES Users(id)
- total_amount: DECIMAL(10, 2) NOT NULL
- status: VARCHAR(20) DEFAULT 'pending'
- created_at: TIMESTAMP DEFAULT NOW()
OrderItems (주문 상품 - 중간 테이블)
- id: UUID PRIMARY KEY
- order_id: UUID REFERENCES Orders(id) ON DELETE CASCADE
- product_id: UUID REFERENCES Products(id)
- quantity: INTEGER NOT NULL
- price: DECIMAL(10, 2) NOT NULL
Step 2: 관계 설계 및 정규화
테이블 간의 관계를 정의하고 정규화를 적용합니다.
작업 내용:
- 1:1 관계: Foreign Key + UNIQUE 제약
- 1:N 관계: Foreign Key
- N:M 관계: 중간(Junction) 테이블 생성
- 정규화 레벨 결정 (1NF ~ 3NF)
판단 기준:
- OLTP 시스템 → 3NF까지 정규화 (데이터 무결성)
- OLAP/분석 시스템 → 비정규화 허용 (쿼리 성능)
- 읽기 중심 → 일부 비정규화로 JOIN 최소화
- 쓰기 중심 → 완전 정규화로 중복 제거
예시 (ERD Mermaid):
erDiagram
Users ||--o{ Orders : places
Orders ||--|{ OrderItems : contains
Products ||--o{ OrderItems : "ordered in"
Categories ||--o{ Products : categorizes
Users ||--o{ Reviews : writes
Products ||--o{ Reviews : "reviewed by"
Users {
uuid id PK
string email UK
string username UK
string password_hash
timestamp created_at
}
Products {
uuid id PK
string name
decimal price
int stock
uuid category_id FK
}
Orders {
uuid id PK
uuid user_id FK
decimal total_amount
string status
timestamp created_at
}
OrderItems {
uuid id PK
uuid order_id FK
uuid product_id FK
int quantity
decimal price
}
Step 3: 인덱스 전략 수립
쿼리 성능을 위한 인덱스를 설계합니다.
작업 내용:
- Primary Key는 자동으로 인덱스 생성됨
- WHERE 절에 자주 사용되는 칼럼 → 인덱스 추가
- JOIN에 사용되는 Foreign Key → 인덱스
- 복합 인덱스 고려 (WHERE col1 = ? AND col2 = ?)
- UNIQUE 인덱스 (email, username 등)
확인 사항:
- 자주 조회되는 칼럼에 인덱스
- Foreign Key 칼럼에 인덱스
- 복합 인덱스 순서 최적화 (선택도 높은 칼럼 먼저)
- 과도한 인덱스 지양 (INSERT/UPDATE 성능 저하)
예시 (PostgreSQL):
-- Primary Keys (자동 인덱스)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL, -- UNIQUE = 자동 인덱스
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Foreign Keys + 명시적 인덱스
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
total_amount DECIMAL(10, 2) NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_created_at ON orders(created_at);
-- 복합 인덱스 (status와 created_at 함께 조회 빈번)
CREATE INDEX idx_orders_status_created ON orders(status, created_at DESC);
-- Products 테이블
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
price DECIMAL(10, 2) NOT NULL CHECK (price >= 0),
stock INTEGER DEFAULT 0 CHECK (stock >= 0),
category_id UUID REFERENCES categories(id),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_products_category ON products(category_id);
CREATE INDEX idx_products_price ON products(price); -- 가격 범위 검색
CREATE INDEX idx_products_name ON products(name); -- 상품명 검색
-- Full-text search (PostgreSQL)
CREATE INDEX idx_products_name_fts ON products USING GIN(to_tsvector('english', name));
CREATE INDEX idx_products_description_fts ON products USING GIN(to_tsvector('english', description));
Step 4: 제약조건 및 트리거 설정
데이터 무결성을 위한 제약조건을 추가합니다.
작업 내용:
- NOT NULL: 필수 칼럼
- UNIQUE: 중복 불가 칼럼
- CHECK: 값 범위 제한 (예: price >= 0)
- Foreign Key + CASCADE 옵션
- Default 값 설정
예시:
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
price DECIMAL(10, 2) NOT NULL CHECK (price >= 0),
stock INTEGER DEFAULT 0 CHECK (stock >= 0),
discount_percent INTEGER CHECK (discount_percent >= 0 AND discount_percent <= 100),
category_id UUID REFERENCES categories(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Trigger: updated_at 자동 갱신
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_products_updated_at
BEFORE UPDATE ON products
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
Step 5: 마이그레이션 스크립트 작성
스키마 변경사항을 안전하게 적용하는 마이그레이션을 작성합니다.
작업 내용:
- UP 마이그레이션: 변경 적용
- DOWN 마이그레이션: 롤백
- 트랜잭션으로 래핑
- 데이터 손실 방지 (ALTER TABLE 신중히)
예시 (SQL 마이그레이션):
-- migrations/001_create_initial_schema.up.sql
BEGIN;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) UNIQUE NOT NULL,
parent_id UUID REFERENCES categories(id)
);
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
price DECIMAL(10, 2) NOT NULL CHECK (price >= 0),
stock INTEGER DEFAULT 0 CHECK (stock >= 0),
category_id UUID REFERENCES categories(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_products_category ON products(category_id);
CREATE INDEX idx_products_price ON products(price);
COMMIT;
-- migrations/001_create_initial_schema.down.sql
BEGIN;
DROP TABLE IF EXISTS products CASCADE;
DROP TABLE IF EXISTS categories CASCADE;
DROP TABLE IF EXISTS users CASCADE;
COMMIT;
Output format
결과물이 따라야 할 정확한 형식을 정의합니다.
기본 구조
프로젝트/
├── database/
│ ├── schema.sql # 전체 스키마
│ ├── migrations/
│ │ ├── 001_create_users.up.sql
│ │ ├── 001_create_users.down.sql
│ │ ├── 002_create_products.up.sql
│ │ └── 002_create_products.down.sql
│ ├── seeds/
│ │ └── sample_data.sql # 테스트 데이터
│ └── docs/
│ ├── ERD.md # Mermaid ERD 다이어그램
│ └── SCHEMA.md # 스키마 설명 문서
└── README.md
ERD 다이어그램 (Mermaid 형식)
# Database Schema
## Entity Relationship Diagram
\`\`\`mermaid
erDiagram
Users ||--o{ Orders : places
Orders ||--|{ OrderItems : contains
Products ||--o{ OrderItems : "ordered in"
Users {
uuid id PK
string email UK
string username UK
}
Products {
uuid id PK
string name
decimal price
}
\`\`\`
## Table Descriptions
### users
- **Purpose**: Store user account information
- **Indexes**: email, username
- **Estimated rows**: 100,000
### products
- **Purpose**: Product catalog
- **Indexes**: category_id, price, name
- **Estimated rows**: 10,000
Constraints
반드시 지켜야 할 규칙과 금지 사항을 명시합니다.
필수 규칙 (MUST)
-
Primary Key 필수: 모든 테이블에 Primary Key 정의
- 레코드 고유 식별
- 참조 무결성 보장
-
Foreign Key 명시: 관계가 있는 테이블은 반드시 Foreign Key 설정
- ON DELETE CASCADE/SET NULL 옵션 명시
- Orphan 레코드 방지
-
NOT NULL 적절히 사용: 필수 칼럼은 NOT NULL
- NULL 허용 여부 명확히
- 기본값 제공 권장
금지 사항 (MUST NOT)
-
EAV 패턴 남용: Entity-Attribute-Value 패턴은 특별한 경우에만
- 쿼리 복잡도 급증
- 성능 저하
-
과도한 비정규화: 성능을 위한 비정규화는 신중히
- 데이터 일관성 문제
- 업데이트 이상 발생 위험
-
민감정보 평문 저장: 비밀번호, 카드번호 등은 절대 평문 저장 금지
- 해싱/암호화 필수
- 법적 책임 문제
보안 규칙
- 최소 권한 원칙: 애플리케이션 DB 계정은 필요한 권한만 부여
- SQL Injection 방지: Prepared Statements/Parameterized Queries 사용
- 민감 칼럼 암호화: 개인정보는 암호화 저장 고려
Examples
실제 사용 사례를 통해 스킬의 적용 방법을 보여줍니다.
예시 1: 블로그 플랫폼 스키마
상황: Medium 스타일의 블로그 플랫폼 데이터베이스 설계
사용자 요청:
블로그 플랫폼을 위한 PostgreSQL 스키마를 설계해줘:
- 사용자는 여러 포스트를 작성
- 포스트는 여러 태그를 가짐 (N:M)
- 사용자는 포스트에 좋아요, 북마크 가능
- 댓글 기능 (대댓글 지원)
최종 결과:
-- Users
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(50) UNIQUE NOT NULL,
bio TEXT,
avatar_url VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW()
);
-- Posts
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
content TEXT NOT NULL,
published_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_posts_author ON posts(author_id);
CREATE INDEX idx_posts_published ON posts(published_at);
CREATE INDEX idx_posts_slug ON posts(slug);
-- Tags
CREATE TABLE tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50) UNIQUE NOT NULL,
slug VARCHAR(50) UNIQUE NOT NULL
);
-- Post-Tag relationship (N:M)
CREATE TABLE post_tags (
post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
CREATE INDEX idx_post_tags_post ON post_tags(post_id);
CREATE INDEX idx_post_tags_tag ON post_tags(tag_id);
-- Likes
CREATE TABLE post_likes (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (user_id, post_id)
);
-- Bookmarks
CREATE TABLE post_bookmarks (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (user_id, post_id)
);
-- Comments (self-referencing for nested comments)
CREATE TABLE comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
parent_comment_id UUID REFERENCES comments(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_comments_post ON comments(post_id);
CREATE INDEX idx_comments_author ON comments(author_id);
CREATE INDEX idx_comments_parent ON comments(parent_comment_id);
예시 2: MongoDB 스키마 (NoSQL)
상황: 실시간 채팅 앱을 위한 MongoDB 스키마
사용자 요청:
실시간 채팅 앱의 MongoDB 스키마를 설계해줘.
읽기가 매우 빈번하고, 메시지 히스토리를 빠르게 조회해야 해.
최종 결과:
// users collection
{
_id: ObjectId,
username: String, // indexed, unique
email: String, // indexed, unique
avatar_url: String,
status: String, // 'online', 'offline', 'away'
last_seen: Date,
created_at: Date
}
// conversations collection (비정규화 - 읽기 최적화)
{
_id: ObjectId,
participants: [ // indexed
{
user_id: ObjectId,
username: String,
avatar_url: String
}
],
last_message: { // 비정규화로 최근 메시지 빠른 조회
content: String,
sender_id: ObjectId,
sent_at: Date
},
unread_counts: { // 각 참여자별 읽지 않은 메시지 수
"user_id_1": 5,
"user_id_2": 0
},
created_at: Date,
updated_at: Date
}
// messages collection
{
_id: ObjectId,
conversation_id: ObjectId, // indexed
sender_id: ObjectId,
content: String,
attachments: [
{
type: String, // 'image', 'file', 'video'
url: String,
filename: String
}
],
read_by: [ObjectId], // 읽은 사용자 ID 배열
sent_at: Date, // indexed
edited_at: Date
}
// Indexes
db.users.createIndex({ username: 1 }, { unique: true });
db.users.createIndex({ email: 1 }, { unique: true });
db.conversations.createIndex({ "participants.user_id": 1 });
db.conversations.createIndex({ updated_at: -1 });
db.messages.createIndex({ conversation_id: 1, sent_at: -1 });
db.messages.createIndex({ sender_id: 1 });
설계 특징:
- 읽기 최적화를 위한 비정규화 (last_message 임베딩)
- 자주 조회되는 필드에 인덱스
- 배열 필드 활용 (participants, read_by)
Best practices
품질 향상
-
명명 규칙 일관성: 테이블/칼럼 이름은 snake_case 사용
- users, post_tags, created_at
- 복수형/단수형 일관되게 (테이블은 복수, 칼럼은 단수 등)
-
Soft Delete 고려: 중요 데이터는 물리 삭제 대신 논리 삭제
- deleted_at TIMESTAMP (NULL이면 활성, NOT NULL이면 삭제됨)
- 실수로 삭제한 데이터 복구 가능
- 감사(Audit) 추적
-
Timestamp 필수: created_at, updated_at은 대부분 테이블에 포함
- 데이터 추적 및 디버깅
- 시계열 분석
효율성 개선
- Partial Indexes: 조건부 인덱스로 인덱스 크기 최소화
CREATE INDEX idx_posts_published ON posts(published_at) WHERE published_at IS NOT NULL; - Materialized Views: 복잡한 집계 쿼리는 Materialized View로 캐싱
- Partitioning: 대용량 테이블은 날짜/범위 기준 파티셔닝
자주 발생하는 문제 (Common Issues)
문제 1: N+1 쿼리 문제
증상: 한 쿼리로 충분한데 여러 번 DB 호출
원인: JOIN 없이 반복문에서 개별 조회
해결방법:
-- ❌ 나쁜 예: N+1 queries
SELECT * FROM posts; -- 1번
-- 각 post마다
SELECT * FROM users WHERE id = ?; -- N번
-- ✅ 좋은 예: 1 query
SELECT posts.*, users.username, users.avatar_url
FROM posts
JOIN users ON posts.author_id = users.id;
문제 2: 인덱스 없는 Foreign Key로 인한 느린 JOIN
증상: JOIN 쿼리가 매우 느림
원인: Foreign Key 칼럼에 인덱스 누락
해결방법:
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
CREATE INDEX idx_order_items_product_id ON order_items(product_id);
문제 3: UUID vs Auto-increment 성능
증상: UUID Primary Key 사용 시 삽입 성능 저하
원인: UUID는 랜덤하여 인덱스 조각화
해결방법:
- PostgreSQL:
uuid_generate_v7()사용 (시간 순서 UUID) - MySQL:
UUID_TO_BIN(UUID(), 1)사용 - 또는 Auto-increment BIGINT 사용 고려
References
공식 문서
도구
- dbdiagram.io - ERD 다이어그램 작성
- PgModeler - PostgreSQL 모델링 도구
- Prisma - ORM + 마이그레이션
학습 자료
- Database Design Course (freecodecamp)
- Use The Index, Luke - SQL 인덱싱 가이드
Metadata
버전
- 현재 버전: 1.0.0
- 최종 업데이트: 2025-01-01
- 호환 플랫폼: Claude, ChatGPT, Gemini
관련 스킬
- api-design: API와 함께 스키마 설계
- performance-optimization: 쿼리 성능 최적화
태그
#database #schema #PostgreSQL #MySQL #MongoDB #SQL #NoSQL #migration #ERD