smithery/gdm0714

database-design-principles

데이터베이스 설계 원칙 - 확장 가능하고 유지보수 쉬운 스키마 만들기

Installation

$ npx skills add smithery/gdm0714 --skill database-design-principles

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from smithery/gdm0714.

npx skills add smithery/gdm0714

Browse all from smithery/gdm0714

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 14,694 B
  • docs SUMMARY.md 128 B

History

  1. First recorded snapshot · 0 installs

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는 피했는가?
  • 인덱스가 과도하지 않은가?
  • 정규화가 적절한가?

마무리 원칙

"지금 당장 필요한 것만 만들되, 미래를 위한 여지는 남겨두라"

  • 단순함: 복잡한 것보다 단순한 것이 낫다
  • 일관성: 명명 규칙을 지켜라
  • 무결성: 제약 조건으로 데이터를 보호하라
  • 성능: 실제 문제가 발생하면 그때 최적화하라
  • 문서화: 중요한 비즈니스 로직은 주석으로 남겨라