Guider/Database/DatabaseDevGuide0004
Database#04

DatabaseDevGuide0004

데이터베이스 설계 완전 정복

DATABASE DEV GUIDE

🗄️ DatabaseDevGuide0004

데이터베이스 설계 완전 정복 A-Z

좋은 DB를 설계해야 좋은 서비스가 나온다!
현업에서 바로 쓰는 DB 설계의 모든 것을 A-Z로 배워봅시다

📐 ERD 설계 🔑 PK/FK 📊 정규화 🏗️ 물리설계 🛡️ 무결성

📋 학습 목차

챕터 주제 핵심 키워드
01 DB 설계란 무엇인가? 설계 3단계, 개념/논리/물리 모델
02 기본키(PK)와 외래키(FK) PRIMARY KEY, FOREIGN KEY, 무결성
03 ERD 다이어그램 설계 엔티티, 관계, 카디널리티, ERD 도구
04 정규화(Normalization) 1NF, 2NF, 3NF, BCNF
05 반정규화(Denormalization) 성능 vs 중복, 실무 판단 기준
06 데이터 타입 선택 INT, VARCHAR, DATETIME, JSON, ENUM
07 제약조건(Constraints) NOT NULL, UNIQUE, CHECK, DEFAULT
08 관계 유형(1:1, 1:N, M:N) 관계 모델링, 중간 테이블 설계
09 실전 프로젝트: 쇼핑몰 DB 설계 요구사항 분석 → ERD → DDL → 검증
10 나쁜 설계 vs 좋은 설계 안티패턴, 설계 체크리스트
11 현업 면접 Q&A 설계 관련 면접 질문 TOP 10
12 학습 로드맵 & 체크리스트 단계별 학습 경로, 추천 도구

📌 01. 데이터베이스 설계란 무엇인가?

설계를 잘못하면 나중에 수정이 거의 불가능합니다. 처음부터 잘 설계하는 게 핵심!

🏗️ DB 설계의 3단계

단계 이름 설명 산출물
1단계 개념적 설계 어떤 정보를 저장할지 결정. DBMS 독립적 ERD (개념 ERD)
2단계 논리적 설계 관계형 모델로 변환. 정규화 적용 논리 ERD, 테이블 명세서
3단계 물리적 설계 특정 DBMS(MySQL 등)에 맞게 실제 구현 DDL(CREATE TABLE), 인덱스 전략
💡 비유로 이해하기

건물을 짓는 것과 같습니다:
개념 설계 = 어떤 방이 필요한지 결정 (거실, 주방, 방 3개)
논리 설계 = 방의 배치도 그리기 (도면 작성)
물리 설계 = 실제 벽돌 쌓고 공사하기 (시공)

🎯 좋은 DB 설계의 5가지 원칙

① 중복 최소화
같은 데이터를 여러 곳에 저장하지 않기
② 무결성 보장
데이터가 항상 올바른 상태 유지
③ 확장성 고려
나중에 기능 추가가 쉬운 구조
④ 성능 고려
조회/수정이 빠른 구조
⑤ 이해하기 쉽게
다른 개발자도 쉽게 파악 가능한 명명

📌 02. 기본키(PK)와 외래키(FK)

테이블 간의 관계를 연결하는 핵심 개념!

🔑 기본키 (PRIMARY KEY)

기본키는 테이블에서 각 행(Row)을 유일하게 식별하는 컬럼입니다.
학생 테이블이라면 "학번"이 기본키 — 두 학생이 같은 학번을 가질 수 없죠!

  • NOT NULL: 반드시 값이 있어야 함
  • UNIQUE: 중복 값 허용 안 함
  • 변경 불가: 되도록 한번 정하면 바꾸지 않는 게 원칙
  • 최소 컬럼: 가능하면 단일 컬럼으로 (복합키는 복잡도 증가)
-- ✅ 기본키 설정 예시
CREATE TABLE users (
    user_id    INT         NOT NULL AUTO_INCREMENT,  -- 자동 증가 숫자 PK (가장 많이 사용)
    email      VARCHAR(255) NOT NULL,
    username   VARCHAR(100) NOT NULL,
    created_at DATETIME     DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (user_id)   -- 기본키 선언
);

-- ✅ UUID를 기본키로 사용 (분산 시스템에서 많이 사용)
CREATE TABLE orders (
    order_id   CHAR(36)    NOT NULL DEFAULT (UUID()),
    user_id    INT         NOT NULL,
    total_price DECIMAL(10,2) NOT NULL,
    PRIMARY KEY (order_id)
);

-- ✅ 복합 기본키 (두 컬럼의 조합이 유일한 경우)
CREATE TABLE order_items (
    order_id   INT  NOT NULL,
    product_id INT  NOT NULL,
    quantity   INT  NOT NULL DEFAULT 1,
    PRIMARY KEY (order_id, product_id)  -- 복합키
);

🔗 외래키 (FOREIGN KEY)

외래키는 다른 테이블의 기본키를 참조하는 컬럼입니다.
주문 테이블의 "user_id"가 사용자 테이블의 "user_id"를 참조하는 것이 외래키!

참조 무결성: 외래키의 값은 반드시 참조 테이블에 존재하는 값이어야 합니다.
없는 사용자 ID로 주문을 넣으려 하면? → DB가 자동으로 막아줍니다! 🛡️

-- ✅ 외래키 설정 예시
CREATE TABLE orders (
    order_id   INT  NOT NULL AUTO_INCREMENT,
    user_id    INT  NOT NULL,          -- users.user_id를 참조
    order_date DATETIME DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (order_id),
    FOREIGN KEY (user_id)
        REFERENCES users(user_id)
        ON DELETE CASCADE    -- 사용자 삭제 시 주문도 같이 삭제
        ON UPDATE CASCADE    -- 사용자 ID 변경 시 주문의 user_id도 자동 변경
);

⚙️ ON DELETE / ON UPDATE 옵션 비교

옵션 설명 사용 상황
CASCADE 부모 삭제/변경 시 자식도 함께 삭제/변경 주문 → 주문상세 처럼 종속 관계
SET NULL 부모 삭제 시 자식의 FK를 NULL로 변경 게시글의 카테고리가 삭제되어도 글은 유지
RESTRICT 자식이 있으면 부모 삭제/변경 불가 주문이 있는 회원 삭제 방지
NO ACTION RESTRICT와 유사 (트랜잭션 끝에 체크) 기본값 (MySQL에서는 RESTRICT와 동일)
⚠️ 외래키 사용 시 주의사항
  • 외래키가 많으면 INSERT/DELETE 성능이 저하될 수 있습니다
  • 대규모 서비스에서는 외래키 없이 애플리케이션 레벨에서 무결성을 관리하기도 합니다
  • 카카오, 쿠팡 같은 대형 서비스는 외래키 제약을 DB 레벨에서 비활성화하는 경우도 많음
  • 그래도 처음 배울 때는 외래키를 쓰는 것이 올바른 설계 습관 형성에 도움!

📌 03. ERD 다이어그램 설계

ERD (Entity-Relationship Diagram) = 데이터베이스의 설계도!

📐 ERD의 3가지 핵심 구성요소

🟦

엔티티 (Entity)

저장할 대상
예: 사용자, 주문, 상품
네모(□)로 표현

🔷

관계 (Relationship)

엔티티 간의 연결
예: 사용자가 주문을 "한다"
마름모(◇)로 표현

🔵

속성 (Attribute)

엔티티의 특성/정보
예: 이름, 나이, 이메일
타원(○)으로 표현

📊 카디널리티 (Cardinality) - 관계의 수

관계 유형 표기 예시 구현 방법
1:1 (일대일) ||--|| 사용자 : 사용자 프로필 한 쪽에 FK 추가 or 같은 테이블
1:N (일대다) ||--|{ 사용자 : 주문 (한 명이 여러 주문) N 쪽 테이블에 FK 추가
M:N (다대다) }|--|{ 학생 : 수업 (여러 학생, 여러 수업) 중간(연결) 테이블 필요!

🔄 M:N 관계를 해결하는 중간 테이블

⚡ 왜 중간 테이블이 필요한가?

학생이 여러 수업을 들을 수 있고, 수업에는 여러 학생이 있습니다.
이를 직접 연결하면 데이터 구조가 무너집니다. 중간 테이블이 해결책!

-- ❌ M:N 직접 표현 불가능
-- student 테이블에 수업 목록을 어디에 저장? → 불가!

-- ✅ 중간 테이블(연결 테이블)로 해결
CREATE TABLE students (
    student_id INT PRIMARY KEY AUTO_INCREMENT,
    name       VARCHAR(100) NOT NULL
);

CREATE TABLE courses (
    course_id INT PRIMARY KEY AUTO_INCREMENT,
    title     VARCHAR(200) NOT NULL
);

-- 중간 테이블: 학생-수업 등록 관계
CREATE TABLE enrollments (
    enrollment_id INT PRIMARY KEY AUTO_INCREMENT,
    student_id    INT NOT NULL,       -- students.student_id 참조
    course_id     INT NOT NULL,       -- courses.course_id 참조
    enrolled_at   DATETIME DEFAULT CURRENT_TIMESTAMP,
    grade         CHAR(2),            -- 중간 테이블에 추가 속성도 넣을 수 있음!
    FOREIGN KEY (student_id) REFERENCES students(student_id),
    FOREIGN KEY (course_id)  REFERENCES courses(course_id),
    UNIQUE (student_id, course_id)    -- 같은 학생이 같은 수업을 중복 등록 방지
);

🛠️ ERD 설계 도구 비교

도구 특징 가격 추천 상황
dbdiagram.io 코드로 ERD 작성, 공유 쉬움 무료 📌 입문자 강추!
draw.io 다양한 다이어그램 지원 무료 범용 다이어그램 작성
MySQL Workbench DB에서 ERD 역방향 생성 무료 기존 DB 시각화
ERDCloud 한국어 지원, 협업 ERD 도구 무료/유료 팀 협업 ERD 작성
Lucidchart 전문적 다이어그램 도구 유료 기업 문서화

📝 dbdiagram.io 사용 예시 (DBML 코드)

// dbdiagram.io 에서 이렇게 작성하면 ERD가 자동으로 그려집니다!
Table users {
  user_id int [pk, increment]
  email varchar [not null, unique]
  username varchar [not null]
  created_at datetime
}

Table orders {
  order_id int [pk, increment]
  user_id int [ref: > users.user_id]  // FK 표기
  total_price decimal
  status varchar
}

Table order_items {
  item_id int [pk, increment]
  order_id int [ref: > orders.order_id]
  product_id int [ref: > products.product_id]
  quantity int
  price decimal
}

Table products {
  product_id int [pk, increment]
  name varchar
  price decimal
  stock int
}

📌 04. 정규화 (Normalization)

데이터 중복을 없애고, 이상(Anomaly)을 방지하는 테이블 재구성 과정!

❓ 왜 정규화가 필요한가?

정규화를 하지 않으면 3가지 이상(Anomaly) 문제가 발생합니다:
삽입 이상: 원하지 않는 데이터도 함께 넣어야 함
수정 이상: 한 데이터를 수정할 때 여러 곳을 수정해야 함
삭제 이상: 하나를 삭제하면 다른 필요한 데이터도 같이 사라짐

📋 정규화 전: 문제가 있는 테이블

주문번호 고객이름 고객주소 상품명 상품가격 카테고리
1001 김철수 서울시 강남구 노트북 1,200,000 전자제품
1001 김철수 서울시 강남구 마우스 30,000 전자제품
1002 이영희 부산시 해운대구 책상 350,000 가구

⬆️ 빨간 부분: 같은 주문인데 고객정보가 중복! 고객 주소 변경 시 2곳 수정 필요 → 수정 이상!

✅ 제1정규형 (1NF) - 원자값 원칙

규칙: 모든 컬럼의 값은 원자값(더 이상 쪼갤 수 없는 단일 값)이어야 합니다.
: "전화번호" 컬럼에 "010-1234, 010-5678" 처럼 여러 값을 쉼표로 넣으면 안 됩니다!

-- ❌ 1NF 위반: 한 컬럼에 여러 값
CREATE TABLE bad_users (
    user_id INT,
    name    VARCHAR(100),
    phones  VARCHAR(500)  -- "010-1234-5678, 010-9876-5432" 저장 → 위반!
);

-- ✅ 1NF 준수: 별도 테이블로 분리
CREATE TABLE users (
    user_id INT PRIMARY KEY,
    name    VARCHAR(100)
);
CREATE TABLE user_phones (
    phone_id INT PRIMARY KEY AUTO_INCREMENT,
    user_id  INT NOT NULL,
    phone    VARCHAR(20) NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(user_id)
);

✅ 제2정규형 (2NF) - 부분 함수 종속 제거

조건: 1NF를 만족하면서, 기본키가 복합키일 때 기본키의 일부에만 종속되는 컬럼이 없어야 합니다.
쉽게: 복합키가 있을 때, 키의 일부분으로도 결정되는 컬럼은 분리하세요!

-- ❌ 2NF 위반
-- order_items(order_id, product_id) 복합키인데
-- product_name은 product_id에만 종속! → 분리 필요
CREATE TABLE bad_order_items (
    order_id     INT,
    product_id   INT,
    quantity     INT,
    product_name VARCHAR(200),  -- product_id에만 종속 → 2NF 위반!
    product_price DECIMAL(10,2), -- product_id에만 종속 → 2NF 위반!
    PRIMARY KEY (order_id, product_id)
);

-- ✅ 2NF 준수: 상품 정보는 products 테이블로 분리
CREATE TABLE products (
    product_id   INT PRIMARY KEY,
    product_name VARCHAR(200),
    price        DECIMAL(10,2)
);
CREATE TABLE order_items (
    order_id   INT,
    product_id INT,
    quantity   INT,
    PRIMARY KEY (order_id, product_id),
    FOREIGN KEY (product_id) REFERENCES products(product_id)
);

✅ 제3정규형 (3NF) - 이행적 함수 종속 제거

규칙: 2NF를 만족하면서, 기본키가 아닌 컬럼이 다른 기본키 아닌 컬럼에 종속되면 안 됩니다.
쉽게: A→B→C 처럼 "기본키→중간컬럼→다른컬럼" 형태를 제거하세요!

-- ❌ 3NF 위반
-- user_id → department_id → department_name (이행적 종속!)
CREATE TABLE bad_employees (
    user_id         INT PRIMARY KEY,
    name            VARCHAR(100),
    department_id   INT,
    department_name VARCHAR(100)  -- department_id에 종속 → 3NF 위반!
);

-- ✅ 3NF 준수: 부서 정보를 별도 테이블로 분리
CREATE TABLE departments (
    department_id   INT PRIMARY KEY,
    department_name VARCHAR(100)
);
CREATE TABLE employees (
    user_id       INT PRIMARY KEY,
    name          VARCHAR(100),
    department_id INT,
    FOREIGN KEY (department_id) REFERENCES departments(department_id)
);

📊 정규화 단계 요약표

단계 조건 해결하는 문제
1NF 원자값만 허용 (여러 값 금지) 반복 그룹, 다중 값 저장 문제
2NF 1NF + 부분 종속 제거 복합키 일부에만 종속되는 컬럼
3NF 2NF + 이행 종속 제거 비기본키 간의 종속 관계
BCNF 3NF + 결정자가 모두 후보키 복합키가 있는 복잡한 경우 처리
💡 현업에서는?

실무에서는 대부분 3NF까지만 정규화합니다. BCNF 이상은 학문적 개념으로, 실무에서 직접 적용하는 경우는 드뭅니다. 면접에서는 1NF~3NF까지 설명할 수 있으면 충분합니다!

📌 05. 반정규화 (Denormalization)

정규화를 의도적으로 "역행"해서 성능을 높이는 전략!

⚖️ 정규화 vs 반정규화 트레이드오프

✅ 정규화 장점

  • 데이터 중복 없음
  • 수정/삭제 쉬움
  • 무결성 보장

⚠️ 정규화 단점

  • JOIN 쿼리 복잡
  • 조회 성능 저하
  • 쿼리 작성 어려움

🔧 반정규화 기법 3가지

① 컬럼 중복 추가 (Derived Column)

JOIN 없이 바로 조회할 수 있도록 자주 쓰이는 값을 다른 테이블에 복사

-- ✅ 반정규화: orders 테이블에 user_name 컬럼 추가
-- 매번 users JOIN 없이 주문 목록에 고객명 바로 표시 가능
ALTER TABLE orders ADD COLUMN user_name VARCHAR(100); -- 중복이지만 성능 향상

-- 단점: users.name 변경 시 orders.user_name도 함께 업데이트 필요!

② 집계 컬럼 추가 (Summary Column)

매번 COUNT/SUM 집계를 계산하지 않고 미리 저장

-- ✅ 반정규화: 게시글 테이블에 댓글 수 컬럼 미리 저장
ALTER TABLE posts ADD COLUMN comment_count INT DEFAULT 0;

-- 댓글 추가 시 자동 업데이트 (트리거 활용)
CREATE TRIGGER trg_comment_count_inc
AFTER INSERT ON comments
FOR EACH ROW
UPDATE posts SET comment_count = comment_count + 1 WHERE post_id = NEW.post_id;

③ 테이블 병합 (Table Merging)

1:1 관계 테이블을 하나로 합치기

-- users + user_profiles를 합쳐서 JOIN 제거
CREATE TABLE users (
    user_id      INT PRIMARY KEY,
    email        VARCHAR(255),
    -- 아래는 원래 user_profiles 테이블 내용
    bio          TEXT,
    profile_img  VARCHAR(500),
    birthday     DATE
);

🎯 반정규화 언제 해야 하나?

상황 권장
조회 쿼리가 너무 느리고 JOIN이 5개 이상 필요 ✅ 반정규화 고려
읽기(SELECT)가 쓰기(INSERT/UPDATE)보다 압도적으로 많은 서비스 ✅ 반정규화 고려
아직 초기 설계 단계 / 서비스 이용자 적음 ❌ 정규화 유지
데이터 수정이 빈번한 서비스 ❌ 정규화 유지

📌 06. 데이터 타입 선택

올바른 데이터 타입 선택 = 저장 공간 절약 + 성능 향상

📊 MySQL 주요 데이터 타입 완전 정리

🔢 숫자형 타입

타입 크기 범위 사용 상황
TINYINT 1 byte -128 ~ 127 Boolean 대용 (0/1), 상태값
SMALLINT 2 byte -32,768 ~ 32,767 작은 정수 (연도, 코드)
INT 4 byte 약 ±21억 📌 가장 많이 사용! ID, 수량
BIGINT 8 byte 약 ±922경 대규모 서비스 ID, 타임스탬프(ms)
DECIMAL(p,s) 가변 정밀 소수 📌 금액! DECIMAL(10,2) = 소수 2자리
FLOAT/DOUBLE 4/8 byte 부동소수점 금액에는 사용 금지! (오차 발생)

🔤 문자형 타입

타입 최대 크기 특징 사용 상황
CHAR(n) 255 char 고정 길이, 빠름 국가코드(KR), 성별(M/F), 고정 코드
VARCHAR(n) 65,535 byte 가변 길이, 유연 📌 가장 많이 사용! 이름, 이메일, 제목
TEXT 65,535 byte 긴 텍스트, 인덱스 제한 게시글 본문, 설명글
MEDIUMTEXT 16 MB 더 긴 텍스트 블로그 글, 긴 설명
LONGTEXT 4 GB 매우 긴 텍스트 로그 저장, 대용량 텍스트

📅 날짜/시간형 타입

타입 형식 특징 사용 상황
DATE YYYY-MM-DD 날짜만 생일, 기념일, 날짜 데이터
DATETIME YYYY-MM-DD HH:MI:SS 날짜+시간, 고정 📌 가장 많이 사용! created_at, updated_at
TIMESTAMP YYYY-MM-DD HH:MI:SS UTC 변환 자동, 타임존 적용 글로벌 서비스, 로그 기록
YEAR YYYY 연도만 제조년도, 연도 단위 데이터
-- ✅ 데이터 타입 올바른 사용 예시
CREATE TABLE products (
    product_id    INT UNSIGNED NOT NULL AUTO_INCREMENT,  -- UNSIGNED: 양수만 허용 (0~42억)
    name          VARCHAR(200) NOT NULL,
    price         DECIMAL(10,2) NOT NULL,   -- 금액은 반드시 DECIMAL!
    stock         SMALLINT UNSIGNED DEFAULT 0, -- 재고는 음수 없으니 UNSIGNED
    status        TINYINT(1) DEFAULT 1,     -- 0=비활성, 1=활성 (bool 대용)
    description   TEXT,
    thumbnail_url VARCHAR(500),
    category      ENUM('전자제품','의류','식품','가구'),  -- 고정 선택지
    created_at    DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at    DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (product_id)
);

📌 07. 제약조건 (Constraints)

데이터 무결성을 지키는 DB 레벨의 수문장!

🛡️ 6가지 제약조건 완전 정리

제약조건 의미 예시
PRIMARY KEY 유일하고 NULL 불가 user_id INT PRIMARY KEY
FOREIGN KEY 다른 테이블 PK 참조 FOREIGN KEY (user_id) REFERENCES users(user_id)
NOT NULL NULL 값 저장 불가 name VARCHAR(100) NOT NULL
UNIQUE 중복 값 저장 불가 email VARCHAR(255) UNIQUE
CHECK 조건 만족하는 값만 허용 CHECK (age >= 0 AND age <= 150)
DEFAULT 값 미입력 시 기본값 사용 status TINYINT DEFAULT 1
-- ✅ 제약조건 종합 예시
CREATE TABLE employees (
    emp_id     INT          NOT NULL AUTO_INCREMENT,
    emp_code   CHAR(6)      NOT NULL,           -- 사번 형식 고정 6자리
    name       VARCHAR(100) NOT NULL,
    email      VARCHAR(255) NOT NULL,
    age        INT,
    salary     DECIMAL(10,2) NOT NULL,
    dept_id    INT,
    hired_at   DATE          NOT NULL,
    is_active  TINYINT(1)    NOT NULL DEFAULT 1,
    
    PRIMARY KEY (emp_id),
    UNIQUE  KEY uk_emp_code (emp_code),          -- 사번 중복 불가
    UNIQUE  KEY uk_email    (email),             -- 이메일 중복 불가
    CHECK       (age >= 18 AND age <= 70),       -- 나이 범위 제한
    CHECK       (salary > 0),                    -- 급여는 양수
    FOREIGN KEY (dept_id) REFERENCES departments(dept_id) ON DELETE SET NULL
);

💡 제약조건 후에 추가하는 방법 (ALTER TABLE)

-- 이미 만든 테이블에 제약조건 추가
ALTER TABLE users ADD CONSTRAINT uk_phone UNIQUE (phone);
ALTER TABLE orders ADD CONSTRAINT chk_total CHECK (total_price >= 0);
ALTER TABLE products ADD FOREIGN KEY (category_id) REFERENCES categories(category_id);

-- 제약조건 삭제
ALTER TABLE users DROP INDEX uk_phone;
ALTER TABLE orders DROP CHECK chk_total;

📌 08. 관계 유형 심화 (1:1, 1:N, M:N)

각 관계 유형별 실제 테이블 설계 패턴을 익혀봅시다!

🔗 1:1 관계 설계 패턴

언제 1:1로 분리?
① 자주 접근하는 기본 정보 vs 드물게 쓰는 상세 정보 분리 (성능 최적화)
② 보안상 민감한 정보를 별도 테이블에 분리 (접근 제어)
③ 선택적으로 존재하는 정보 (모든 사용자에게 프로필이 없을 수 있음)

-- ✅ 1:1 관계: 사용자 + 사용자 프로필
CREATE TABLE users (
    user_id    INT PRIMARY KEY AUTO_INCREMENT,
    email      VARCHAR(255) UNIQUE NOT NULL,
    password   VARCHAR(255) NOT NULL,   -- 자주 쓰는 기본 정보
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE user_profiles (
    profile_id  INT PRIMARY KEY AUTO_INCREMENT,
    user_id     INT UNIQUE NOT NULL,           -- UNIQUE → 1:1 보장!
    nickname    VARCHAR(100),
    bio         TEXT,
    avatar_url  VARCHAR(500),
    website     VARCHAR(300),
    birthday    DATE,
    FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);

-- 조회: 1:1이라 항상 최대 1개 행만 JOIN됨
SELECT u.email, p.nickname, p.bio
FROM users u
LEFT JOIN user_profiles p ON u.user_id = p.user_id
WHERE u.user_id = 1;

🔗 1:N 관계 설계 패턴

가장 흔한 패턴! 부모 테이블의 PK를 자식 테이블의 FK로 저장합니다.
예: 1명의 사용자가 N개의 주문, 1개의 카테고리에 N개의 상품

-- ✅ 1:N 관계: 사용자(1) : 주문(N)
CREATE TABLE users (
    user_id INT PRIMARY KEY AUTO_INCREMENT,
    name    VARCHAR(100) NOT NULL
);

CREATE TABLE orders (
    order_id    INT PRIMARY KEY AUTO_INCREMENT,
    user_id     INT NOT NULL,          -- 1:N의 N쪽에 FK 저장
    status      ENUM('주문','결제','배송','완료','취소') DEFAULT '주문',
    total_price DECIMAL(10,2) NOT NULL,
    created_at  DATETIME DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE RESTRICT
);

-- 조회: 특정 사용자의 모든 주문
SELECT o.order_id, o.status, o.total_price
FROM orders o
WHERE o.user_id = 1
ORDER BY o.created_at DESC;

🔗 M:N 관계 설계 패턴 (심화)

M:N 관계는 반드시 중간 테이블로 해결!
중간 테이블에는 두 테이블의 FK 외에도 관계에 대한 속성을 추가할 수 있습니다.
예: 학생-수업 관계에서 "성적", "수강신청일" 같은 부가 정보

-- ✅ M:N 관계: 상품(M) : 태그(N)
CREATE TABLE products (
    product_id INT PRIMARY KEY AUTO_INCREMENT,
    name       VARCHAR(200) NOT NULL
);

CREATE TABLE tags (
    tag_id INT PRIMARY KEY AUTO_INCREMENT,
    name   VARCHAR(100) UNIQUE NOT NULL
);

-- 중간 테이블: 상품-태그 연결
CREATE TABLE product_tags (
    product_id INT NOT NULL,
    tag_id     INT NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (product_id, tag_id),             -- 복합 PK로 중복 방지
    FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE CASCADE,
    FOREIGN KEY (tag_id)     REFERENCES tags(tag_id) ON DELETE CASCADE
);

-- 조회: 특정 상품의 태그 목록
SELECT t.name
FROM product_tags pt
JOIN tags t ON pt.tag_id = t.tag_id
WHERE pt.product_id = 1;

-- 조회: 특정 태그가 달린 상품 목록
SELECT p.name
FROM product_tags pt
JOIN products p ON pt.product_id = p.product_id
WHERE pt.tag_id = (SELECT tag_id FROM tags WHERE name = '신상품');

🌳 자기 참조 (Self-Referencing) 관계

같은 테이블 내에서 부모-자식 관계를 표현할 때 사용합니다.
예: 카테고리(대분류→중분류→소분류), 직원(상사→부하), 댓글(댓글→대댓글)

-- ✅ 자기 참조: 카테고리 계층 구조
CREATE TABLE categories (
    category_id INT PRIMARY KEY AUTO_INCREMENT,
    name        VARCHAR(100) NOT NULL,
    parent_id   INT DEFAULT NULL,                -- 자기 자신을 참조
    depth       TINYINT DEFAULT 0,               -- 계층 깊이 (0=대분류, 1=중분류...)
    FOREIGN KEY (parent_id) REFERENCES categories(category_id) ON DELETE SET NULL
);

-- 데이터 예시
INSERT INTO categories VALUES (1, '전자제품', NULL, 0);  -- 대분류
INSERT INTO categories VALUES (2, '컴퓨터',   1,    1);  -- 중분류 (전자제품의 자식)
INSERT INTO categories VALUES (3, '노트북',   2,    2);  -- 소분류 (컴퓨터의 자식)

-- 특정 카테고리의 하위 카테고리 조회
SELECT c.name, parent.name AS parent_name
FROM categories c
LEFT JOIN categories parent ON c.parent_id = parent.category_id
WHERE c.parent_id = 1;

📌 09. 실전 프로젝트 - 쇼핑몰 DB 설계 A-Z

요구사항 분석 → ERD 설계 → DDL 작성 → 검증까지 전 과정 실습!

📋 Step 1. 요구사항 분석

쇼핑몰 서비스의 요구사항:

  • 사용자는 회원가입/로그인을 할 수 있다 → users 테이블
  • 사용자는 배송지를 여러 개 저장할 수 있다 → addresses 테이블
  • 상품은 카테고리별로 분류된다 → categories, products 테이블
  • 사용자는 장바구니에 상품을 담을 수 있다 → cart_items 테이블
  • 주문 시 여러 상품을 한 번에 구매할 수 있다 → orders, order_items 테이블
  • 상품에 리뷰를 달 수 있다 → reviews 테이블
  • 상품은 여러 태그를 가질 수 있다 (검색용) → tags, product_tags 테이블

📐 Step 2. ERD 구조 (텍스트 표현)

users (1) ──────── (N) addresses       [사용자:배송지 = 1:N]
users (1) ──────── (N) orders          [사용자:주문 = 1:N]
users (1) ──────── (N) cart_items      [사용자:장바구니 = 1:N]
users (1) ──────── (N) reviews         [사용자:리뷰 = 1:N]

categories (1) ─── (N) products        [카테고리:상품 = 1:N]

products (1) ────── (N) cart_items     [상품:장바구니 = 1:N]
products (1) ────── (N) order_items    [상품:주문상세 = 1:N]
products (1) ────── (N) reviews        [상품:리뷰 = 1:N]
products (M) ────── (N) tags           [상품:태그 = M:N → product_tags]

orders (1) ─────── (N) order_items     [주문:주문상세 = 1:N]
orders (N) ─────── (1) addresses       [주문:배송지 = N:1]

🏗️ Step 3. DDL - 전체 테이블 생성

-- 쇼핑몰 DB 생성
CREATE DATABASE shopdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE shopdb;

-- 1. 사용자 테이블
CREATE TABLE users (
    user_id    BIGINT       NOT NULL AUTO_INCREMENT,
    email      VARCHAR(255) NOT NULL,
    password   VARCHAR(255) NOT NULL,
    name       VARCHAR(100) NOT NULL,
    phone      VARCHAR(20),
    is_active  TINYINT(1)   NOT NULL DEFAULT 1,
    role       ENUM('customer','admin') NOT NULL DEFAULT 'customer',
    created_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (user_id),
    UNIQUE KEY uk_email (email)
);

-- 2. 배송지 테이블
CREATE TABLE addresses (
    address_id   BIGINT       NOT NULL AUTO_INCREMENT,
    user_id      BIGINT       NOT NULL,
    alias        VARCHAR(50)  DEFAULT '집',  -- '집', '회사' 등
    recipient    VARCHAR(100) NOT NULL,
    phone        VARCHAR(20)  NOT NULL,
    zipcode      CHAR(5)      NOT NULL,
    address1     VARCHAR(300) NOT NULL,
    address2     VARCHAR(200),
    is_default   TINYINT(1)   NOT NULL DEFAULT 0,
    PRIMARY KEY (address_id),
    FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);

-- 3. 카테고리 테이블 (자기 참조)
CREATE TABLE categories (
    category_id BIGINT       NOT NULL AUTO_INCREMENT,
    name        VARCHAR(100) NOT NULL,
    parent_id   BIGINT       DEFAULT NULL,
    sort_order  INT          DEFAULT 0,
    is_active   TINYINT(1)   NOT NULL DEFAULT 1,
    PRIMARY KEY (category_id),
    FOREIGN KEY (parent_id) REFERENCES categories(category_id) ON DELETE SET NULL
);

-- 4. 상품 테이블
CREATE TABLE products (
    product_id   BIGINT         NOT NULL AUTO_INCREMENT,
    category_id  BIGINT         NOT NULL,
    name         VARCHAR(300)   NOT NULL,
    description  TEXT,
    price        DECIMAL(12,2)  NOT NULL,
    sale_price   DECIMAL(12,2),              -- 할인가 (NULL이면 할인 없음)
    stock        INT            NOT NULL DEFAULT 0,
    status       ENUM('판매중','품절','단종') NOT NULL DEFAULT '판매중',
    thumbnail    VARCHAR(500),
    view_count   INT            NOT NULL DEFAULT 0,
    created_at   DATETIME       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at   DATETIME       NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (product_id),
    FOREIGN KEY (category_id) REFERENCES categories(category_id),
    INDEX idx_category (category_id),
    INDEX idx_status (status),
    INDEX idx_price (price)
);

-- 5. 태그 테이블
CREATE TABLE tags (
    tag_id INT NOT NULL AUTO_INCREMENT,
    name   VARCHAR(50) UNIQUE NOT NULL,
    PRIMARY KEY (tag_id)
);

-- 6. 상품-태그 연결 테이블 (M:N)
CREATE TABLE product_tags (
    product_id BIGINT NOT NULL,
    tag_id     INT    NOT NULL,
    PRIMARY KEY (product_id, tag_id),
    FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE CASCADE,
    FOREIGN KEY (tag_id)     REFERENCES tags(tag_id) ON DELETE CASCADE
);

-- 7. 장바구니 테이블
CREATE TABLE cart_items (
    cart_id    BIGINT NOT NULL AUTO_INCREMENT,
    user_id    BIGINT NOT NULL,
    product_id BIGINT NOT NULL,
    quantity   INT    NOT NULL DEFAULT 1,
    added_at   DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (cart_id),
    UNIQUE KEY uk_user_product (user_id, product_id),  -- 같은 상품 중복 방지
    FOREIGN KEY (user_id)    REFERENCES users(user_id) ON DELETE CASCADE,
    FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE CASCADE
);

-- 8. 주문 테이블
CREATE TABLE orders (
    order_id       BIGINT         NOT NULL AUTO_INCREMENT,
    user_id        BIGINT         NOT NULL,
    address_id     BIGINT,
    total_price    DECIMAL(12,2)  NOT NULL,
    discount_price DECIMAL(12,2)  NOT NULL DEFAULT 0,
    final_price    DECIMAL(12,2)  NOT NULL,
    status         ENUM('주문접수','결제완료','배송준비','배송중','배송완료','취소','반품') NOT NULL DEFAULT '주문접수',
    memo           VARCHAR(500),
    ordered_at     DATETIME       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (order_id),
    FOREIGN KEY (user_id)    REFERENCES users(user_id)   ON DELETE RESTRICT,
    FOREIGN KEY (address_id) REFERENCES addresses(address_id) ON DELETE SET NULL,
    INDEX idx_user_id (user_id),
    INDEX idx_status (status),
    INDEX idx_ordered_at (ordered_at)
);

-- 9. 주문 상세 테이블
CREATE TABLE order_items (
    item_id    BIGINT        NOT NULL AUTO_INCREMENT,
    order_id   BIGINT        NOT NULL,
    product_id BIGINT,
    name       VARCHAR(300)  NOT NULL,       -- 주문 당시 상품명 (상품 삭제돼도 유지)
    price      DECIMAL(12,2) NOT NULL,       -- 주문 당시 가격 (나중에 가격 변경돼도 유지)
    quantity   INT           NOT NULL DEFAULT 1,
    PRIMARY KEY (item_id),
    FOREIGN KEY (order_id)   REFERENCES orders(order_id) ON DELETE CASCADE,
    FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE SET NULL
);

-- 10. 리뷰 테이블
CREATE TABLE reviews (
    review_id  BIGINT NOT NULL AUTO_INCREMENT,
    user_id    BIGINT NOT NULL,
    product_id BIGINT NOT NULL,
    order_id   BIGINT,
    rating     TINYINT NOT NULL,
    content    TEXT,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (review_id),
    CHECK (rating BETWEEN 1 AND 5),
    UNIQUE KEY uk_user_product_order (user_id, product_id, order_id),  -- 주문 1번당 리뷰 1개
    FOREIGN KEY (user_id)    REFERENCES users(user_id) ON DELETE CASCADE,
    FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE CASCADE,
    FOREIGN KEY (order_id)   REFERENCES orders(order_id) ON DELETE SET NULL
);

🔍 Step 4. 검증 쿼리

-- ✅ 사용자별 총 주문금액 집계
SELECT u.name, COUNT(o.order_id) AS order_count, SUM(o.final_price) AS total_spent
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id AND o.status != '취소'
GROUP BY u.user_id, u.name
ORDER BY total_spent DESC;

-- ✅ 카테고리별 상품 수 및 평균 가격
SELECT c.name AS category, COUNT(p.product_id) AS product_count,
       ROUND(AVG(p.price),0) AS avg_price
FROM categories c
LEFT JOIN products p ON c.category_id = p.category_id
GROUP BY c.category_id, c.name
ORDER BY product_count DESC;

-- ✅ 인기 상품 TOP 10 (주문 기준)
SELECT p.name, COUNT(oi.item_id) AS order_count, SUM(oi.quantity) AS total_qty
FROM products p
JOIN order_items oi ON p.product_id = oi.product_id
JOIN orders o ON oi.order_id = o.order_id
WHERE o.status = '배송완료'
GROUP BY p.product_id, p.name
ORDER BY total_qty DESC
LIMIT 10;

-- ✅ 평점 4점 이상 상품 목록
SELECT p.name, ROUND(AVG(r.rating),1) AS avg_rating, COUNT(r.review_id) AS review_count
FROM products p
JOIN reviews r ON p.product_id = r.product_id
GROUP BY p.product_id, p.name
HAVING avg_rating >= 4.0
ORDER BY avg_rating DESC, review_count DESC;

🎯 설계 시 중요한 포인트 3가지

  1. order_items에 상품명/가격을 별도 저장: 나중에 상품이 삭제되거나 가격이 바뀌어도 주문 내역은 유지되어야 함
  2. soft delete 고려: is_active 컬럼으로 물리 삭제 대신 논리 삭제 (데이터 복구 가능)
  3. BIGINT 사용: 대규모 서비스에서 INT의 한계(21억)를 넘을 수 있으므로 ID는 BIGINT로

📌 10. 나쁜 설계 vs 좋은 설계 (안티패턴)

현업에서 흔히 보이는 잘못된 설계 패턴과 올바른 해결책!

❌ 안티패턴 1: 만능 테이블 (God Table)

❌ 나쁜 설계

-- 모든 정보를 한 테이블에!
CREATE TABLE all_in_one (
  id INT,
  type VARCHAR(50), -- 'user','product','order'
  field1 VARCHAR(500),
  field2 VARCHAR(500),
  field3 VARCHAR(500),
  data TEXT  -- JSON으로 모든 데이터 때려넣기
);

✅ 좋은 설계

-- 역할에 맞게 테이블 분리
CREATE TABLE users (...);
CREATE TABLE products (...);
CREATE TABLE orders (...);

-- 각 테이블은 단일 책임!
-- 필요 시 JOIN으로 연결

❌ 안티패턴 2: 컬럼이름에 번호 붙이기 (Repeated Columns)

❌ 나쁜 설계

-- 배송지를 컬럼으로 여러 개!
CREATE TABLE users (
  user_id INT,
  addr1   VARCHAR(300),
  addr2   VARCHAR(300),
  addr3   VARCHAR(300),
  phone1  VARCHAR(20),
  phone2  VARCHAR(20)
);

✅ 좋은 설계

-- 별도 테이블로 1:N 관계
CREATE TABLE addresses (
  addr_id INT PRIMARY KEY,
  user_id INT NOT NULL, -- FK
  address VARCHAR(300),
  phone   VARCHAR(20),
  ...
);

❌ 안티패턴 3: NULL 남용

❌ 나쁜 설계

-- 모든 컬럼을 nullable로
CREATE TABLE orders (
  order_id INT,
  status VARCHAR(50),  -- NULL이면 접수전?취소?
  price DECIMAL,       -- NULL이면 무료?미확정?
  note VARCHAR(500)    -- OK: 이건 nullable 적절
);

✅ 좋은 설계

-- 필수값은 NOT NULL + DEFAULT
CREATE TABLE orders (
  order_id INT PRIMARY KEY,
  status VARCHAR(50) NOT NULL DEFAULT '접수',
  price DECIMAL NOT NULL,
  note VARCHAR(500)  -- 선택값만 nullable
);

📋 DB 설계 최종 체크리스트

항목 체크
모든 테이블에 기본키(PK)가 있다
1NF: 모든 컬럼에 원자값만 저장된다
2NF: 복합키가 있을 때 부분 종속 없다
3NF: 이행적 종속이 없다
M:N 관계는 중간 테이블로 해결했다
외래키(FK)로 참조 무결성을 보장한다
컬럼마다 적절한 데이터 타입이 사용됐다
필수 컬럼에 NOT NULL이 적용됐다
테이블명/컬럼명이 명확하고 일관성 있다
자주 조회하는 컬럼에 인덱스가 있다

📌 11. 현업 면접 Q&A TOP 10

DB 설계 관련 실제 면접에서 자주 나오는 질문들!

Q1. 정규화란 무엇이고, 왜 필요한가요?

정규화는 데이터 중복을 제거하고 삽입/수정/삭제 이상(Anomaly)을 방지하기 위해 테이블을 체계적으로 분리하는 과정입니다. 1NF(원자값), 2NF(부분 종속 제거), 3NF(이행 종속 제거) 단계를 거칩니다. 실무에서는 보통 3NF까지 적용합니다.

Q2. PK를 INT로 할지 UUID로 할지 어떻게 결정하나요?

INT AUTO_INCREMENT: 단순하고 빠름. 단일 서버/소규모 서비스에 적합. 단점: ID로 데이터 규모 추측 가능.
UUID: 전역 유일성. 분산 시스템/마이크로서비스에 적합. 단점: 16바이트로 크고, 인덱스 성능 저하 가능. 실무에서는 일반적으로 BIGINT AUTO_INCREMENT를 선호합니다.

Q3. 외래키 제약조건을 DB에 걸지 않는 이유가 있나요?

대규모 서비스에서는 ① 대량 INSERT/DELETE 성능 저하, ② 샤딩 환경에서 FK 제약 불가, ③ 배포 시 의존성 문제 등으로 DB FK를 비활성화하고 애플리케이션 레벨에서 무결성을 관리하기도 합니다. 하지만 입문/중급 단계에서는 FK를 사용하는 것이 올바른 습관입니다.

Q4. CHAR와 VARCHAR의 차이는?

CHAR(n): 고정 길이. 항상 n바이트를 차지. 짧은 값엔 공백으로 채움. 길이가 고정된 데이터(국가코드, 성별)에 적합. 검색 속도가 약간 빠름.
VARCHAR(n): 가변 길이. 실제 저장된 길이만큼만 사용. 이름, 이메일처럼 길이가 다양한 데이터에 적합. 실무에서 훨씬 많이 사용.

Q5. 반정규화는 언제 하나요?

① 복잡한 JOIN으로 인한 조회 성능 문제가 실측으로 확인될 때, ② 읽기(Read) 비율이 쓰기(Write)보다 압도적으로 높은 서비스, ③ 통계나 집계 데이터를 매번 계산하기 부담스러울 때 적용합니다. 먼저 인덱스 최적화를 시도하고, 그래도 안 되면 반정규화를 고려합니다.

Q6. DATETIME과 TIMESTAMP의 차이는?

DATETIME: 타임존 변환 없이 입력한 그대로 저장. 범위: 1000-01-01 ~ 9999-12-31.
TIMESTAMP: UTC로 저장하고 클라이언트 타임존에 맞게 출력. 범위: 1970-01-01 ~ 2038-01-19. 글로벌 서비스엔 TIMESTAMP가 적합하지만 2038년 문제가 있어 최근엔 DATETIME을 더 많이 씁니다.

Q7. ON DELETE CASCADE vs ON DELETE SET NULL을 어떻게 선택하나요?

CASCADE: 부모가 사라지면 자식도 의미 없는 경우. (예: 회원 탈퇴 시 장바구니 삭제)
SET NULL: 부모가 사라져도 자식 데이터를 유지해야 하는 경우. (예: 카테고리 삭제 후 상품은 '미분류'로 유지)
RESTRICT: 자식이 있으면 부모 삭제를 막아야 하는 경우. (예: 주문이 있는 상품은 삭제 불가)

Q8. ERD를 그려보라고 할 때 어떻게 접근하나요?

① 요구사항에서 명사를 뽑아 엔티티 후보 도출 → ② 엔티티의 속성(컬럼) 정의 → ③ 엔티티 간 관계(1:1, 1:N, M:N) 파악 → ④ M:N은 중간 테이블로 해결 → ⑤ PK/FK 설정 → ⑥ 정규화 점검 순서로 접근합니다.

Q9. 금액 컬럼에 FLOAT를 쓰면 안 되는 이유는?

FLOAT/DOUBLE은 부동소수점 방식으로 저장되어 부정확한 근사값이 저장됩니다. 예를 들어 0.1 + 0.2 = 0.30000000000000004 같은 결과가 나올 수 있습니다. 금액처럼 정확한 소수점 계산이 필요할 때는 반드시 DECIMAL(10,2) 타입을 사용해야 합니다.

Q10. 테이블 명명 규칙(네이밍 컨벤션)은 어떻게 하나요?

스네이크 케이스 사용: order_items (카멜케이스 OrderItems는 DB에서 피하기) → ② 테이블명은 복수형: users, products, orders → ③ PK 컬럼명: {테이블명 단수}_{id} 형식 (user_id, product_id) → ④ 날짜 컬럼: created_at, updated_at, deleted_at으로 통일 → ⑤ 불리언: is_active, is_deleted 처럼 is_ 접두사

📌 12. 학습 로드맵 & 체크리스트

📖

1주차

PK, FK, 제약조건
기본 개념 이해

✏️

2주차

ERD 설계 실습
dbdiagram.io 활용

🔬

3주차

정규화 연습
1NF~3NF 직접 적용

🛍️

4주차

미니 프로젝트
쇼핑몰 DB 직접 설계

🎤

5주차

면접 Q&A 정리
설계 설명 연습

🛠️ 추천 학습 도구

도구 용도 비용
dbdiagram.io ERD 설계 (DBML 코드로 자동 생성) 무료
ERDCloud 협업 ERD 설계, 한국어 지원 무료
MySQL Workbench ERD 역공학, DB 관리 무료
DBeaver 다양한 DB 지원 쿼리 도구 무료
draw.io 범용 다이어그램 (ERD 포함) 무료

🎉 DatabaseDevGuide0004 완료!

DB 설계의 기본기를 완전히 익혔습니다!
좋은 설계는 좋은 서비스의 시작입니다. ERD를 직접 그리고 DDL로 구현해보세요!

📌 다음 단계: DatabaseDevGuide0005 - NoSQL 입문
(MongoDB, Redis, 언제 SQL vs NoSQL?)

✅ ERD 설계 ✅ 정규화 1NF~3NF ✅ PK/FK 설계 ✅ 쇼핑몰 DB 구현
반응형