🏗️ DATABASE DEV GUIDE
DatabaseDevGuide0007
실전 프로젝트 완전 정복 A-Z
블로그 · 쇼핑몰 DB 설계 · 구현 · 배포까지
⏱ 예상 학습: 30~40시간 | 🏗️ 난이도: 고급 | 🎯 목표: 현업 즉시 투입
🗺️ DatabaseDevGuide 시리즈 진행 현황
✅ 0001 DB개념 ✅ 0002 SQL기초 ✅ 0003 SQL심화 ✅ 0004 DB설계 ✅ 0005 NoSQL ✅ 0006 실전활용 🔥 0007 실전프로젝트 ← 지금 여기!
📋 학습 목차 — 이 글 하나로 현업 DB 개발자가 됩니다
| 챕터 | 주제 | 핵심 키워드 |
|---|---|---|
| 01 | 실전 프로젝트 개요 & 설계 원칙 | 요구사항 분석, ERD, 설계 체크리스트 |
| 02 | 프로젝트 1: 블로그 시스템 DB 설계 | 사용자, 포스트, 댓글, 태그, 좋아요 |
| 03 | 블로그 DB 구현 (DDL + 샘플 데이터) | CREATE TABLE, 인덱스, 트리거 |
| 04 | 블로그 핵심 쿼리 실전 작성 | 복잡한 JOIN, 페이지네이션, 검색 |
| 05 | 프로젝트 2: 쇼핑몰 DB 설계 | 상품, 카테고리, 주문, 결제, 재고 |
| 06 | 쇼핑몰 DB 구현 (DDL + 트랜잭션) | 주문 트랜잭션, 재고 관리, 결제 처리 |
| 07 | 쇼핑몰 핵심 쿼리 실전 작성 | 매출 통계, 추천 시스템, 재고 알림 |
| 08 | NoSQL 연동 (MongoDB + Redis) | 세션, 캐싱, 검색, 피드 |
| 09 | 성능 최적화 실전 | 실행계획, 슬로우쿼리, 인덱스 튜닝 |
| 10 | 배포 & 운영 자동화 | Docker, 마이그레이션, CI/CD |
| 11 | 포트폴리오 작성 & 코드리뷰 포인트 | README, ERD 문서화, 기술 면접 준비 |
| 12 | 현업 면접 Q&A 파이널 TOP 20 | 실전 면접 완전 정복 |
CHAPTER 01
실전 프로젝트 개요 & 설계 원칙
요구사항 분석부터 ERD 작성까지 — 프로 개발자처럼 설계하는 법
💡 이 챕터에서 배우는 것
실무에서 DB 설계를 시작할 때 "어떻게 시작하지?"라는 막막함을 해결합니다. 요구사항 문서 분석 → 엔티티 도출 → 관계 정의 → ERD 작성 → DDL 생성의 체계적인 프로세스를 배웁니다.
1-1 DB 설계 5단계 프로세스
| 단계 | 작업 내용 | 산출물 | 도구 |
|---|---|---|---|
| 1️⃣ | 요구사항 분석 | 기능 목록, 엔티티 후보 목록 | Notion, Excel |
| 2️⃣ | 개념적 설계 | 개념 ERD (엔티티, 관계) | draw.io, Miro |
| 3️⃣ | 논리적 설계 | 상세 ERD (컬럼, 타입, PK/FK) | dbdiagram.io |
| 4️⃣ | 물리적 설계 | DDL SQL, 인덱스 설계 | MySQL Workbench |
| 5️⃣ | 검증 & 최적화 | 정규화 확인, EXPLAIN 분석 | DBeaver, EXPLAIN |
1-2 DB 설계 체크리스트 (현업 필수)
| 체크 항목 | 왜 중요한가 | 중요도 |
|---|---|---|
| 모든 테이블에 PK가 있는가? | PK 없으면 행 식별 불가, ORM 오동작 | 🔴 필수 |
| created_at / updated_at이 있는가? | 디버깅, 감사 로그, 데이터 추적 필수 | 🔴 필수 |
| FK에 인덱스가 있는가? | FK 자동 인덱스 안 되는 DB 있음. JOIN 성능에 직결 | 🔴 필수 |
| 컬럼 이름이 일관성 있는가? | user_id vs userId 혼재 시 협업 혼란 | 🟡 권장 |
| 금액 컬럼에 DECIMAL 사용하는가? | FLOAT 사용 시 부동소수점 오차 → 금전 손실 | 🔴 필수 |
| 삭제 전략이 결정됐는가? | Hard Delete vs Soft Delete (deleted_at) | 🔴 필수 |
| 검색 컬럼에 인덱스가 있는가? | 검색 빈도 높은 컬럼 인덱스 누락 → 풀스캔 | 🔴 필수 |
| 정규화 수준이 결정됐는가? | 3NF vs 역정규화 — 읽기/쓰기 트레이드오프 고려 | 🟡 권장 |
CHAPTER 02
프로젝트 1: 블로그 시스템 DB 설계
티스토리/Medium 같은 블로그 플랫폼의 DB를 처음부터 직접 설계합니다
2-1 블로그 시스템 요구사항 분석
📋 기능 요구사항 목록
| 기능 그룹 | 세부 기능 | 관련 엔티티 |
|---|---|---|
| 👤 회원 관리 | 회원가입, 로그인, 프로필, OAuth | users, social_accounts |
| ✍️ 게시글 관리 | 작성, 수정, 삭제, 임시저장, 예약발행 | posts, post_revisions |
| 💬 댓글 관리 | 댓글, 대댓글, 수정, 신고 | comments |
| 🏷️ 분류 관리 | 카테고리, 태그 (다대다) | categories, tags, post_tags |
| ❤️ 소셜 기능 | 좋아요, 북마크, 팔로우 | likes, bookmarks, follows |
| 📊 통계 | 조회수, 방문자, 인기 게시글 | post_views, analytics |
2-2 블로그 시스템 ERD 다이어그램
📊 블로그 DB ERD (텍스트 다이어그램)
┌─────────────────────────────────────────────────────────┐
│ users │
│ id(PK) | username | email | password_hash | role │
│ profile_image | bio | created_at | updated_at │
└────────────────────┬────────────────────────────────────┘
│ 1:N (작성자)
┌────────────────────▼────────────────────────────────────┐
│ posts │
│ id(PK) | user_id(FK) | category_id(FK) | title │
│ slug | content | summary | status | view_count │
│ is_featured | published_at | created_at | updated_at │
└──┬──────────────────────────────────────────────────────┘
│
├─── 1:N ──▶ comments (id, post_id, user_id, parent_id, content)
├─── N:M ──▶ tags (through post_tags)
├─── 1:N ──▶ likes (id, post_id, user_id)
└─── 1:N ──▶ bookmarks (id, post_id, user_id)
┌─────────────────────────────────────────────────────────┐
│ users │
│ id(PK) | username | email | password_hash | role │
│ profile_image | bio | created_at | updated_at │
└────────────────────┬────────────────────────────────────┘
│ 1:N (작성자)
┌────────────────────▼────────────────────────────────────┐
│ posts │
│ id(PK) | user_id(FK) | category_id(FK) | title │
│ slug | content | summary | status | view_count │
│ is_featured | published_at | created_at | updated_at │
└──┬──────────────────────────────────────────────────────┘
│
├─── 1:N ──▶ comments (id, post_id, user_id, parent_id, content)
├─── N:M ──▶ tags (through post_tags)
├─── 1:N ──▶ likes (id, post_id, user_id)
└─── 1:N ──▶ bookmarks (id, post_id, user_id)
2-3 블로그 테이블 상세 설계
| 📋 posts 테이블 상세 명세 | ||||
|---|---|---|---|---|
| 컬럼명 | 타입 | 제약 조건 | 기본값 | 설명 |
| id | BIGINT | PK, AUTO_INCREMENT | - | 게시글 고유 ID (BIGINT: 미래 확장 대비) |
| user_id | BIGINT | FK → users.id, NOT NULL | - | 작성자 ID (INDEX 필수!) |
| title | VARCHAR(300) | NOT NULL | - | 게시글 제목 (300자 제한) |
| slug | VARCHAR(350) | UNIQUE, NOT NULL | - | URL 친화적 고유 이름 (SEO) |
| content | LONGTEXT | NOT NULL | - | 본문 (HTML/Markdown, 최대 4GB) |
| status | ENUM | NOT NULL | 'draft' | 'draft','published','private','scheduled' |
| view_count | INT UNSIGNED | NOT NULL | 0 | 조회수 (음수 방지: UNSIGNED) |
| published_at | DATETIME | NULL 허용 | NULL | 발행 시각 (예약 발행 포함) |
| deleted_at | DATETIME | NULL 허용 | NULL | 소프트 삭제 (NULL = 정상) |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | 생성 시각 (자동 기록) |
CHAPTER 03
블로그 DB 구현 — DDL + 샘플 데이터
설계도를 실제 SQL로 변환합니다. 현업에서 쓰는 패턴 그대로!
3-1 블로그 전체 DDL (현업 수준)
-- ============================================================
-- 블로그 시스템 DB DDL (현업 수준)
-- charset: utf8mb4 (이모지 포함 모든 문자 지원)
-- ============================================================
CREATE DATABASE blog_db
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE blog_db;
-- 1. 사용자 테이블
CREATE TABLE users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(50) NOT NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role ENUM('user','admin','editor') NOT NULL DEFAULT 'user',
profile_image VARCHAR(500),
bio TEXT,
is_active TINYINT(1) NOT NULL DEFAULT 1,
last_login_at DATETIME,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_email (email),
UNIQUE KEY uk_username (username),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2. 카테고리 테이블 (계층 구조)
CREATE TABLE categories (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
parent_id INT UNSIGNED,
name VARCHAR(100) NOT NULL,
slug VARCHAR(120) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_slug (slug),
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 3. 게시글 테이블
CREATE TABLE posts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
category_id INT UNSIGNED,
title VARCHAR(300) NOT NULL,
slug VARCHAR(350) NOT NULL,
content LONGTEXT NOT NULL,
summary VARCHAR(500),
thumbnail_url VARCHAR(500),
status ENUM('draft','published','private','scheduled') NOT NULL DEFAULT 'draft',
view_count INT UNSIGNED NOT NULL DEFAULT 0,
like_count INT UNSIGNED NOT NULL DEFAULT 0,
comment_count INT UNSIGNED NOT NULL DEFAULT 0,
is_featured TINYINT(1) NOT NULL DEFAULT 0,
published_at DATETIME,
deleted_at DATETIME,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_slug (slug),
INDEX idx_user_id (user_id),
INDEX idx_category_id (category_id),
INDEX idx_status_published (status, published_at),
INDEX idx_deleted_at (deleted_at),
FULLTEXT INDEX ft_title_content (title, content),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 4. 댓글 테이블 (계층형 대댓글)
CREATE TABLE comments (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
parent_id BIGINT UNSIGNED, -- NULL이면 최상위 댓글
content TEXT NOT NULL,
is_deleted TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_post_id (post_id),
INDEX idx_user_id (user_id),
INDEX idx_parent_id (parent_id),
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 5. 태그 테이블 (다대다)
CREATE TABLE tags (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
slug VARCHAR(60) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE post_tags (
post_id BIGINT UNSIGNED NOT NULL,
tag_id INT UNSIGNED NOT NULL,
PRIMARY KEY (post_id, tag_id),
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- 6. 좋아요 테이블
CREATE TABLE likes (
post_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (post_id, user_id),
INDEX idx_user_id (user_id),
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
⚠️ 현업에서 자주 저지르는 DDL 실수 TOP 5
| 실수 | 결과 | 올바른 방법 |
|---|---|---|
| INT PK (소규모) | 2억 건 넘으면 오버플로우 | BIGINT UNSIGNED 사용 |
| charset 미지정 | 이모지, 한글 깨짐 | utf8mb4 명시 |
| FK 인덱스 누락 | JOIN 풀스캔 발생 | FK 컬럼에 INDEX 추가 |
| ON DELETE 미지정 | 부모 삭제 시 오류 or 고아 데이터 | CASCADE/SET NULL 명시 |
| FLOAT으로 금액 저장 | 10000.0000001 같은 오차 발생 | DECIMAL(10,2) 사용 |
CHAPTER 04
블로그 핵심 쿼리 실전 작성
실무에서 매일 쓰는 복잡한 쿼리들을 현업 수준으로 작성합니다
4-1 게시글 목록 조회 (페이지네이션 + 정렬)
-- 메인 페이지 게시글 목록 (저자 정보, 카테고리, 태그 수, 커서 기반 페이지네이션)
SELECT
p.id,
p.title,
p.slug,
p.summary,
p.thumbnail_url,
p.view_count,
p.like_count,
p.comment_count,
p.published_at,
u.username AS author_name,
u.profile_image AS author_image,
c.name AS category_name,
c.slug AS category_slug,
-- 태그 목록을 하나의 컬럼으로 집계
GROUP_CONCAT(t.name ORDER BY t.name SEPARATOR ',') AS tag_names
FROM posts p
INNER JOIN users u ON p.user_id = u.id
LEFT JOIN categories c ON p.category_id = c.id
LEFT JOIN post_tags pt ON p.id = pt.post_id
LEFT JOIN tags t ON pt.tag_id = t.id
WHERE
p.status = 'published'
AND p.deleted_at IS NULL
AND p.published_at <= NOW()
AND p.id < :last_id -- 커서 기반 페이지네이션 (OFFSET보다 빠름!)
GROUP BY p.id
ORDER BY p.id DESC
LIMIT 10;
-- ✅ 왜 커서 기반 페이지네이션인가?
-- OFFSET 방식: SELECT ... LIMIT 20 OFFSET 10000 → 10020개 행 스캔 후 뒤 10개 반환 (느림!)
-- 커서 방식: SELECT ... WHERE id < :last_id LIMIT 10 → 인덱스로 정확히 10개만 읽음 (빠름!)
4-2 전문 검색 쿼리 (FULLTEXT)
-- FULLTEXT 검색 (제목 + 본문 통합 검색)
SELECT
p.id,
p.title,
p.slug,
p.summary,
-- 검색어와의 관련성 점수
MATCH(p.title, p.content) AGAINST(:keyword IN BOOLEAN MODE) AS relevance_score
FROM posts p
WHERE
p.status = 'published'
AND p.deleted_at IS NULL
AND MATCH(p.title, p.content) AGAINST(:keyword IN BOOLEAN MODE)
ORDER BY relevance_score DESC
LIMIT 20;
-- BOOLEAN MODE 검색 예시
-- 'MySQL +인덱스' → MySQL 포함, 인덱스 필수
-- 'MySQL -Django' → MySQL 포함, Django 제외
-- '"트랜잭션 처리"' → 정확한 구문 검색
4-3 댓글 계층 조회 쿼리
-- 댓글 + 대댓글 계층 조회 (2단계)
SELECT
c.id,
c.content,
c.parent_id,
c.created_at,
c.is_deleted,
u.id AS user_id,
u.username,
u.profile_image,
-- 대댓글 수
(SELECT COUNT(*) FROM comments r WHERE r.parent_id = c.id AND r.is_deleted = 0) AS reply_count
FROM comments c
INNER JOIN users u ON c.user_id = u.id
WHERE
c.post_id = :post_id
AND c.parent_id IS NULL -- 최상위 댓글만
ORDER BY c.created_at ASC;
-- 특정 댓글의 대댓글 로드
SELECT c.*, u.username, u.profile_image
FROM comments c
INNER JOIN users u ON c.user_id = u.id
WHERE c.parent_id = :parent_id AND c.is_deleted = 0
ORDER BY c.created_at ASC;
4-4 트리거로 좋아요 카운트 자동 관리
-- 좋아요 추가 시 posts.like_count 자동 증가
DELIMITER //
CREATE TRIGGER trg_likes_after_insert
AFTER INSERT ON likes
FOR EACH ROW
BEGIN
UPDATE posts SET like_count = like_count + 1
WHERE id = NEW.post_id;
END//
CREATE TRIGGER trg_likes_after_delete
AFTER DELETE ON likes
FOR EACH ROW
BEGIN
UPDATE posts SET like_count = like_count - 1
WHERE id = OLD.post_id;
END//
DELIMITER ;
-- 💡 트리거 대신 application 레벨에서 관리하는 방법도 있음
-- 트리거: DB 자체에서 보장, 하지만 디버깅 어려움
-- 애플리케이션: 명시적이지만 누락될 수 있음
-- 현업에서는 Redis로 카운트 관리 후 주기적으로 DB 동기화하는 방식도 많이 씀
CHAPTER 05
프로젝트 2: 쇼핑몰 DB 설계
쿠팡/11번가 같은 이커머스 플랫폼의 DB를 A-Z까지 직접 설계합니다
5-1 쇼핑몰 엔티티 관계 분석
| 엔티티 | 설명 | 주요 컬럼 | 관계 |
|---|---|---|---|
| users | 회원 | id, email, name, grade, point | 1:N → orders, addresses |
| products | 상품 | id, name, price, stock, category_id | N:M → orders (through order_items) |
| orders | 주문 | id, user_id, status, total_amount | 1:N → order_items, payments |
| order_items | 주문 상세 | order_id, product_id, qty, price | N:M 중간 테이블 |
| payments | 결제 | id, order_id, method, status, amount | 1:1 → orders |
| reviews | 리뷰 | id, product_id, user_id, rating, content | N:1 → products, users |
| coupons | 쿠폰 | id, code, discount_type, value | N:M → users (through user_coupons) |
CHAPTER 06
쇼핑몰 DB 구현 — DDL + 트랜잭션
주문 처리의 핵심: 재고 감소 + 주문 생성 + 결제 기록을 원자적으로 처리
6-1 쇼핑몰 핵심 DDL
CREATE DATABASE shop_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE shop_db;
-- 상품 카테고리 (계층 구조)
CREATE TABLE categories (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
parent_id INT UNSIGNED,
name VARCHAR(100) NOT NULL,
depth TINYINT NOT NULL DEFAULT 0, -- 0:대분류, 1:중분류, 2:소분류
sort_order INT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
INDEX idx_parent (parent_id),
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
) ENGINE=InnoDB;
-- 상품 테이블
CREATE TABLE products (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
category_id INT UNSIGNED,
name VARCHAR(300) NOT NULL,
description LONGTEXT,
price DECIMAL(12, 2) NOT NULL, -- FLOAT 절대 쓰지 말 것!
discount_rate DECIMAL(5, 2) NOT NULL DEFAULT 0.00,
stock_qty INT UNSIGNED NOT NULL DEFAULT 0,
status ENUM('active','inactive','soldout') NOT NULL DEFAULT 'active',
avg_rating DECIMAL(3, 2) NOT NULL DEFAULT 0.00,
review_count INT UNSIGNED NOT NULL DEFAULT 0,
sales_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
thumbnail_url VARCHAR(500),
deleted_at DATETIME,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_category (category_id),
INDEX idx_status_price (status, price),
FULLTEXT INDEX ft_name (name)
) ENGINE=InnoDB;
-- 주문 테이블
CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_number VARCHAR(30) NOT NULL, -- ORD-20241015-000001
user_id BIGINT UNSIGNED NOT NULL,
status ENUM('pending','paid','shipping','delivered','cancelled','refunded') NOT NULL DEFAULT 'pending',
total_amount DECIMAL(14, 2) NOT NULL,
discount_amount DECIMAL(14, 2) NOT NULL DEFAULT 0.00,
final_amount DECIMAL(14, 2) NOT NULL,
shipping_address TEXT NOT NULL,
shipping_name VARCHAR(100) NOT NULL,
shipping_phone VARCHAR(20) NOT NULL,
memo VARCHAR(500),
paid_at DATETIME,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_order_number (order_number),
INDEX idx_user_id (user_id),
INDEX idx_status_created (status, created_at)
) ENGINE=InnoDB;
-- 주문 상세 (스냅샷 패턴 - 중요!)
CREATE TABLE order_items (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
product_name VARCHAR(300) NOT NULL, -- 스냅샷! 상품명 변경돼도 주문 내역 보존
unit_price DECIMAL(12, 2) NOT NULL, -- 스냅샷! 가격 변경돼도 보존
quantity INT UNSIGNED NOT NULL,
subtotal DECIMAL(14, 2) NOT NULL,
PRIMARY KEY (id),
INDEX idx_order_id (order_id),
INDEX idx_product_id (product_id),
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
) ENGINE=InnoDB;
6-2 주문 처리 트랜잭션 (핵심!)
🔴 이 트랜잭션이 없으면? 재고가 마이너스가 되거나, 결제는 됐는데 주문이 없는 상황 발생!
-- 주문 처리 완전 트랜잭션 (Node.js + mysql2 패턴)
-- 1) 재고 확인 & 감소 (FOR UPDATE로 락 획득)
-- 2) 주문 생성
-- 3) 주문 상세 생성
-- 4) 결제 기록 생성
-- 5) 장바구니 비우기
START TRANSACTION;
-- Step 1: 재고 확인 및 비관적 락 획득
SELECT id, stock_qty, price, name
FROM products
WHERE id = :product_id
FOR UPDATE; -- 다른 트랜잭션의 접근 차단!
-- (앱 코드에서) stock_qty < 주문수량이면 ROLLBACK
-- Step 2: 재고 감소
UPDATE products
SET stock_qty = stock_qty - :quantity,
status = CASE WHEN stock_qty - :quantity = 0 THEN 'soldout' ELSE status END
WHERE id = :product_id AND stock_qty >= :quantity;
-- ROW_COUNT() = 0이면 재고 부족 → ROLLBACK
-- Step 3: 주문 생성
INSERT INTO orders (order_number, user_id, status, total_amount, final_amount, shipping_address, shipping_name, shipping_phone)
VALUES (:order_number, :user_id, 'pending', :total, :final, :address, :name, :phone);
-- Step 4: 주문 상세 (스냅샷 패턴)
INSERT INTO order_items (order_id, product_id, product_name, unit_price, quantity, subtotal)
VALUES (LAST_INSERT_ID(), :product_id, :product_name, :price, :qty, :price * :qty);
-- Step 5: 결제 기록
INSERT INTO payments (order_id, method, status, amount, pg_transaction_id)
VALUES (:order_id, :method, 'completed', :amount, :pg_tid);
COMMIT; -- 모두 성공해야 확정!
-- 실패 시: ROLLBACK; (모든 변경사항 원복)
CHAPTER 07
쇼핑몰 핵심 쿼리 실전 작성
매출 통계, 상품 추천, 재고 알림까지 — 실무에서 쓰는 쿼리 모음
7-1 매출 통계 쿼리 (현업 필수)
-- 일별 매출 통계 (최근 30일)
SELECT
DATE(o.created_at) AS sale_date,
COUNT(DISTINCT o.id) AS order_count,
COUNT(DISTINCT o.user_id) AS unique_buyers,
SUM(o.final_amount) AS total_revenue,
AVG(o.final_amount) AS avg_order_value,
SUM(oi.quantity) AS total_items_sold
FROM orders o
INNER JOIN order_items oi ON o.id = oi.order_id
WHERE
o.status IN ('paid', 'shipping', 'delivered')
AND o.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY DATE(o.created_at)
ORDER BY sale_date DESC;
-- 카테고리별 매출 순위 (WITH ROLLUP)
SELECT
IFNULL(c.name, '전체 합계') AS category,
COUNT(DISTINCT o.id) AS order_count,
SUM(oi.subtotal) AS revenue,
RANK() OVER (ORDER BY SUM(oi.subtotal) DESC) AS rank_no
FROM order_items oi
INNER JOIN products p ON oi.product_id = p.id
INNER JOIN categories c ON p.category_id = c.id
INNER JOIN orders o ON oi.order_id = o.id
WHERE o.status != 'cancelled'
GROUP BY c.id WITH ROLLUP
ORDER BY revenue DESC;
7-2 재고 부족 알림 & 리뷰 집계 쿼리
-- 재고 10개 미만 상품 목록 (긴급 발주 필요)
SELECT
p.id,
p.name,
p.stock_qty,
p.sales_count,
c.name AS category,
-- 최근 7일 평균 판매량
ROUND(
(SELECT SUM(oi2.quantity)
FROM order_items oi2
INNER JOIN orders o2 ON oi2.order_id = o2.id
WHERE oi2.product_id = p.id
AND o2.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
) / 7, 1
) AS avg_daily_sales
FROM products p
LEFT JOIN categories c ON p.category_id = c.id
WHERE p.stock_qty < 10 AND p.status = 'active' AND p.deleted_at IS NULL
ORDER BY p.stock_qty ASC;
-- 상품 리뷰 평점 분포 (별점 분석)
SELECT
p.id,
p.name,
COUNT(r.id) AS total_reviews,
AVG(r.rating) AS avg_rating,
SUM(CASE WHEN r.rating = 5 THEN 1 ELSE 0 END) AS five_star,
SUM(CASE WHEN r.rating = 4 THEN 1 ELSE 0 END) AS four_star,
SUM(CASE WHEN r.rating <= 2 THEN 1 ELSE 0 END) AS low_rating
FROM products p
LEFT JOIN reviews r ON p.id = r.product_id
GROUP BY p.id
HAVING total_reviews > 0
ORDER BY avg_rating DESC;
CHAPTER 08
NoSQL 연동 전략 (MongoDB + Redis)
MySQL만으로는 한계가 있다! NoSQL을 올바르게 조합하는 법
8-1 MySQL vs MongoDB vs Redis 역할 분담
| 데이터 종류 | MySQL | MongoDB | Redis |
|---|---|---|---|
| 회원 정보, 주문 내역 | ✅ 주저장소 | ❌ | ❌ |
| 세션, 로그인 토큰 | ❌ | ❌ | ✅ TTL 지원 |
| 게시글 조회수 카운터 | ❌ 쓰기 부하 | ❌ | ✅ INCR 원자적 |
| 상품 상세 페이지 캐시 | ❌ 반복 읽기 부하 | ❌ | ✅ 캐시 레이어 |
| 유저 활동 로그 (비정형) | ❌ 스키마 변경 비용 | ✅ 유연한 스키마 | ❌ |
| 실시간 인기 상품 랭킹 | ❌ 집계 쿼리 부하 | ❌ | ✅ Sorted Set |
| 대용량 상품 리뷰 검색 | △ FULLTEXT | ✅ Atlas Search | ❌ |
8-2 Redis 캐싱 패턴 실전
// Node.js Redis 캐싱 패턴 (Cache-Aside / Lazy Loading)
async function getProduct(productId) {
const cacheKey = `product:${productId}`;
// 1. Redis 캐시 확인
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached); // 캐시 히트!
}
// 2. DB 조회 (캐시 미스)
const product = await db.query(
'SELECT * FROM products WHERE id = ?', [productId]
);
// 3. Redis에 저장 (TTL: 5분)
await redis.setex(cacheKey, 300, JSON.stringify(product));
return product;
}
// 실시간 인기 상품 랭킹 (Sorted Set)
// 상품 조회 시 점수 증가
await redis.zincrby('popular:products', 1, productId);
// 상위 10개 인기 상품 조회
const topProducts = await redis.zrevrange('popular:products', 0, 9, 'WITHSCORES');
// 조회수 원자적 증가 (Race Condition 없음!)
await redis.incr(`post:views:${postId}`);
// 주기적으로 DB에 동기화 (배치 작업)
CHAPTER 09
성능 최적화 실전
슬로우 쿼리 발견부터 인덱스 튜닝까지 — 실무 트러블슈팅 완전 가이드
9-1 슬로우 쿼리 발견 및 분석
-- 슬로우 쿼리 로그 활성화 (MySQL)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- 1초 이상 걸리는 쿼리 기록
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
SET GLOBAL log_queries_not_using_indexes = 'ON';
-- 현재 실행 중인 쿼리 목록
SHOW FULL PROCESSLIST;
-- 슬로우 쿼리 실행계획 분석
EXPLAIN FORMAT=JSON
SELECT * FROM orders o
INNER JOIN order_items oi ON o.id = oi.order_id
WHERE o.user_id = 12345 AND o.status = 'paid';
-- EXPLAIN 결과에서 확인할 핵심 컬럼:
-- type: ALL(풀스캔🔴) | ref(인덱스🟡) | const(최적🟢)
-- rows: 스캔 행 수 (작을수록 좋음)
-- Extra: Using filesort(정렬 느림🔴), Using index(커버링 인덱스🟢)
9-2 인덱스 튜닝 가이드
| 상황 | 인덱스 전략 | 예시 |
|---|---|---|
| WHERE 단일 컬럼 자주 조회 | 단일 인덱스 | INDEX(user_id) |
| WHERE A AND B 복합 조건 | 복합 인덱스 (선택도 높은 것 먼저) | INDEX(status, created_at) |
| SELECT 컬럼 = WHERE 컬럼 | 커버링 인덱스 (테이블 접근 불필요) | INDEX(user_id, status, created_at) |
| UNIQUE 컬럼 조회 | UNIQUE INDEX (자동으로 const) | UNIQUE(email) |
| 텍스트 검색 (LIKE '%키워드%') | FULLTEXT INDEX | FULLTEXT(title, content) |
| 대용량 정렬 (ORDER BY) | ORDER BY 컬럼 인덱스 | INDEX(created_at DESC) |
⚠️ 인덱스 사용을 방해하는 패턴 (무조건 피해야 함!)
| ❌ 잘못된 쿼리 | ✅ 올바른 쿼리 | 이유 |
|---|---|---|
| WHERE YEAR(created_at) = 2024 | WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31' | 함수 적용 시 인덱스 무력화 |
| WHERE LIKE '%검색어%' | MATCH(col) AGAINST('검색어') | 앞 와일드카드는 인덱스 무력화 |
| WHERE id != 5 | WHERE id IN (1,2,3,4,6,...) | 부정형 조건은 인덱스 비효율 |
| WHERE status + 0 = 1 | WHERE status = 1 | 컬럼에 연산 적용 시 인덱스 무력화 |
CHAPTER 10
배포 & 운영 자동화
Docker로 DB 환경 구성, 마이그레이션 자동화, CI/CD 파이프라인 구축
10-1 Docker Compose로 개발 환경 구성
# docker-compose.yml (블로그/쇼핑몰 개발 환경)
version: '3.8'
services:
mysql:
image: mysql:8.0
container_name: project_mysql
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: blog_db
MYSQL_USER: developer
MYSQL_PASSWORD: devpassword
volumes:
- mysql_data:/var/lib/mysql
- ./init-scripts:/docker-entrypoint-initdb.d # 초기 DDL 자동 실행
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 30s
timeout: 10s
retries: 5
redis:
image: redis:7-alpine
container_name: project_redis
ports:
- "6379:6379"
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
phpmyadmin: # DB 관리 GUI (개발용)
image: phpmyadmin/phpmyadmin
ports:
- "8080:80"
environment:
PMA_HOST: mysql
PMA_USER: developer
PMA_PASSWORD: devpassword
volumes:
mysql_data:
redis_data:
10-2 DB 마이그레이션 자동화 (Sequelize/Prisma)
// Prisma 마이그레이션 워크플로우
// 1. schema.prisma 수정 (새 컬럼 추가)
model Post {
id Int @id @default(autoincrement())
title String
// 새로 추가!
readingTime Int? // 예상 읽기 시간 (분)
updatedAt DateTime @updatedAt
}
// 2. 마이그레이션 생성
$ npx prisma migrate dev --name add_reading_time_to_posts
// 3. 프로덕션 배포 시
$ npx prisma migrate deploy # 미실행 마이그레이션만 자동 적용
// Sequelize 마이그레이션
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('posts', 'reading_time', {
type: Sequelize.INTEGER,
allowNull: true
});
},
async down(queryInterface) {
await queryInterface.removeColumn('posts', 'reading_time');
}
};
$ npx sequelize-cli db:migrate # 마이그레이션 실행
$ npx sequelize-cli db:migrate:undo # 롤백
CHAPTER 11
포트폴리오 작성 & 코드리뷰 포인트
프로젝트를 취업/이직에 활용하는 방법 — DB 개발자로 어필하는 전략
11-1 DB 프로젝트 README 필수 항목
| 섹션 | 포함할 내용 | 면접관 어필 포인트 |
|---|---|---|
| 🛠️ 기술 스택 | MySQL 8.0, Redis 7, Docker, Prisma | 최신 버전 사용 = 최신 트렌드 파악 |
| 📊 ERD 다이어그램 | dbdiagram.io 링크 + 이미지 | 설계 능력 직관적 시각화 |
| ⚡ 성능 최적화 결과 | "인덱스 추가 후 쿼리 시간 850ms → 12ms" | 숫자로 보여주는 성과가 최고! |
| 🔐 보안 고려사항 | SQL Injection 방지, 권한 분리, 암호화 | 보안 의식 있는 개발자 어필 |
| 🚀 설치 및 실행 방법 | docker-compose up만으로 실행 가능 | 재현 가능성 = 협업 역량 증명 |
| 📝 기술적 의사결정 | "NoSQL 대신 MySQL을 선택한 이유" | 트레이드오프 분석 능력 어필 |
11-2 코드리뷰에서 자주 지적받는 DB 코드 패턴
| ❌ 나쁜 코드 | ✅ 좋은 코드 | 이유 |
|---|---|---|
| SELECT * FROM users | SELECT id, email, name FROM users | 불필요한 데이터 전송 차단 |
| N+1 문제: users.forEach(u => db.findPosts(u.id)) | JOIN으로 한 번에 조회 또는 DataLoader 사용 | 쿼리 N+1 → 1+1로 감소 |
| 직접 문자열 쿼리: "WHERE id="+userId | 준비문: "WHERE id=?" + [userId] | SQL Injection 방지 |
| 트랜잭션 없이 여러 INSERT | BEGIN + 모든 INSERT + COMMIT | 원자성 보장 |
| 연결을 닫지 않는 코드 | Connection Pool + 자동 release | Connection Leak 방지 |
CHAPTER 12
현업 면접 Q&A 파이널 TOP 20
실제 면접에서 나온 질문들 + 면접관을 감동시키는 답변 전략
Q1. 🔴 필수 질문
DB 설계 시 가장 중요하게 생각하는 것은 무엇인가요?
💡 답변 전략: "세 가지를 고려합니다. 첫째, 데이터 무결성 — 모든 테이블에 적절한 PK, FK, 제약 조건을 설정하고 NOT NULL과 DEFAULT를 적극 활용합니다. 둘째, 성능 — 자주 조회되는 컬럼에 인덱스를 설계하고, EXPLAIN으로 실행 계획을 검증합니다. 셋째, 확장성 — ID 컬럼에 BIGINT UNSIGNED를 사용하고, 소프트 삭제(deleted_at)로 데이터 보존성을 확보합니다."
Q2. 🔴 필수 질문
트랜잭션이 필요한 상황과 그 이유를 설명해주세요.
💡 답변 전략: "여러 테이블에 걸친 데이터 변경이 '모두 성공' 또는 '모두 실패'해야 할 때 트랜잭션이 필요합니다. 예를 들어 쇼핑몰 주문 처리에서 재고 감소, 주문 생성, 결제 기록 세 작업 중 하나라도 실패하면 전체 롤백이 필요합니다. 트랜잭션 없이 구현하면 재고는 줄었는데 주문이 없거나, 결제는 됐는데 주문 기록이 없는 데이터 불일치 상황이 발생합니다."
Q3. 🔵 심화 질문
N+1 문제가 무엇이고 어떻게 해결하나요?
💡 답변 전략: "N+1 문제는 1번의 메인 쿼리 후 결과 N개에 대해 추가 쿼리를 N번 실행하는 패턴입니다. 예를 들어 게시글 10개를 조회 후 각 게시글의 작성자를 별도로 조회하면 총 11번의 쿼리가 실행됩니다. 해결 방법은 세 가지입니다: ①JOIN을 사용해 한 번에 가져오기, ②ORM의 eager loading(Sequelize: include, Prisma: include), ③DataLoader 패턴으로 쿼리 배치 처리. 실무에서는 EXPLAIN으로 쿼리 수를 모니터링하고 Prisma의 쿼리 로그를 활성화해 확인합니다."
Q4. 🔵 심화 질문
소프트 삭제(Soft Delete)를 왜 사용하나요?
💡 답변 전략: "Hard Delete는 데이터를 완전히 제거하지만, 비즈니스 요구사항상 '삭제된 데이터'도 보존해야 하는 경우가 많습니다. 예를 들어 주문 테이블에서 사용자가 탈퇴해도 주문 기록은 5년간 보관해야 합니다(전자상거래법). Soft Delete는 deleted_at DATETIME 컬럼을 추가해 삭제 시각을 기록하고 WHERE deleted_at IS NULL로 조회합니다. 데이터 복구가 쉽고 감사 로그 용도로도 활용됩니다. 단, 인덱스 설계 시 deleted_at을 포함한 부분 인덱스를 고려해야 합니다."
Q5. 🟢 실전 질문
대용량 테이블 컬럼 추가 시 서비스 다운 없이 하는 방법은?
💡 답변 전략: "MySQL 5.6+ 이상에서는 Online DDL을 지원하지만, 대용량 테이블에서는 여전히 위험합니다. 실무에서는 세 가지 방법을 사용합니다: ①pt-online-schema-change (Percona Toolkit) — 새 테이블을 만들고 트리거로 동기화 후 교체, ②gh-ost (GitHub) — MySQL 바이너리 로그 기반 무중단 스키마 변경, ③Blue-Green 배포 — 새 스키마의 DB를 별도 구성 후 트래픽 전환. 실제로 5천만 행 테이블 컬럼 추가 시 pt-osc를 사용해 서비스 다운 없이 처리한 경험이 있습니다."
| # | 면접 질문 | 핵심 키워드 |
|---|---|---|
| Q6 | 커버링 인덱스란 무엇인가요? | SELECT 컬럼이 모두 인덱스에 포함 → 테이블 접근 불필요, Extra: Using index |
| Q7 | 데드락이 발생하는 상황과 해결 방법은? | 두 트랜잭션이 서로의 락을 기다림, 락 순서 통일, 타임아웃 설정 |
| Q8 | OFFSET 페이지네이션의 문제점과 대안은? | 대용량에서 OFFSET 큰 값은 전체 스캔, 커서 기반(WHERE id < :last_id) 사용 |
| Q9 | 정규화 vs 역정규화를 언제 선택하나요? | 쓰기 많으면 정규화, 읽기 많으면 역정규화 (중복 허용해 JOIN 제거) |
| Q10 | MySQL vs PostgreSQL 선택 기준은? | MySQL: 웹 서비스 입문, PostgreSQL: JSON/어레이/전문 기능, 복잡한 쿼리 |
| Q11 | Redis를 DB 대신 쓰지 않는 이유는? | 메모리 비용, 재시작 시 데이터 손실 가능, 복잡한 쿼리/트랜잭션 불가 |
| Q12 | ORM을 사용하면 어떤 단점이 있나요? | 복잡한 쿼리 생성 어려움, 성능 튜닝 어려움, N+1, Raw SQL 보완 필요 |
| Q13 | 스냅샷 패턴을 왜 주문 상세에 사용하나요? | 상품 가격/이름 변경돼도 과거 주문 내역 보존 필요 → product_name, unit_price 복사 |
| Q14 | 슬로우 쿼리가 발생했을 때 처리 순서는? | 슬로우 쿼리 로그 확인 → EXPLAIN 분석 → 인덱스 추가/쿼리 재작성 → 부하 테스트 |
| Q15 | DB 백업 전략을 설명해주세요. | 풀 백업(주 1회) + 차등 백업(일 1회) + 바이너리 로그 PITR, 3-2-1 백업 규칙 |
| Q16 | Read Replica를 사용하는 이유와 주의사항은? | 읽기 부하 분산, Replication Lag 주의 (최신 데이터 보장 안됨) |
| Q17 | DB 마이그레이션 배포 시 주의사항은? | 하위 호환 유지, 컬럼 추가 먼저 후 코드 배포, 컬럼 삭제는 마지막에 |
| Q18 | 분산 트랜잭션의 문제점과 해결책은? | 2PC 성능 저하, Saga 패턴(로컬 트랜잭션 + 보상 트랜잭션) 사용 |
| Q19 | VARCHAR vs TEXT 선택 기준은? | VARCHAR: 짧고 인덱스 필요 (이름, 이메일), TEXT: 긴 내용 (블로그 본문, 상품 설명) |
| Q20 | Connection Pool을 설정할 때 고려할 사항은? | max = CPU 코어 수 * 2 + 여유, 타임아웃 설정, Connection Leak 모니터링 |
📋 DatabaseDevGuide0007 최종 학습 체크리스트
📦 프로젝트 설계
☐ 요구사항 → 엔티티 도출 가능
☐ ERD 직접 작성 가능
☐ 블로그 DB DDL 완성 가능
☐ 쇼핑몰 DB DDL 완성 가능
☐ 스냅샷 패턴 이해
☐ ERD 직접 작성 가능
☐ 블로그 DB DDL 완성 가능
☐ 쇼핑몰 DB DDL 완성 가능
☐ 스냅샷 패턴 이해
⚡ 실전 구현
☐ 주문 트랜잭션 직접 구현
☐ 커서 기반 페이지네이션 구현
☐ Redis 캐싱 패턴 적용
☐ Docker Compose 환경 구성
☐ Prisma/Sequelize 마이그레이션
☐ 커서 기반 페이지네이션 구현
☐ Redis 캐싱 패턴 적용
☐ Docker Compose 환경 구성
☐ Prisma/Sequelize 마이그레이션
🎉
DatabaseDevGuide 완주!
DB개념 · SQL기초 · SQL심화 · DB설계 · NoSQL · 실전활용 · 실전프로젝트
현업 DB 개발자가 되기 위한 모든 내용을 마스터했습니다!
✅ mysql2 연결 ✅ ERD 설계 ✅ 트랜잭션 ✅ 성능 최적화 ✅ Redis 연동 ✅ Docker 배포
🚀 시리즈 완료! 이제 현업에서 DB 개발자로 일할 준비가 됐습니다!
반응형