그룹마다 숫자 하나
GROUP BY는 같은 값을 가진 행을 그룹으로 묶고, 집계 함수는 각 그룹을 숫자 하나로 요약합니다.
1
SELECT status, COUNT(*) AS orders
FROM orders
GROUP BY status
ORDER BY orders DESC;
결과 — 7행 중 처음 5행
| status | orders |
|---|---|
| delivered | 1351 |
| paid | 695 |
| shipped | 641 |
| pending | 349 |
| refunded | 327 |
브라우저의 새 탭에서 Planche가 열리고, 샘플 데이터베이스가 로드된 채로 이 쿼리가 들어갑니다. 이 페이지의 다른 예제도 같은 탭을 다시 씁니다. F9를 누르기 전까지는 아무것도 실행되지 않습니다.
상태가 일곱 가지라 결과도 일곱 행입니다. SELECT에는 그룹 기준 컬럼이나 집계 결과만 둘 수 있습니다. 예를 들어 order_number를 함께 고르면 배송 완료 주문 1,351건 가운데 어느 주문번호를 보여줘야 할지 정할 수 없습니다.
COUNT(*)와 COUNT(컬럼)은 다릅니다
2
SELECT COUNT(*) AS customers,
COUNT(phone) AS with_phone,
COUNT(*) - COUNT(phone) AS without_phone
FROM customers;
결과 — 1행
| customers | with_phone | without_phone |
|---|---|---|
| 1500 | 1221 | 279 |
COUNT(*)는 모든 행을 셉니다. COUNT(phone)은 phone이 NULL이 아닌 행만 셉니다. 두 값의 차이가 전화번호가 없는 고객 수이며, 별도 쿼리 없이 빠진 값의 개수를 구할 수 있습니다.
다른 집계 함수도 NULL을 건너뜁니다. 예를 들어 10, 20, NULL의 평균은 10이 아니라 15입니다. NULL을 0으로 계산하는 것이 아니라 평균 대상에서 제외하기 때문입니다. 0으로 취급하려면 COALESCE(컬럼, 0)처럼 명시해야 합니다.
집계 여러 개를 한 번에
3
SELECT cat.name AS category,
COUNT(*) AS products,
ROUND(MIN(p.price), 2) AS cheapest,
ROUND(AVG(p.price), 2) AS average,
ROUND(MAX(p.price), 2) AS dearest
FROM products p
JOIN categories cat ON cat.id = p.category_id
GROUP BY cat.id
ORDER BY average DESC
LIMIT 20;
결과 — 20행 중 처음 5행
| category | products | cheapest | average | dearest |
|---|---|---|---|---|
| Wearables | 11 | 417.3 | 1672.29 | 2433.73 |
| Programming | 10 | 6.89 | 1585.67 | 2420.02 |
| Tablets | 17 | 314.76 | 1529.45 | 2412.93 |
| Audio | 11 | 593.8 | 1482.89 | 2334.28 |
| Kitchen | 12 | 670.54 | 1443.21 | 2293.8 |
이 쿼리는 테이블을 한 번 읽고 카테고리마다 다섯 값을 계산합니다. 앱에서 직접 처리하려면 상품 행을 모두 가져와 반복해야 하지만, 데이터베이스에서는 요약 결과만 받으면 됩니다.
조인 결과를 그룹 짓기
집계와 조인을 함께 사용할 수도 있습니다. 먼저 테이블을 조인하고 그 결과를 그룹으로 묶습니다.
4
SELECT cat.name AS category,
COUNT(DISTINCT o.id) AS orders,
SUM(oi.quantity) AS units,
ROUND(SUM(oi.quantity * oi.unit_price), 2) AS revenue
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
JOIN categories cat ON cat.id = p.category_id
WHERE o.status IN ('paid', 'shipped', 'delivered')
GROUP BY cat.id
ORDER BY revenue DESC;
결과 — 27행 중 처음 5행
| category | orders | units | revenue |
|---|---|---|---|
| Watches | 395 | 815 | 933900.92 |
| Board Games | 395 | 801 | 929534.55 |
| Laptops | 366 | 768 | 900778.09 |
| Science | 340 | 709 | 888542.78 |
| Cycling | 405 | 823 | 858478.88 |
WHERE는 앞에서, HAVING은 뒤에서
둘 다 결과를 거르지만 적용되는 시점이 다릅니다.
5
SELECT cat.name AS category,
ROUND(SUM(oi.quantity * oi.unit_price), 2) AS revenue
FROM order_items oi
JOIN products p ON p.id = oi.product_id
JOIN categories cat ON cat.id = p.category_id
GROUP BY cat.id
HAVING SUM(oi.quantity * oi.unit_price) > 1000000
ORDER BY revenue DESC;
결과 — 8행 중 처음 5행
| category | revenue |
|---|---|
| Board Games | 1377454.99 |
| Tablets | 1348572.94 |
| Science | 1347046.09 |
| Watches | 1325717.58 |
| Cycling | 1288143.46 |
WHERE는 그룹을 만들기 전에 어떤 행을 포함할지 정합니다. HAVING은 그룹을 만든 뒤 어떤 그룹을 남길지 정합니다. 원본 컬럼 조건은 WHERE에, 집계 조건은 HAVING에 써야 하는 이유입니다. WHERE가 평가될 때는 집계값이 아직 만들어지지 않았습니다.
SELECT 레슨의 절 순서를 보면 이 규칙을 이해하기 쉽습니다.
FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY -> LIMIT
계산한 값으로 그룹 짓기
컬럼만이 아니라 표현식으로도 그룹을 지을 수 있습니다. 타임스탬프의 앞 일곱 글자를 자르면 2025-01이 되고, 이 값이 월이 됩니다.
6
SELECT substr(ordered_at, 1, 7) AS month,
COUNT(*) AS orders,
ROUND(SUM(total), 2) AS revenue
FROM orders
WHERE status IN ('paid', 'shipped', 'delivered')
AND ordered_at >= '2025-01-01'
GROUP BY month
ORDER BY month;
결과 — 12행 중 처음 5행
| month | orders | revenue |
|---|---|---|
| 2025-01 | 52 | 311707.73 |
| 2025-02 | 53 | 350775.62 |
| 2025-03 | 58 | 351184.96 |
| 2025-04 | 52 | 374465.94 |
| 2025-05 | 53 | 381541.09 |
월별·주별 리포트도 대개 이렇게 만든 값을 기준으로 그룹을 짓습니다. 여기서는 GROUP BY month가 SELECT의 별칭을 다시 씁니다. 다만 이 허용 범위는 엔진마다 다를 수 있으므로, 이식성이 필요하면 표현식을 그대로 반복하는 편이 안전합니다.
여기까지로 할 수 있는 것
이제 그룹별 건수, 합계, 평균 같은 리포트를 만들 수 있습니다. 다음 레슨에서는 행 하나를 전체 요약과 비교하거나, 카테고리별 상위 몇 개를 찾는 데 쓰는 서브쿼리를 다룹니다.