Stage 3: Backend Essentials

Database · Lesson 03

Advanced SQL

JOINs, GROUP BY, subqueries and indexes for real queries.

Database3 min readFree lesson

Real questions rarely fit in one table. "Who spent the most?", "Which category sells best?", "Which products are above average price?" — these need joins, grouping and subqueries.

Learning objectives

  • Combine tables with INNER JOIN and LEFT JOIN.
  • Summarise with GROUP BY and filter groups with HAVING.
  • Write subqueries and EXISTS checks.
  • Understand what an index does and when it helps.

1. JOIN

The shop schema from the previous assignment: users, orders, order_items, products.

INNER JOIN — only rows that match on both sides:

SELECT o.id AS order_id, u.username, o.total_price
FROM orders o
INNER JOIN users u ON o.user_id = u.id
ORDER BY o.id;

Three-table join — the order line, with the buyer and the product:

SELECT
  u.username,
  p.name AS product,
  oi.quantity,
  oi.unit_price,
  (oi.quantity * oi.unit_price) AS subtotal
FROM order_items oi
INNER JOIN orders   o ON oi.order_id   = o.id
INNER JOIN users    u ON o.user_id     = u.id
INNER JOIN products p ON oi.product_id = p.id;

LEFT JOIN — keep every row from the left table, even without a match:

SELECT u.username, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.username;

With INNER JOIN, a user with no orders disappears. With LEFT JOIN, they appear with 0. That difference is usually the bug in a "top customers" report.

2. GROUP BY and HAVING

SELECT u.id, u.username, SUM(oi.quantity * oi.unit_price) AS spent
FROM order_items oi
INNER JOIN orders o ON oi.order_id = o.id
INNER JOIN users  u ON o.user_id   = u.id
GROUP BY u.id, u.username
ORDER BY spent DESC;

WHERE filters rows before grouping; HAVING filters groups after aggregation:

SELECT u.id, u.username, SUM(oi.quantity * oi.unit_price) AS spent
FROM order_items oi
INNER JOIN orders o ON oi.order_id = o.id
INNER JOIN users  u ON o.user_id   = u.id
GROUP BY u.id, u.username
HAVING spent > 100000
ORDER BY spent DESC;

Every column in SELECT that is not an aggregate must appear in GROUP BY. SQLite is lenient about this; PostgreSQL is not. Write standard SQL now.

3. Subqueries

-- products priced above the average
SELECT name, price
FROM products
WHERE price > (SELECT AVG(price) FROM products);

-- users who have never ordered
SELECT username
FROM users
WHERE id NOT IN (SELECT user_id FROM orders);

-- same idea with EXISTS
SELECT u.username
FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);

EXISTS short-circuits on the first match and handles NULL more predictably than NOT IN.

4. Indexes

An index is a sorted structure that lets the database find rows without scanning the table.

CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_posts_created_at ON posts(created_at);

Index columns used in WHERE, JOIN ... ON and ORDER BY on large tables. Every index slows writes and uses space, so do not index everything.

EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE user_id = 1;

Look for SCAN versus SEARCH ... USING INDEX. If a hot query scans a big table, add the index the plan is asking for.

5. Transactions

A transaction groups statements so they succeed or fail together:

BEGIN;
INSERT INTO orders (user_id, total_price) VALUES (1, 5000);
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES (last_insert_rowid(), 2, 1, 5000);
COMMIT;

If the second insert fails, ROLLBACK leaves no orphaned order. Money and inventory changes always belong in a transaction.

Practice

Write the reporting queries in the assignment against the shop schema. Verify the joins by comparing counts with a plain SELECT COUNT(*).

Example files

Complete, runnable files used in this lesson. Copy them into your project and experiment.

advanced.sqlSQL
-- ============================================
-- 강의03 예제: 주문-상품-유저 3테이블 조인 쿼리 모음
-- ============================================

-- ─────────────────────────────────────────
-- 1. 테이블 생성 및 데이터 준비
-- ─────────────────────────────────────────
DROP TABLE IF EXISTS order_items;
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS products;
DROP TABLE IF EXISTS users;

CREATE TABLE users (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    username   TEXT    NOT NULL,
    email      TEXT    NOT NULL UNIQUE,
    created_at TEXT    DEFAULT (date('now'))
);

CREATE TABLE products (
    id       INTEGER PRIMARY KEY AUTOINCREMENT,
    name     TEXT    NOT NULL,
    price    INTEGER NOT NULL,
    category TEXT    NOT NULL,
    stock    INTEGER DEFAULT 0
);

CREATE TABLE orders (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id     INTEGER NOT NULL,
    status      TEXT    DEFAULT 'pending',
    ordered_at  TEXT    DEFAULT (date('now')),
    FOREIGN KEY (user_id) REFERENCES users(id)
);

CREATE TABLE order_items (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    order_id   INTEGER NOT NULL,
    product_id INTEGER NOT NULL,
    quantity   INTEGER NOT NULL DEFAULT 1,
    unit_price INTEGER NOT NULL,
    FOREIGN KEY (order_id)   REFERENCES orders(id),
    FOREIGN KEY (product_id) REFERENCES products(id)
);

-- 샘플 데이터
INSERT INTO users (username, email) VALUES
    ('kim',  '[email protected]'),
    ('lee',  '[email protected]'),
    ('park', '[email protected]'),
    ('choi', '[email protected]');  -- 주문 없는 회원

INSERT INTO products (name, price, category, stock) VALUES
    ('노트북',     1500000, '전자제품', 10),
    ('마우스',       30000, '전자제품', 50),
    ('키보드',       80000, '전자제품', 30),
    ('모니터',      450000, '전자제품', 15),
    ('파이썬 책',    28000, '도서',     40),
    ('클린코드',     32000, '도서',     35),
    ('의자',        350000, '가구',     20);

-- 주문 데이터 (ordered_at을 직접 지정하여 월별 통계 테스트)
INSERT INTO orders (user_id, status, ordered_at) VALUES
    (1, 'paid',    '2024-01-15'),
    (1, 'paid',    '2024-02-20'),
    (2, 'paid',    '2024-01-10'),
    (2, 'pending', '2024-03-05'),
    (3, 'paid',    '2024-02-14'),
    (1, 'paid',    '2024-03-22');

INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES
    (1, 1, 1, 1500000),  -- 주문1: 노트북 1개
    (1, 2, 2,   30000),  -- 주문1: 마우스 2개
    (2, 3, 1,   80000),  -- 주문2: 키보드 1개
    (2, 5, 2,   28000),  -- 주문2: 파이썬 책 2개
    (3, 4, 1,  450000),  -- 주문3: 모니터 1개
    (4, 6, 1,   32000),  -- 주문4: 클린코드 1개
    (5, 7, 1,  350000),  -- 주문5: 의자 1개
    (5, 2, 1,   30000),  -- 주문5: 마우스 1개
    (6, 1, 1, 1500000),  -- 주문6: 노트북 1개
    (6, 3, 1,   80000);  -- 주문6: 키보드 1개

-- 인덱스 생성
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_ordered_at ON orders(ordered_at);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
CREATE INDEX idx_order_items_product_id ON order_items(product_id);

-- ─────────────────────────────────────────
-- 2. JOIN 쿼리 모음
-- ─────────────────────────────────────────

-- 2-1. 전체 주문 내역 (회원명 포함) — INNER JOIN
SELECT
    u.username           AS 회원명,
    o.id                 AS 주문번호,
    o.status             AS 상태,
    o.ordered_at         AS 주문일
FROM orders o
INNER JOIN users u ON o.user_id = u.id
ORDER BY o.ordered_at DESC;

-- 2-2. 주문 상품 상세 내역 — 3테이블 JOIN
SELECT
    u.username                        AS 회원명,
    o.id                              AS 주문번호,
    p.name                            AS 상품명,
    p.category                        AS 카테고리,
    oi.quantity                       AS 수량,
    oi.unit_price                     AS 단가,
    (oi.quantity * oi.unit_price)     AS 소계
FROM order_items oi
INNER JOIN orders   o ON oi.order_id   = o.id
INNER JOIN users    u ON o.user_id     = u.id
INNER JOIN products p ON oi.product_id = p.id
ORDER BY o.id, oi.id;

-- 2-3. 주문이 없는 회원 찾기 — LEFT JOIN 활용
SELECT
    u.username,
    u.email,
    o.id AS 주문번호
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;  -- 주문이 없으면 NULL

-- ─────────────────────────────────────────
-- 3. GROUP BY + 집계 함수
-- ─────────────────────────────────────────

-- 3-1. 회원별 총 주문 금액
SELECT
    u.username                              AS 회원명,
    COUNT(DISTINCT o.id)                    AS 주문수,
    SUM(oi.quantity * oi.unit_price)        AS 총구매액,
    AVG(oi.quantity * oi.unit_price)        AS 평균주문금액
FROM order_items oi
INNER JOIN orders o ON oi.order_id = o.id
INNER JOIN users  u ON o.user_id   = u.id
GROUP BY u.id, u.username
ORDER BY 총구매액 DESC;

-- 3-2. 카테고리별 판매량 및 매출
SELECT
    p.category              AS 카테고리,
    COUNT(oi.id)            AS 판매건수,
    SUM(oi.quantity)        AS 판매수량,
    SUM(oi.quantity * oi.unit_price) AS 총매출
FROM order_items oi
INNER JOIN products p ON oi.product_id = p.id
GROUP BY p.category
ORDER BY 총매출 DESC;

-- 3-3. HAVING: 총 구매액 100만원 이상 VIP 고객
SELECT
    u.username,
    SUM(oi.quantity * oi.unit_price) AS 총구매액
FROM order_items oi
INNER JOIN orders o ON oi.order_id = o.id
INNER JOIN users  u ON o.user_id   = u.id
GROUP BY u.id, u.username
HAVING SUM(oi.quantity * oi.unit_price) >= 1000000;  -- 100만원 이상만

-- ─────────────────────────────────────────
-- 4. 서브쿼리
-- ─────────────────────────────────────────

-- 4-1. 평균 단가보다 비싼 상품 조회
SELECT name, price, category
FROM products
WHERE price > (SELECT AVG(price) FROM products)
ORDER BY price DESC;

-- 4-2. 가장 많이 팔린 상품 TOP 3
SELECT
    p.name,
    SUM(oi.quantity) AS 총판매수량
FROM order_items oi
INNER JOIN products p ON oi.product_id = p.id
GROUP BY p.id, p.name
ORDER BY 총판매수량 DESC
LIMIT 3;

-- ─────────────────────────────────────────
-- 5. EXPLAIN QUERY PLAN — 실행 계획 확인
-- ─────────────────────────────────────────

-- 인덱스 사용 여부 확인
EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE user_id = 1;
-- 기대 결과: SEARCH TABLE orders USING INDEX idx_orders_user_id

Assignment

Assignment 03 — Shop reports

Using the users, products, orders and order_items tables from Assignment 01, write advanced.sql with the reports below.

Reports

  1. Order list — order id, username and total, newest first.
  2. Full order lines — username, product name, quantity, unit price, subtotal.
  3. Revenue per user — username and total spent, highest first.
  4. Every user, including those with no orders — username and order count, using LEFT JOIN.
  5. Revenue per category — category, units sold, revenue.
  6. VIP customers — users who spent more than 100,000, using HAVING.
  7. Above-average products — products priced above the average, using a subquery.
  8. Customers who never ordered — with NOT EXISTS.
  9. Top three products by units sold.

Extras

  1. Add an index on orders(user_id) and run EXPLAIN QUERY PLAN for report 1.
  2. Wrap a new order plus its items in BEGIN / COMMIT.

Rules

  • Use explicit JOIN ... ON syntax, not comma joins.
  • Alias tables (o, oi, u, p) and qualify every column.
  • Every non-aggregated SELECT column must appear in GROUP BY.
  • Add a comment above each query with the question it answers.

Done when

  • Report 4 shows users with 0 orders.
  • Report 5 totals match a manual sum of quantity * unit_price.
  • The query plan for report 1 mentions the new index instead of a full scan.
Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

advanced.sqlSQL
-- ============================================
-- 과제03 정답: 통계 쿼리 작성
-- ============================================
-- 실행 전: 강의03 예제/advanced.sql을 먼저 실행하여 데이터 준비
-- ============================================

-- ─────────────────────────────────────────
-- 쿼리 1. 카테고리별 매출 통계
-- order_items와 products를 JOIN한 뒤
-- 카테고리로 그룹화하여 총매출, 판매수량, 평균단가를 계산
-- ─────────────────────────────────────────
SELECT
    p.category                               AS 카테고리,
    SUM(oi.quantity * oi.unit_price)         AS 총매출,       -- 수량 × 단가 합계
    SUM(oi.quantity)                         AS 판매수량,     -- 총 팔린 개수
    CAST(AVG(oi.unit_price) AS INTEGER)      AS 평균단가      -- 평균 판매 단가 (정수 변환)
FROM order_items oi
INNER JOIN products p ON oi.product_id = p.id  -- 상품 정보 연결
GROUP BY p.category                            -- 카테고리별 묶기
ORDER BY 총매출 DESC;                          -- 매출 높은 순

-- ─────────────────────────────────────────
-- 쿼리 2. 월별 주문 수 및 매출
-- strftime('%Y-%m', ordered_at)으로 연-월 문자열을 추출
-- 예: '2024-01-15' → '2024-01'
-- 주문(orders)과 주문상품(order_items)을 JOIN하여 매출 계산
-- ─────────────────────────────────────────
SELECT
    strftime('%Y-%m', o.ordered_at)          AS 연월,        -- 연-월 추출
    COUNT(DISTINCT o.id)                     AS 주문건수,    -- 중복 없이 주문 수 세기
    SUM(oi.quantity * oi.unit_price)         AS 총매출       -- 해당 월 총 매출
FROM orders o
INNER JOIN order_items oi ON o.id = oi.order_id  -- 주문별 상품 연결
GROUP BY strftime('%Y-%m', o.ordered_at)          -- 연월별 그룹화
ORDER BY 연월 ASC;                                 -- 날짜 오름차순

-- ─────────────────────────────────────────
-- 쿼리 3. 회원별 구매 순위
-- 여러 테이블을 3중 JOIN하여 회원 기준으로 집계
-- COUNT(DISTINCT product_id)로 구매한 상품 종류 수 계산
-- ─────────────────────────────────────────
SELECT
    u.username                                AS 회원명,
    SUM(oi.quantity * oi.unit_price)          AS 총구매액,       -- 누적 구매 금액
    COUNT(DISTINCT oi.product_id)             AS 구매상품종류수   -- 중복 제거한 상품 종류 수
FROM order_items oi
INNER JOIN orders o ON oi.order_id = o.id   -- 주문 연결
INNER JOIN users  u ON o.user_id   = u.id   -- 회원 연결
GROUP BY u.id, u.username                   -- 회원별 그룹화
ORDER BY 총구매액 DESC;                      -- 구매액 높은 순

-- ─────────────────────────────────────────
-- 쿼리 4. 베스트셀러 상품 TOP 5
-- 상품별로 판매 수량과 총 매출을 계산
-- ORDER BY + LIMIT으로 상위 5개만 추출
-- ─────────────────────────────────────────
SELECT
    p.name                                   AS 상품명,
    p.category                               AS 카테고리,
    SUM(oi.quantity)                         AS 총판매수량,  -- 팔린 개수 합계
    SUM(oi.quantity * oi.unit_price)         AS 총매출       -- 해당 상품 총 매출
FROM order_items oi
INNER JOIN products p ON oi.product_id = p.id  -- 상품 정보 연결
GROUP BY p.id, p.name, p.category              -- 상품별 그룹화
ORDER BY 총판매수량 DESC                        -- 많이 팔린 순
LIMIT 5;                                        -- 상위 5개

-- ─────────────────────────────────────────
-- 쿼리 5. 주문이 없는 회원 목록
-- LEFT JOIN을 사용하면 users의 모든 행을 포함
-- 주문이 없는 회원은 orders.id가 NULL → WHERE로 필터링
-- ─────────────────────────────────────────
SELECT
    u.username   AS 회원명,
    u.email      AS 이메일
FROM users u
LEFT JOIN orders o ON u.id = o.user_id  -- 주문 없어도 회원은 포함
WHERE o.id IS NULL;                     -- 주문이 없는 경우만 (NULL인 경우)

Learn this lesson with a mentor

Work through the Build Your Homepage series on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now