Guider/Database/DatabaseDevGuide0007
Database#07

DatabaseDevGuide0007

실전 프로젝트 완전 정복

🏗️ 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)

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 완성 가능
☐ 스냅샷 패턴 이해
⚡ 실전 구현
☐ 주문 트랜잭션 직접 구현
☐ 커서 기반 페이지네이션 구현
☐ Redis 캐싱 패턴 적용
☐ Docker Compose 환경 구성
☐ Prisma/Sequelize 마이그레이션
🎉

DatabaseDevGuide 완주!

DB개념 · SQL기초 · SQL심화 · DB설계 · NoSQL · 실전활용 · 실전프로젝트
현업 DB 개발자가 되기 위한 모든 내용을 마스터했습니다!

✅ mysql2 연결 ✅ ERD 설계 ✅ 트랜잭션 ✅ 성능 최적화 ✅ Redis 연동 ✅ Docker 배포

🚀 시리즈 완료! 이제 현업에서 DB 개발자로 일할 준비가 됐습니다!

반응형