SKILL.md
데이터베이스 설계 원칙
핵심 철학
"데이터는 영원하지만, 코드는 바뀐다. 데이터 구조를 신중하게 설계하라."
1. 정규화부터 시작, 필요 시 비정규화
- 3차 정규화(3NF)까지는 기본
- 성능 문제가 실제로 발생하면 그때 비정규화
2. 미래를 예측하지 말고, 변화에 대비하라
- 확장 가능한 구조
- 하지만 과도한 추상화는 금물
3. 제약 조건은 DB 레벨에서
- 애플리케이션 코드는 바뀌어도 DB는 데이터 무결성 보장
명명 규칙
일관된 네이밍
-- ✅ 좋은 이름
users -- 복수형 테이블명
user_id -- 스네이크 케이스
created_at, updated_at -- 표준 타임스탬프
is_active, has_verified -- boolean은 is/has 접두사
-- ❌ 나쁜 이름
tblUser, UserTable -- 접두사/접미사 불필요
userId, createdAt -- 카멜케이스 (DB에서는 비추천)
active -- boolean 불명확
date -- 너무 일반적
관계 테이블 명명
-- ✅ 좋은 이름
user_roles -- 알파벳 순
post_tags
order_items
-- ❌ 나쁜 이름
roles_users -- 순서 일관성 없음
posttag -- 언더스코어 없음
order_item -- 단수형 (복수형 권장)
기본 설계 패턴
패턴 1: 타임스탬프는 필수
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
-- ✅ 필수 타임스탬프
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
-- 🤔 상황에 따라 추가
deleted_at TIMESTAMP -- Soft delete
);
-- updated_at 자동 갱신 (PostgreSQL)
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
패턴 2: Soft Delete
-- ✅ Soft Delete 패턴
CREATE TABLE posts (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
content TEXT,
user_id BIGINT NOT NULL REFERENCES users(id),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP, -- NULL이면 활성, 값이 있으면 삭제됨
-- 삭제되지 않은 것만 조회
CONSTRAINT check_not_deleted CHECK (deleted_at IS NULL)
);
-- 인덱스 (삭제되지 않은 것만)
CREATE INDEX idx_posts_not_deleted ON posts (user_id)
WHERE deleted_at IS NULL;
-- 조회 (deleted_at이 NULL인 것만)
SELECT * FROM posts WHERE deleted_at IS NULL;
패턴 3: UUID vs Auto Increment
-- 자동 증가 ID (기본 권장)
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY, -- ✅ 간단하고 빠름
email VARCHAR(255) NOT NULL
);
-- UUID (분산 시스템, 보안)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- ✅ 예측 불가능
user_id BIGINT NOT NULL,
token TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL
);
정규화
1차 정규화 (1NF): 원자값
-- ❌ 반정규형 (배열)
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100),
phone_numbers TEXT -- "010-1234-5678, 010-9876-5432" ❌
);
-- ✅ 정규화 (별도 테이블)
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100)
);
CREATE TABLE user_phones (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
phone_number VARCHAR(20) NOT NULL,
type VARCHAR(20) -- 'mobile', 'home', 'work'
);
2차 정규화 (2NF): 부분 종속 제거
-- ❌ 부분 종속
CREATE TABLE order_items (
order_id BIGINT,
product_id BIGINT,
product_name VARCHAR(200), -- product_id에만 종속
product_price DECIMAL(10, 2), -- product_id에만 종속
quantity INT,
PRIMARY KEY (order_id, product_id)
);
-- ✅ 부분 종속 제거
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
price DECIMAL(10, 2) NOT NULL
);
CREATE TABLE order_items (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id),
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INT NOT NULL,
unit_price DECIMAL(10, 2) NOT NULL, -- 주문 시점의 가격 (중요!)
UNIQUE (order_id, product_id)
);
3차 정규화 (3NF): 이행 종속 제거
-- ❌ 이행 종속
CREATE TABLE employees (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100),
department_name VARCHAR(100),
department_location VARCHAR(200) -- department_name을 통해 이행 종속
);
-- ✅ 이행 종속 제거
CREATE TABLE departments (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
location VARCHAR(200)
);
CREATE TABLE employees (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
department_id BIGINT NOT NULL REFERENCES departments(id)
);
관계 설계
1:N 관계
-- 사용자 1명 : 주문 N개
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id), -- FK
total_amount DECIMAL(10, 2) NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- 인덱스 (FK에는 거의 항상 인덱스 필요)
CREATE INDEX idx_orders_user_id ON orders(user_id);
N:M 관계
-- 학생 N명 : 과목 M개
CREATE TABLE students (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
student_number VARCHAR(20) NOT NULL UNIQUE
);
CREATE TABLE courses (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
code VARCHAR(20) NOT NULL UNIQUE
);
-- 중간 테이블
CREATE TABLE enrollments (
id BIGSERIAL PRIMARY KEY,
student_id BIGINT NOT NULL REFERENCES students(id),
course_id BIGINT NOT NULL REFERENCES courses(id),
enrolled_at TIMESTAMP NOT NULL DEFAULT NOW(),
grade VARCHAR(2), -- 추가 정보
UNIQUE (student_id, course_id) -- 중복 방지
);
CREATE INDEX idx_enrollments_student ON enrollments(student_id);
CREATE INDEX idx_enrollments_course ON enrollments(course_id);
인덱스 전략
인덱스가 필요한 곳
-- ✅ 인덱스가 필요한 컬럼
-- 1. Primary Key (자동)
-- 2. Foreign Key
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- 3. 자주 검색하는 컬럼
CREATE INDEX idx_users_email ON users(email);
-- 4. 정렬에 사용하는 컬럼
CREATE INDEX idx_posts_created_at ON posts(created_at DESC);
-- 5. 복합 인덱스 (순서 중요!)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- WHERE user_id = ? AND status = ? ✅ 사용
-- WHERE status = ? ❌ 사용 안 됨 (첫 컬럼이 빠짐)
-- 6. 부분 인덱스
CREATE INDEX idx_orders_pending ON orders(created_at)
WHERE status = 'PENDING'; -- PENDING 주문만 인덱스
❌ 불필요한 인덱스
-- 작은 테이블 (< 1000 rows)
-- 거의 사용하지 않는 컬럼
-- 데이터 분포가 좋지 않은 컬럼 (예: boolean)
CREATE INDEX idx_users_is_active ON users(is_active); -- ❌
-- is_active가 대부분 true라면 인덱스 효과 없음
제약 조건
NOT NULL vs NULL
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL, -- ✅ 필수
description TEXT, -- 🤔 선택 (NULL 허용)
price DECIMAL(10, 2) NOT NULL, -- ✅ 필수
discount_price DECIMAL(10, 2), -- 🤔 선택
-- ✅ CHECK 제약조건
CONSTRAINT check_price_positive CHECK (price > 0),
CONSTRAINT check_discount_valid
CHECK (discount_price IS NULL OR discount_price < price)
);
UNIQUE 제약조건
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE, -- ✅ 단일 UNIQUE
phone VARCHAR(20),
-- ✅ 복합 UNIQUE
UNIQUE (email, phone)
);
-- 부분 UNIQUE (NULL 제외)
CREATE UNIQUE INDEX idx_users_phone ON users(phone)
WHERE phone IS NOT NULL;
Foreign Key 옵션
CREATE TABLE comments (
id BIGSERIAL PRIMARY KEY,
post_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
content TEXT NOT NULL,
-- ✅ CASCADE: 부모 삭제 시 자식도 삭제
FOREIGN KEY (post_id) REFERENCES posts(id)
ON DELETE CASCADE,
-- ✅ SET NULL: 부모 삭제 시 NULL로
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE SET NULL
);
-- ON DELETE 옵션:
-- CASCADE: 부모 삭제 → 자식도 삭제
-- SET NULL: 부모 삭제 → 자식의 FK를 NULL로
-- RESTRICT: 자식이 있으면 부모 삭제 불가 (기본값)
-- NO ACTION: RESTRICT와 비슷
한국 서비스 특화
주소 테이블
CREATE TABLE addresses (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
-- 도로명주소
postal_code VARCHAR(5) NOT NULL, -- 우편번호
address VARCHAR(200) NOT NULL, -- 도로명주소
address_detail VARCHAR(100), -- 상세주소
-- 지번주소 (선택)
jibun_address VARCHAR(200),
-- 좌표
latitude DECIMAL(10, 8),
longitude DECIMAL(11, 8),
-- 배송 정보
recipient_name VARCHAR(100) NOT NULL,
recipient_phone VARCHAR(20) NOT NULL,
is_default BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- 사용자당 기본 배송지는 하나만
CREATE UNIQUE INDEX idx_addresses_default ON addresses(user_id)
WHERE is_default = true;
주문 테이블
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
order_number VARCHAR(20) NOT NULL UNIQUE, -- 주문번호
user_id BIGINT NOT NULL REFERENCES users(id),
-- 금액
product_amount DECIMAL(10, 2) NOT NULL, -- 상품 금액
discount_amount DECIMAL(10, 2) NOT NULL DEFAULT 0, -- 할인
delivery_fee DECIMAL(10, 2) NOT NULL DEFAULT 0, -- 배송비
total_amount DECIMAL(10, 2) NOT NULL, -- 최종 금액
-- 상태
status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- 주문상태
-- 배송 정보
recipient_name VARCHAR(100) NOT NULL,
recipient_phone VARCHAR(20) NOT NULL,
delivery_address TEXT NOT NULL,
delivery_request TEXT, -- 배송 요청사항
-- 결제 정보
payment_method VARCHAR(20), -- 결제 수단
paid_at TIMESTAMP, -- 결제 시각
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
-- 체크 제약
CONSTRAINT check_amounts CHECK (
total_amount = product_amount - discount_amount + delivery_fee
)
);
-- 주문 상품
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),
-- 주문 시점의 정보 저장 (중요!)
product_name VARCHAR(200) NOT NULL,
product_price DECIMAL(10, 2) NOT NULL,
quantity INT NOT NULL CHECK (quantity > 0),
subtotal DECIMAL(10, 2) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (order_id, product_id)
);
성능 최적화
비정규화 (신중하게)
-- ✅ 읽기 성능이 중요한 경우
CREATE TABLE posts (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
content TEXT,
user_id BIGINT NOT NULL REFERENCES users(id),
-- 비정규화: 캐시 컬럼
comment_count INT NOT NULL DEFAULT 0, -- 댓글 수
like_count INT NOT NULL DEFAULT 0, -- 좋아요 수
view_count INT NOT NULL DEFAULT 0, -- 조회 수
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- 트리거로 자동 업데이트
CREATE OR REPLACE FUNCTION update_post_comment_count()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE posts SET comment_count = comment_count + 1
WHERE id = NEW.post_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE posts SET comment_count = comment_count - 1
WHERE id = OLD.post_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_comment_count
AFTER INSERT OR DELETE ON comments
FOR EACH ROW
EXECUTE FUNCTION update_post_comment_count();
파티셔닝
-- 날짜별 파티셔닝 (로그 테이블 등)
CREATE TABLE logs (
id BIGSERIAL,
user_id BIGINT,
action VARCHAR(100),
created_at TIMESTAMP NOT NULL,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
-- 월별 파티션
CREATE TABLE logs_2024_01 PARTITION OF logs
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE logs_2024_02 PARTITION OF logs
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
마이그레이션 전략
안전한 컬럼 추가
-- ✅ 기본값이 있는 컬럼 (안전)
ALTER TABLE users
ADD COLUMN phone VARCHAR(20) DEFAULT '';
-- ⚠️ NOT NULL + 기본값 없음 (위험)
-- 대용량 테이블에서는 오래 걸림
ALTER TABLE users
ADD COLUMN phone VARCHAR(20) NOT NULL; -- ❌
-- ✅ 단계별 추가 (안전)
-- 1. NULL 허용으로 추가
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
-- 2. 데이터 채우기
UPDATE users SET phone = '' WHERE phone IS NULL;
-- 3. NOT NULL 제약조건
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;
체크리스트
테이블 설계 전
- 테이블 이름이 복수형인가?
- createdat, updatedat이 있는가?
- Primary Key가 정의되어 있는가?
- Foreign Key에 인덱스가 있는가?
- NOT NULL이 적절히 사용되었는가?
- 제약 조건이 DB 레벨에 있는가?
배포 전
- 마이그레이션 스크립트가 롤백 가능한가?
- 대용량 테이블의 ALTER는 피했는가?
- 인덱스가 과도하지 않은가?
- 정규화가 적절한가?
마무리 원칙
"지금 당장 필요한 것만 만들되, 미래를 위한 여지는 남겨두라"
- 단순함: 복잡한 것보다 단순한 것이 낫다
- 일관성: 명명 규칙을 지켜라
- 무결성: 제약 조건으로 데이터를 보호하라
- 성능: 실제 문제가 발생하면 그때 최적화하라
- 문서화: 중요한 비즈니스 로직은 주석으로 남겨라