안동민 개발노트 아이콘

안동민 개발노트

4장 : SQL 기초 — DML

실습 프로젝트: 쇼핑몰 DB

지금까지 배운 DDL(CREATE TABLE, ALTER TABLE)과 DML(INSERT, UPDATE, DELETE, SELECT)을 종합하여 실제 쇼핑몰 데이터베이스를 설계하고 운영하는 실습을 진행합니다. 단순히 SQL 문법을 따라 치는 것이 아니라, 왜 이렇게 설계하는가, 실무에서 이 쿼리를 어떤 상황에서 쓰는가를 함께 생각하면서 진행합니다.

이 실습에서는 회원, 상품, 주문, 주문상세의 4개 테이블로 구성된 쇼핑몰 스키마를 만들고, 데이터를 삽입한 뒤, 다양한 비즈니스 시나리오에 맞는 조회·수정·삭제를 수행합니다. 각 SQL에 대해 실행 결과와 그 의미를 함께 설명합니다.


스키마 설계

먼저 쇼핑몰에 필요한 테이블을 설계합니다. 3장에서 학습한 CREATE TABLE을 활용합니다.

쇼핑몰 스키마 생성
-- 회원 테이블
CREATE TABLE users (
    user_id    INT PRIMARY KEY,
    username   VARCHAR(50) NOT NULL UNIQUE,
    email      VARCHAR(100) NOT NULL UNIQUE,
    password   VARCHAR(200) NOT NULL,
    grade      VARCHAR(10) DEFAULT 'BRONZE',
    created_at DATE DEFAULT CURRENT_DATE
);

-- 상품 테이블
CREATE TABLE products (
    product_id  INT PRIMARY KEY,
    name        VARCHAR(100) NOT NULL,
    price       INT NOT NULL CHECK (price >= 0),
    stock       INT NOT NULL DEFAULT 0 CHECK (stock >= 0),
    category    VARCHAR(50),
    description VARCHAR(500),
    created_at  DATE DEFAULT CURRENT_DATE
);

-- 주문 테이블
CREATE TABLE orders (
    order_id    INT PRIMARY KEY,
    user_id     INT NOT NULL REFERENCES users(user_id),
    order_date  DATE NOT NULL DEFAULT CURRENT_DATE,
    status      VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    total_amount INT DEFAULT 0
);

-- 주문 상세 테이블
CREATE TABLE order_items (
    item_id     INT PRIMARY KEY,
    order_id    INT NOT NULL REFERENCES orders(order_id) ON DELETE CASCADE,
    product_id  INT NOT NULL REFERENCES products(product_id),
    quantity    INT NOT NULL CHECK (quantity > 0),
    unit_price  INT NOT NULL CHECK (unit_price >= 0)
);

이 스키마의 설계 포인트를 짚어봅시다.

이 절의 SQL은 학습 흐름을 위해 PostgreSQL/Oracle에 가까운 표준 SQL 표기를 중심으로 작성합니다. DATE 'YYYY-MM-DD', FETCH FIRST, CURRENT_DATE, BEGIN 같은 표현은 DBMS마다 문법이 조금씩 다르므로, MySQL이나 SQL Server에서 실행할 때는 해당 DBMS 문법으로 바꿔야 합니다. 또한 MySQL의 CHECK 제약은 8.0.16 이상에서 실제로 검사됩니다.


데이터 삽입

테이블에 실습용 데이터를 넣습니다. 실제 쇼핑몰의 데이터를 모방하여 다양한 조회 시나리오를 연습할 수 있도록 구성합니다.

회원 데이터
INSERT INTO users (user_id, username, email, password, grade, created_at)
VALUES (1, 'kim_dev', 'kim@example.com', 'hashed_pw1', 'GOLD', DATE '2024-01-15');

INSERT INTO users (user_id, username, email, password, grade, created_at)
VALUES (2, 'lee_design', 'lee@example.com', 'hashed_pw2', 'BRONZE', DATE '2024-02-20');

INSERT INTO users (user_id, username, email, password, grade, created_at)
VALUES (3, 'park_pm', 'park@example.com', 'hashed_pw3', 'SILVER', DATE '2024-03-10');

INSERT INTO users (user_id, username, email, password, grade, created_at)
VALUES (4, 'choi_data', 'choi@example.com', 'hashed_pw4', 'BRONZE', DATE '2024-05-05');

INSERT INTO users (user_id, username, email, password, grade, created_at)
VALUES (5, 'jung_back', 'jung@example.com', 'hashed_pw5', 'GOLD', DATE '2024-06-20');
상품 데이터
INSERT INTO products (product_id, name, price, stock, category, description)
VALUES (1, '맥북 프로 16', 3690000, 10, '전자기기', '최신 M3 칩 탑재');

INSERT INTO products (product_id, name, price, stock, category, description)
VALUES (2, '에어팟 프로', 359000, 50, '전자기기', '노이즈 캔슬링 지원');

INSERT INTO products (product_id, name, price, stock, category, description)
VALUES (3, '클린 코드', 33000, 100, '도서', '로버트 C. 마틴 저');

INSERT INTO products (product_id, name, price, stock, category, description)
VALUES (4, '기계식 키보드', 89000, 30, '전자기기', '체리 청축');

INSERT INTO products (product_id, name, price, stock, category, description)
VALUES (5, '모니터 암', 45000, 0, '가구', '듀얼 모니터용, 현재 품절');

INSERT INTO products (product_id, name, price, stock, category, description)
VALUES (6, '리팩터링', 44000, 80, '도서', '마틴 파울러 저');

INSERT INTO products (product_id, name, price, stock, category, description)
VALUES (7, '무선 마우스', 35000, 200, '전자기기', '인체공학 설계');

INSERT INTO products (product_id, name, price, stock, category, description)
VALUES (8, '스탠딩 데스크', 450000, 5, '가구', '전동 높이 조절');

INSERT INTO products (product_id, name, price, stock, category, description)
VALUES (9, 'USB-C 허브', 55000, 150, '전자기기', '7-in-1 멀티포트');

INSERT INTO products (product_id, name, price, stock, category, description)
VALUES (10, '실용주의 프로그래머', 38000, 60, '도서', '앤드류 헌트 저');
주문 데이터
INSERT INTO orders (order_id, user_id, order_date, status, total_amount)
VALUES (1, 1, DATE '2024-06-01', 'DELIVERED', 4408000);

INSERT INTO orders (order_id, user_id, order_date, status, total_amount)
VALUES (2, 1, DATE '2024-07-15', 'SHIPPED', 33000);

INSERT INTO orders (order_id, user_id, order_date, status, total_amount)
VALUES (3, 2, DATE '2024-08-01', 'PAID', 177000);

INSERT INTO orders (order_id, user_id, order_date, status, total_amount)
VALUES (4, 3, DATE '2024-08-15', 'DELIVERED', 3690000);

INSERT INTO orders (order_id, user_id, order_date, status, total_amount)
VALUES (5, 5, DATE '2024-09-01', 'PENDING', 89000);

INSERT INTO orders (order_id, user_id, order_date, status, total_amount)
VALUES (6, 4, DATE '2024-09-10', 'CANCELLED', 359000);
주문 상세 데이터
-- 주문1: kim이 맥북 1개 + 에어팟 2개 구매
INSERT INTO order_items (item_id, order_id, product_id, quantity, unit_price)
VALUES (1, 1, 1, 1, 3690000);
INSERT INTO order_items (item_id, order_id, product_id, quantity, unit_price)
VALUES (2, 1, 2, 2, 359000);

-- 주문2: kim이 클린코드 1권 구매
INSERT INTO order_items (item_id, order_id, product_id, quantity, unit_price)
VALUES (3, 2, 3, 1, 33000);

-- 주문3: lee가 키보드 1개 + 리팩터링 2권 구매
INSERT INTO order_items (item_id, order_id, product_id, quantity, unit_price)
VALUES (4, 3, 4, 1, 89000);
INSERT INTO order_items (item_id, order_id, product_id, quantity, unit_price)
VALUES (5, 3, 6, 2, 44000);

-- 주문4: park이 맥북 1개 구매
INSERT INTO order_items (item_id, order_id, product_id, quantity, unit_price)
VALUES (6, 4, 1, 1, 3690000);

-- 주문5: jung이 키보드 1개 구매
INSERT INTO order_items (item_id, order_id, product_id, quantity, unit_price)
VALUES (7, 5, 4, 1, 89000);

-- 주문6: choi가 에어팟 1개 구매 (취소됨)
INSERT INTO order_items (item_id, order_id, product_id, quantity, unit_price)
VALUES (8, 6, 2, 1, 359000);

COMMIT;

기본 조회 실습

단일 테이블 조회

1. 전자기기 카테고리 가격순 조회
SELECT name, price, stock
FROM products
WHERE category = '전자기기'
ORDER BY price DESC;
실행 결과
name              | price    | stock
------------------+----------+------
맥북 프로 16      | 3690000  | 10
에어팟 프로        | 359000   | 50
기계식 키보드      | 89000    | 30
USB-C 허브        | 55000    | 150
무선 마우스        | 35000    | 200
2. 재고가 있는 상품만 조회
SELECT name, stock, price
FROM products
WHERE stock > 0
ORDER BY stock DESC;
실행 결과
name                  | stock | price
----------------------+-------+--------
무선 마우스            | 200   | 35000
USB-C 허브            | 150   | 55000
클린 코드              | 100   | 33000
리팩터링               | 80    | 44000
실용주의 프로그래머     | 60    | 38000
에어팟 프로            | 50    | 359000
기계식 키보드          | 30    | 89000
맥북 프로 16          | 10    | 3690000
스탠딩 데스크          | 5     | 450000

모니터 암(stock=0)이 결과에서 빠져 있습니다. WHERE stock > 0 조건에 의해 필터링되었습니다.

3. 가격 범위 조회 (BETWEEN)
SELECT name, price
FROM products
WHERE price BETWEEN 30000 AND 100000
ORDER BY price;
실행 결과
name                  | price
----------------------+--------
클린 코드              | 33000
무선 마우스            | 35000
실용주의 프로그래머     | 38000
리팩터링               | 44000
모니터 암              | 45000
USB-C 허브            | 55000
기계식 키보드          | 89000

BETWEEN은 양쪽 끝값을 포함합니다(30000 이상 100000 이하).

4. 패턴 검색 (LIKE)
SELECT name, price FROM products WHERE name LIKE '%프로%';
실행 결과
name          | price
--------------+--------
맥북 프로 16  | 3690000
에어팟 프로    | 359000
실용주의 프로그래머 | 38000

'프로'가 포함된 모든 상품이 조회됩니다. LIKE '%프로%'는 일반 B-tree 인덱스 활용이 어려운 경우가 많으므로 대용량 테이블에서는 성능에 주의해야 합니다.

5. IN을 이용한 다중값 조회
SELECT username, email, grade
FROM users
WHERE grade IN ('GOLD', 'SILVER')
ORDER BY grade, username;
실행 결과
username   | email              | grade
-----------+--------------------+-------
kim_dev    | kim@example.com    | GOLD
jung_back  | jung@example.com   | GOLD
park_pm    | park@example.com   | SILVER

집계 함수 활용

6. 카테고리별 통계
SELECT category,
       COUNT(*) AS 상품수,
       AVG(price) AS 평균가격,
       MIN(price) AS 최저가격,
       MAX(price) AS 최고가격,
       SUM(stock) AS 총재고
FROM products
GROUP BY category
ORDER BY 상품수 DESC;
실행 결과
category | 상품수 | 평균가격  | 최저가격 | 최고가격  | 총재고
---------+--------+----------+---------+----------+------
전자기기  | 5      | 845600   | 35000   | 3690000  | 440
도서     | 3      | 38333    | 33000   | 44000    | 240
가구     | 2      | 247500   | 45000   | 450000   | 5
7. 가장 비싼 상품 조회 (서브쿼리)
SELECT name, price, category
FROM products
WHERE price = (SELECT MAX(price) FROM products);
실행 결과
name          | price    | category
--------------+----------+----------
맥북 프로 16  | 3690000  | 전자기기

서브쿼리가 먼저 실행되어 최대 가격(3690000)을 구하고, 외부 쿼리가 그 가격에 해당하는 상품을 찾습니다.

8. 평균 가격 이상 상품만 조회
SELECT name, price, category
FROM products
WHERE price >= (SELECT AVG(price) FROM products)
ORDER BY price DESC;
실행 결과
name          | price    | category
--------------+----------+----------
맥북 프로 16  | 3690000  | 전자기기

전체 평균 가격은 483,800원입니다. 이 기준 이상인 상품은 맥북 프로 16 하나뿐입니다.


다중 테이블 조회 (JOIN 미리보기)

아직 JOIN을 정식으로 배우지 않았지만, 여러 테이블의 데이터를 결합하는 쿼리를 미리 경험해 봅시다.

9. 회원별 주문 조회
SELECT u.username, o.order_id, o.order_date, o.status, o.total_amount
FROM users u
JOIN orders o ON u.user_id = o.user_id
ORDER BY o.order_date DESC;
실행 결과
username    | order_id | order_date | status    | total_amount
------------+----------+------------+-----------+-------------
choi_data   | 6        | 2024-09-10 | CANCELLED | 359000
jung_back   | 5        | 2024-09-01 | PENDING   | 89000
park_pm     | 4        | 2024-08-15 | DELIVERED | 3690000
lee_design  | 3        | 2024-08-01 | PAID      | 177000
kim_dev     | 2        | 2024-07-15 | SHIPPED   | 33000
kim_dev     | 1        | 2024-06-01 | DELIVERED | 4408000

kim_dev가 두 번 나옵니다. 한 회원이 여러 주문을 할 수 있기 때문입니다. 이것이 1:N 관계의 특성입니다.

10. 주문 상세 — 어떤 회원이 어떤 상품을 몇 개 구매했는지
SELECT u.username,
       p.name AS product_name,
       oi.quantity,
       oi.unit_price,
       oi.quantity * oi.unit_price AS subtotal
FROM order_items oi
JOIN orders o ON oi.order_id = o.order_id
JOIN users u ON o.user_id = u.user_id
JOIN products p ON oi.product_id = p.product_id
ORDER BY o.order_date, oi.item_id;
실행 결과
username   | product_name    | quantity | unit_price | subtotal
-----------+-----------------+----------+------------+---------
kim_dev    | 맥북 프로 16    | 1        | 3690000    | 3690000
kim_dev    | 에어팟 프로      | 2        | 359000     | 718000
kim_dev    | 클린 코드        | 1        | 33000      | 33000
lee_design | 기계식 키보드    | 1        | 89000      | 89000
lee_design | 리팩터링         | 2        | 44000      | 88000
park_pm    | 맥북 프로 16    | 1        | 3690000    | 3690000
jung_back  | 기계식 키보드    | 1        | 89000      | 89000
choi_data  | 에어팟 프로      | 1        | 359000     | 359000

3개 테이블을 JOIN하여 회원-주문-상품을 하나의 결과로 연결했습니다. 이것이 관계형 데이터베이스의 핵심 능력입니다. 정규화로 분리된 데이터를 JOIN으로 다시 결합하여 원하는 정보를 추출합니다.

11. 한 번도 주문하지 않은 회원 (LEFT JOIN)
SELECT u.username, u.email, u.created_at
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
WHERE o.order_id IS NULL;

LEFT JOIN은 왼쪽 테이블(users)의 모든 행을 유지하고, 오른쪽 테이블(orders)에 매칭되는 행이 없으면 NULL로 채웁니다. WHERE o.order_id IS NULL 조건으로 주문이 없는 회원만 필터링합니다.

12. 한 번도 주문되지 않은 상품
SELECT p.name, p.stock, p.category
FROM products p
LEFT JOIN order_items oi ON p.product_id = oi.product_id
WHERE oi.item_id IS NULL;
실행 결과
name                  | stock | category
----------------------+-------+---------
모니터 암              | 0     | 가구
스탠딩 데스크          | 5     | 가구
무선 마우스            | 200   | 전자기기
USB-C 허브            | 150   | 전자기기
실용주의 프로그래머     | 60    | 도서

비즈니스 시나리오별 조회

실제 쇼핑몰을 운영하면서 자주 필요한 쿼리들을 연습합니다.

13. 매출 TOP 3 상품
SELECT p.name,
       SUM(oi.quantity) AS 총판매수량,
       SUM(oi.quantity * oi.unit_price) AS 총매출
FROM order_items oi
JOIN products p ON oi.product_id = p.product_id
JOIN orders o ON oi.order_id = o.order_id
WHERE o.status IN ('PAID', 'SHIPPED', 'DELIVERED')
GROUP BY p.product_id, p.name
ORDER BY 총매출 DESC
FETCH FIRST 3 ROWS ONLY;  -- Oracle/PostgreSQL (MySQL: LIMIT 3)
실행 결과
name          | 총판매수량 | 총매출
--------------+-----------+--------
맥북 프로 16  | 2         | 7380000
에어팟 프로    | 2         | 718000
기계식 키보드  | 1         | 89000

결제·배송·배송완료 상태만 매출로 인정하고, 취소(CANCELLED)와 대기(PENDING)는 제외합니다. 실무에서는 이런 상태 정책을 빠뜨리면 잘못된 매출 보고서를 만들게 됩니다.

14. 월별 매출 추이
SELECT EXTRACT(MONTH FROM o.order_date) AS 월,
       COUNT(DISTINCT o.order_id) AS 주문수,
       SUM(o.total_amount) AS 월매출
FROM orders o
WHERE o.status IN ('PAID', 'SHIPPED', 'DELIVERED')
GROUP BY EXTRACT(MONTH FROM o.order_date)
ORDER BY 월;
실행 결과
월 | 주문수 | 월매출
---+--------+---------
6  | 1      | 4408000
7  | 1      | 33000
8  | 2      | 3867000

실습 데이터는 한 해만 사용하므로 월만 그룹화했지만, 실무에서는 EXTRACT(YEAR FROM order_date)까지 함께 묶어야 서로 다른 해의 같은 월이 합쳐지지 않습니다.

15. VIP 회원의 총 구매액
SELECT u.username, u.grade,
       COUNT(o.order_id) AS 주문횟수,
       SUM(o.total_amount) AS 총구매액
FROM users u
JOIN orders o ON u.user_id = o.user_id
WHERE o.status IN ('PAID', 'SHIPPED', 'DELIVERED')
  AND u.grade = 'GOLD'
GROUP BY u.user_id, u.username, u.grade
ORDER BY 총구매액 DESC;
16. 재고 부족 알림 (재고 10개 이하 + 판매 이력 있는 상품)
SELECT p.name, p.stock, p.category,
       SUM(oi.quantity) AS 총판매수량
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 p.stock <= 10
  AND o.status IN ('PAID', 'SHIPPED', 'DELIVERED')
GROUP BY p.product_id, p.name, p.stock, p.category
ORDER BY p.stock;

재고가 부족하면서 실제 매출 인정 주문에 포함된 상품만 확인합니다. 판매 이력이 없는 상품까지 재고 보충 대상으로 볼지, 매출이 발생한 상품만 볼지는 운영 정책에 따라 달라집니다.


UPDATE 실습

가격 변경

17. 전자기기 10% 할인
-- 변경 전 확인
SELECT name, price FROM products WHERE category = '전자기기';

-- 할인 적용
UPDATE products SET price = CAST(ROUND(price * 0.9) AS INT)
WHERE category = '전자기기';

-- 변경 후 확인
SELECT name, price FROM products WHERE category = '전자기기';
변경 전 → 후 비교
변경 전:                        변경 후:
name          | price           name          | price
맥북 프로 16  | 3690000         맥북 프로 16  | 3321000
에어팟 프로    | 359000          에어팟 프로    | 323100
기계식 키보드  | 89000           기계식 키보드  | 80100
무선 마우스    | 35000           무선 마우스    | 31500
USB-C 허브    | 55000           USB-C 허브    | 49500
18. 할인 원복
-- 10% 할인을 되돌리려면 0.9로 나누기
UPDATE products SET price = CAST(ROUND(price / 0.9) AS INT)
WHERE category = '전자기기';

ROUND와 CAST를 사용하는 이유는 할인 계산에서 소수점이 발생할 수 있기 때문입니다. 다만 반올림 정책은 DBMS와 데이터 타입에 따라 달라질 수 있으므로, 실무에서는 할인 전 원래 가격을 별도 컬럼(original_price)에 저장하는 것이 안전합니다.

주문 상태 변경

19. CASE를 활용한 일괄 상태 변경
UPDATE orders
SET status = CASE
    WHEN status = 'PENDING' AND order_date < DATE '2024-09-05'  THEN 'EXPIRED'
    WHEN status = 'SHIPPED' AND order_date < DATE '2024-08-01'  THEN 'DELIVERED'
    ELSE status
END
WHERE status IN ('PENDING', 'SHIPPED');

여러 조건에 따라 서로 다른 상태로 변경합니다. ELSE status를 넣어서 조건에 해당하지 않는 행은 변경하지 않습니다.

회원 등급 재산정

20. 구매액 기반 등급 업데이트
UPDATE users u
SET grade = (
    SELECT CASE
        WHEN COALESCE(SUM(o.total_amount), 0) >= 3000000 THEN 'GOLD'
        WHEN COALESCE(SUM(o.total_amount), 0) >= 1000000 THEN 'SILVER'
        ELSE 'BRONZE'
    END
    FROM orders o
    WHERE o.user_id = u.user_id
      AND o.status IN ('PAID', 'SHIPPED', 'DELIVERED')
);

-- 결과 확인
SELECT username, grade FROM users ORDER BY username;

서브쿼리 UPDATE를 사용하여 각 회원의 매출 인정 주문 합계를 기준으로 등급을 재산정합니다. COALESCE로 주문이 없는 회원(NULL)은 0으로 처리합니다.


DELETE 실습

취소된 주문 처리

21. 취소된 주문의 주문 상세 삭제
-- 삭제 전 확인
SELECT oi.item_id, o.order_id, o.status, p.name
FROM order_items oi
JOIN orders o ON oi.order_id = o.order_id
JOIN products p ON oi.product_id = p.product_id
WHERE o.status = 'CANCELLED';

-- 삭제 실행
DELETE FROM order_items
WHERE order_id IN (
    SELECT order_id FROM orders WHERE status = 'CANCELLED'
);

-- 취소 주문 자체도 삭제
DELETE FROM orders WHERE status = 'CANCELLED';

삭제 정책을 먼저 확인해야 합니다. 이 스키마는 order_items.order_id에 ON DELETE CASCADE가 있어 orders를 삭제하면 상세도 함께 삭제될 수 있습니다. 다만 실습에서는 영향 범위를 눈으로 확인하기 위해 order_items를 먼저 지우고 orders를 지우는 순서로 진행합니다. ON DELETE CASCADE가 없는 FK라면 자식 행을 먼저 삭제하지 않으면 부모 삭제가 실패합니다.

품절 상품 정리

22. 주문 이력 없는 품절 상품만 삭제
-- 확인
SELECT p.name, p.stock
FROM products p
LEFT JOIN order_items oi ON p.product_id = oi.product_id
WHERE p.stock = 0
  AND oi.item_id IS NULL;

-- 삭제
DELETE FROM products
WHERE stock = 0
  AND NOT EXISTS (
      SELECT 1
      FROM order_items oi
      WHERE oi.product_id = products.product_id
  );

주문 이력이 있는 상품은 삭제하면 안 됩니다. FK 제약 때문에 에러가 발생하기도 하지만, 비즈니스적으로도 과거 주문 내역에서 어떤 상품을 샀는지를 알 수 없게 되기 때문입니다. NOT EXISTS는 서브쿼리 결과에 NULL이 섞일 때 위험해질 수 있는 NOT IN보다 안전한 삭제 조건으로 자주 사용됩니다.


고급 조회 실습

23. 상품별 구매 회원 목록 (GROUP_CONCAT / STRING_AGG)
-- MySQL
SELECT p.name,
       GROUP_CONCAT(DISTINCT u.username ORDER BY u.username) AS 구매회원
FROM products p
JOIN order_items oi ON p.product_id = oi.product_id
JOIN orders o ON oi.order_id = o.order_id
JOIN users u ON o.user_id = u.user_id
WHERE o.status IN ('PAID', 'SHIPPED', 'DELIVERED')
GROUP BY p.product_id, p.name;

-- PostgreSQL
SELECT p.name,
       STRING_AGG(DISTINCT u.username, ', ' ORDER BY u.username) AS 구매회원
FROM products p
JOIN order_items oi ON p.product_id = oi.product_id
JOIN orders o ON oi.order_id = o.order_id
JOIN users u ON o.user_id = u.user_id
WHERE o.status IN ('PAID', 'SHIPPED', 'DELIVERED')
GROUP BY p.product_id, p.name;
24. 카테고리별 매출 비중
SELECT p.category,
       SUM(oi.quantity * oi.unit_price) AS 카테고리매출,
       ROUND(
           SUM(oi.quantity * oi.unit_price) * 100.0 /
           (SELECT SUM(oi2.quantity * oi2.unit_price)
            FROM order_items oi2
            JOIN orders o2 ON oi2.order_id = o2.order_id
            WHERE o2.status IN ('PAID', 'SHIPPED', 'DELIVERED')),
           1
       ) AS 비중_퍼센트
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 IN ('PAID', 'SHIPPED', 'DELIVERED')
GROUP BY p.category
ORDER BY 카테고리매출 DESC;
25. 재구매 회원 (2회 이상 주문)
SELECT u.username,
       COUNT(o.order_id) AS 주문횟수,
       MIN(o.order_date) AS 첫주문일,
       MAX(o.order_date) AS 최근주문일
FROM users u
JOIN orders o ON u.user_id = o.user_id
WHERE o.status IN ('PAID', 'SHIPPED', 'DELIVERED')
GROUP BY u.user_id, u.username
HAVING COUNT(o.order_id) >= 2;

트랜잭션 실습

실무에서의 트랜잭션 처리 패턴을 연습합니다.

26. 주문 처리 트랜잭션
BEGIN;  -- START TRANSACTION (MySQL)

-- 1. 주문 생성
INSERT INTO orders (order_id, user_id, order_date, status, total_amount)
VALUES (7, 2, CURRENT_DATE, 'PAID', 89000);

-- 2. 주문 상세 생성
INSERT INTO order_items (item_id, order_id, product_id, quantity, unit_price)
VALUES (9, 7, 4, 1, 89000);

-- 3. 재고 차감 (재고가 있을 때만 성공)
UPDATE products SET stock = stock - 1
WHERE product_id = 4
  AND stock >= 1;

-- 4. 영향 행 수 확인
SELECT stock FROM products WHERE product_id = 4;

-- UPDATE된 행이 1개이면 아래 COMMIT 실행
COMMIT;

-- UPDATE된 행이 0개이거나 중간에 실패했다면 COMMIT 대신 ROLLBACK
-- ROLLBACK;

이 트랜잭션은 주문 생성, 주문 상세 기록, 재고 차감을 하나의 원자적 단위로 처리합니다. 세 단계 중 하나라도 실패하거나 재고 차감 UPDATE가 0행이면 COMMIT하지 말고 ROLLBACK으로 전체를 취소해야 합니다. 영향 행 수는 보통 DB 클라이언트나 애플리케이션 드라이버가 반환하는 값을 확인합니다. CHECK 제약은 음수 재고를 막는 마지막 방어선이고, 실무에서는 UPDATE 조건과 영향 행 수 확인으로 먼저 막는 편이 안전합니다.

27. 등급 변경 트랜잭션 (가상 시나리오)
BEGIN;

-- 여러 회원의 등급을 하나의 정책 변경으로 처리
UPDATE users SET grade = 'SILVER' WHERE user_id = 1;  -- 등급 변경
UPDATE users SET grade = 'GOLD' WHERE user_id = 2;    -- 등급 변경

-- 변경 확인
SELECT user_id, username, grade FROM users WHERE user_id IN (1, 2);

-- 확인 후 확정
COMMIT;

실습 정리 및 데이터 원복

실습 후 정리
-- 실습에서 변경한 데이터를 원복하려면 처음부터 다시 시작
-- 방법 1: ROLLBACK (COMMIT 전이라면)
ROLLBACK;

-- 방법 2: 테이블 재생성
DROP TABLE order_items;
DROP TABLE orders;
DROP TABLE products;
DROP TABLE users;
-- 위의 CREATE TABLE과 INSERT를 다시 실행

실습에서 배운 핵심 정리

핵심 정리
1. INSERT — 컬럼 리스트를 항상 명시하고, FK 순서를 지켜야 한다
   (부모 테이블 먼저 삽입)

2. SELECT — WHERE, ORDER BY, GROUP BY, HAVING의 조합으로
   다양한 비즈니스 요구를 처리할 수 있다

3. JOIN — 정규화로 분리된 데이터를 다시 결합하는 핵심 도구이다
   LEFT JOIN으로 "없는 것"도 찾을 수 있다

4. UPDATE — 서브쿼리, CASE를 활용하면 복잡한 일괄 수정이 가능하다
   항상 SELECT로 먼저 확인하고 실행한다

5. DELETE — 순서가 중요하다 (자식 먼저, 부모 나중에)
   삭제 전 영향 범위를 반드시 확인한다

6. 트랜잭션 — 관련 DML을 하나의 단위로 묶어 원자성을 보장한다
   문제가 있으면 ROLLBACK으로 전체를 취소한다

다음 장에서는 여러 테이블을 연결하여 데이터를 추출하는 조인과 서브쿼리를 다루겠습니다.