GROUP BY와 집계 함수
카테고리별 평균 가격, 월별 주문 수, 부서별 최고 연봉 — 이런 질문에 답하려면 행을 그룹으로 묶고 각 그룹을 요약(집계)해야 합니다. GROUP BY가 행을 그룹으로 묶는 역할을 하고, 집계 함수가 각 그룹을 하나의 값으로 요약하는 역할을 합니다.
개별 행 하나하나를 보는 것이 미시적 관점이라면, 집계는 전체 또는 그룹 단위의 거시적 관점으로 데이터를 바라보는 것입니다.
집계 함수 (Aggregate Functions)
집계 함수는 여러 행의 값을 받아 하나의 결과를 반환하는 함수입니다. GROUP BY 없이 사용하면 전체 테이블이 하나의 그룹으로 간주됩니다.
기본 집계 함수 6가지
| 함수 | 용도 | NULL 처리 |
|---|---|---|
| COUNT(*) | 행 수 | NULL 포함 (모든 행) |
| COUNT(컬럼) | NULL이 아닌 행 수 | NULL 제외 |
| SUM(컬럼) | 합계 | NULL 무시 |
| AVG(컬럼) | 평균 | NULL 무시 |
| MAX(컬럼) | 최대값 | NULL 무시 |
| MIN(컬럼) | 최소값 | NULL 무시 |
SELECT COUNT(*) AS 전체상품수,
COUNT(category) AS 카테고리있는수,
SUM(stock) AS 전체재고,
AVG(price) AS 평균가격,
MAX(price) AS 최고가,
MIN(price) AS 최저가
FROM products;전체상품수 | 카테고리있는수 | 전체재고 | 평균가격 | 최고가 | 최저가
-----------|----------------|----------|----------|---------|-------
6 | 5 | 150 | 75000 | 150000 | 15000COUNT(*) vs COUNT(컬럼) vs COUNT(DISTINCT 컬럼)
이 세 가지는 자주 혼동되지만 동작이 완전히 다릅니다.
-- 예제 데이터:
-- id | category
-- 1 | 전자
-- 2 | 전자
-- 3 | 식품
-- 4 | NULL
-- 5 | 식품
SELECT COUNT(*) AS total_rows, -- 5 (NULL 행 포함)
COUNT(category) AS non_null_count, -- 4 (NULL 제외)
COUNT(DISTINCT category) AS distinct_count -- 2 (전자, 식품)
FROM products;COUNT(DISTINCT category)도 COUNT(category)처럼 NULL은 세지 않습니다. NULL을 별도 값처럼 세야 한다면 COALESCE(category, '미분류')처럼 업무상 의미가 있는 대체값을 먼저 정해야 합니다.
AVG와 NULL의 함정
AVG는 NULL을 무시하고 계산합니다. 이것이 의도와 다를 수 있습니다.
-- 점수 데이터: 80, 90, NULL, 70, NULL
SELECT AVG(score) AS avg_score FROM students;
-- 결과: (80 + 90 + 70) / 3 = 80
-- NULL인 2명은 분모에서도 제외됨!
-- NULL을 0으로 포함하여 계산하고 싶으면:
SELECT AVG(COALESCE(score, 0)) AS avg_score FROM students;
-- 결과: (80 + 90 + 0 + 70 + 0) / 5 = 48
-- Oracle에서는 NVL 사용
SELECT AVG(NVL(score, 0)) AS avg_score FROM students;미응시자를 0점으로 처리할지, 제외할지는 업무 요구에 따라 다릅니다. AVG의 NULL 처리 방식을 이해하고, 의도에 맞게 COALESCE를 사용해야 합니다.
SUM과 NULL
SUM도 NULL을 무시합니다. 모든 값이 NULL이면 SUM의 결과도 NULL입니다 (0이 아님).
-- 주문이 없는 회원의 총 주문금액
SELECT COALESCE(SUM(total_amount), 0) AS total_spent
FROM orders
WHERE user_id = 999; -- 해당 주문 없음
-- SUM → NULL, COALESCE → 0GROUP BY
GROUP BY는 지정한 컬럼의 값이 같은 행들을 하나의 그룹으로 묶습니다. 각 그룹에 대해 집계 함수가 적용되어, 그룹당 하나의 결과 행이 생성됩니다.
-- 카테고리별 통계
SELECT category,
COUNT(*) AS 상품수,
AVG(price) AS 평균가격,
SUM(stock) AS 총재고
FROM products
GROUP BY category;GROUP BY 규칙 (매우 중요)
일반적으로 SELECT에 집계 함수가 아닌 컬럼이 있으면, 그 컬럼은 GROUP BY에 포함되어야 합니다.
-- ✓ category가 GROUP BY에 있으므로 SELECT에 사용 가능
SELECT category, AVG(price)
FROM products
GROUP BY category;
-- ✓ 여러 컬럼으로 그룹화
SELECT category, brand, AVG(price)
FROM products
GROUP BY category, brand;-- ✗ name이 GROUP BY에 없음 → 오류
SELECT name, AVG(price)
FROM products
GROUP BY category;
-- ERROR: 'name' is not in GROUP BY clause왜 이 규칙이 필요할까요? category='전자'인 그룹에 여러 상품이 있는데, 그중 어떤 상품의 name을 반환해야 할지 알 수 없기 때문입니다. 그룹 내에서 하나의 확정된 값이 아닌 컬럼은 SELECT에 올 수 없습니다.
MySQL의 ONLY_FULL_GROUP_BY 모드가 꺼져 있으면 이 오류가 발생하지 않고 임의의 값이 반환될 수 있습니다. 이는 잘못된 결과의 원인이므로, 특별한 이유가 없다면 ONLY_FULL_GROUP_BY를 켜두는 것이 좋습니다. 일부 DBMS는 기본키처럼 함수 종속성이 명확한 컬럼을 예외적으로 허용할 수 있지만, 원칙은 그룹 키와 집계 값을 명확히 구분하는 것입니다.
다중 컬럼 GROUP BY
여러 컬럼으로 그룹화하면 컬럼1의 값 + 컬럼2의 값의 조합으로 그룹이 결정됩니다.
-- 연도별, 카테고리별 매출 통계
SELECT YEAR(order_date) AS 연도,
p.category AS 카테고리,
SUM(oi.quantity * oi.unit_price) AS 매출
FROM orders o
INNER JOIN order_items oi ON o.order_id = oi.order_id
INNER JOIN products p ON oi.product_id = p.product_id
GROUP BY YEAR(order_date), p.category
ORDER BY 연도, 매출 DESC;YEAR(order_date)는 MySQL/SQL Server 계열에서 흔히 쓰는 표현입니다. PostgreSQL은 EXTRACT(YEAR FROM order_date), Oracle은 EXTRACT(YEAR FROM order_date) 또는 TO_CHAR 기반 표현을 사용하는 식으로 바꿔야 합니다.
연도 | 카테고리 | 매출
------|---------|----------
2023 | 전자 | 12000000
2023 | 식품 | 3000000
2024 | 전자 | 15000000
2024 | 식품 | 4500000HAVING
HAVING은 GROUP BY로 만들어진 그룹을 필터링합니다. WHERE가 개별 행을 필터링한다면, HAVING은 그룹을 필터링합니다.
WHERE vs HAVING
SELECT category, AVG(price) AS avg_price
FROM products
WHERE stock > 0 -- 먼저: 재고 있는 상품만 (행 필터링)
GROUP BY category
HAVING AVG(price) > 50000; -- 이후: 평균 가격 5만 이상인 그룹만 (그룹 필터링)HAVING의 조건
HAVING 절에는 보통 집계 함수나 GROUP BY에 포함된 컬럼을 사용합니다. DBMS별로 별칭이나 함수 종속성 처리 방식은 다를 수 있습니다.
-- 상품이 2개 이상인 카테고리만
SELECT category, COUNT(*) AS cnt
FROM products
GROUP BY category
HAVING COUNT(*) >= 2;
-- 총 매출 100만 이상이고 주문 수 10건 이상인 회원
SELECT u.username,
SUM(o.total_amount) AS total,
COUNT(o.order_id) AS orders
FROM users u
INNER JOIN orders o ON u.user_id = o.user_id
GROUP BY u.username
HAVING SUM(o.total_amount) >= 1000000
AND COUNT(o.order_id) >= 10;WHERE vs HAVING 선택 기준
집계 함수 없이 필터링할 수 있는 조건은 WHERE에 쓰는 편이 보통 더 유리합니다. WHERE는 그룹화 전에 행을 줄이므로 처리할 데이터가 줄어들기 때문입니다.
-- ✗ 비효율: 모든 행을 그룹화한 후 카테고리 필터링
SELECT category, AVG(price)
FROM products
GROUP BY category
HAVING category = '전자';
-- ✓ 효율: 먼저 행을 줄인 후 그룹화
SELECT category, AVG(price)
FROM products
WHERE category = '전자'
GROUP BY category;GROUP BY와 정렬
GROUP BY의 결과 순서는 보장되지 않습니다. 특정 순서가 필요하면 반드시 ORDER BY를 추가해야 합니다.
-- 매출이 높은 카테고리 순서로 정렬
SELECT category, SUM(price * stock) AS estimated_value
FROM products
GROUP BY category
ORDER BY estimated_value DESC;일부 DBMS(특히 MySQL의 이전 버전)에서는 GROUP BY가 자동으로 정렬하는 것처럼 보이지만, 이는 보장되지 않는 동작입니다. 항상 명시적으로 ORDER BY를 사용해야 합니다.
GROUP BY와 NULL
NULL 값은 GROUP BY에서 하나의 그룹으로 취급됩니다. 즉, category가 NULL인 모든 행은 하나의 NULL 그룹으로 묶입니다.
-- category가 NULL인 상품도 하나의 그룹으로
SELECT category, COUNT(*) AS cnt
FROM products
GROUP BY category;category | cnt
---------|----
전자 | 2
식품 | 3
NULL | 1 ← NULL도 하나의 그룹ROLLUP, CUBE, GROUPING SETS
그룹화의 여러 수준에서 동시에 집계하는 확장 기능입니다. 지원 여부와 문법은 DBMS마다 차이가 있으므로 사용하는 DBMS의 문서를 확인해야 합니다.
아래 예시는 PostgreSQL, Oracle, SQL Server에서 쓰는 표준형 문법에 가깝습니다. MySQL은 ROLLUP을 GROUP BY category, brand WITH ROLLUP 형태로 사용하고, CUBE/GROUPING SETS 지원 범위는 다르므로 그대로 옮기면 안 됩니다.
ROLLUP
소계와 총계를 자동으로 생성합니다. 컬럼 순서대로 오른쪽에서부터 하나씩 제거하며 집계합니다.
SELECT category, brand, SUM(price) AS total_price
FROM products
GROUP BY ROLLUP(category, brand);ROLLUP(A, B, C)는 (A,B,C), (A,B), (A), () 네 수준의 집계를 생성합니다.
CUBE
가능한 모든 조합의 집계를 생성합니다.
SELECT category, brand, SUM(price) AS total_price
FROM products
GROUP BY CUBE(category, brand);CUBE(A, B)는 (A,B), (A), (B), () 네 수준의 집계를 생성합니다. ROLLUP보다 더 많은 집계 행을 생성합니다.
GROUPING SETS
원하는 그룹 조합만 지정할 수 있습니다.
SELECT category, brand, SUM(price) AS total_price
FROM products
GROUP BY GROUPING SETS (
(category, brand), -- 카테고리+브랜드별
(category), -- 카테고리별
() -- 전체
);GROUPING 함수
소계/총계 행에서 NULL이 데이터의 NULL인지 집계로 인한 NULL인지 구분하기 위해 GROUPING 함수를 사용합니다.
SELECT CASE WHEN GROUPING(category) = 1 THEN '전체'
ELSE COALESCE(category, '미분류')
END AS category,
SUM(price) AS total_price
FROM products
GROUP BY ROLLUP(category);GROUPING 함수도 DBMS별 지원 범위와 반환값 표현이 다릅니다. 소계 행의 NULL과 실제 데이터 NULL을 구분해야 하는 보고서라면 반드시 함께 확인해야 합니다.
집계 함수와 DISTINCT
집계 함수 안에서 DISTINCT를 사용하면 중복을 제거한 후 집계합니다.
-- 주문한 적 있는 고유 회원 수
SELECT COUNT(DISTINCT user_id) AS unique_customers
FROM orders;
-- 카테고리별 고유 브랜드 수
SELECT category, COUNT(DISTINCT brand) AS brand_count
FROM products
GROUP BY category;실무 집계 패턴
조건부 집계 (CASE + 집계)
-- 상태별 주문 수를 한 행으로 피벗
SELECT COUNT(CASE WHEN status = 'PAID' THEN 1 END) AS paid,
COUNT(CASE WHEN status = 'SHIPPED' THEN 1 END) AS shipped,
COUNT(CASE WHEN status = 'DELIVERED' THEN 1 END) AS delivered
FROM orders;COUNT(CASE WHEN ... THEN 1 END)는 조건을 만족하지 않는 행을 NULL로 만들어 세지 않는 방식입니다. 같은 의미로 SUM(CASE WHEN 조건 THEN 1 ELSE 0 END)를 쓰기도 하고, PostgreSQL에서는 COUNT(*) FILTER (WHERE status = 'PAID')처럼 더 직접적인 문법도 사용할 수 있습니다.
paid | shipped | delivered
-----|---------|----------
15 | 8 | 42비율/퍼센트 계산
-- 카테고리별 매출 비율
SELECT category,
SUM(COALESCE(price, 0) * COALESCE(stock, 0)) AS value,
ROUND(SUM(COALESCE(price, 0) * COALESCE(stock, 0)) * 100.0 /
NULLIF((SELECT SUM(COALESCE(price, 0) * COALESCE(stock, 0))
FROM products), 0), 1) AS pct
FROM products
GROUP BY category
ORDER BY pct DESC;비율 계산에서는 분모가 0이거나 NULL일 수 있습니다. NULLIF(..., 0)로 나눗셈 오류를 막고, COALESCE로 가격이나 재고 NULL을 0으로 볼지 여부를 명확히 정합니다.
TOP-N 그룹
SELECT p.category,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders o
INNER JOIN order_items oi ON o.order_id = oi.order_id
INNER JOIN products p ON oi.product_id = p.product_id
GROUP BY p.category
ORDER BY revenue DESC
LIMIT 3; -- MySQL, PostgreSQL
-- SQL Server: SELECT TOP 3 ... 또는 OFFSET 0 ROWS FETCH NEXT 3 ROWS ONLY
-- Oracle: ORDER BY 이후 FETCH FIRST 3 ROWS ONLY 권장날짜 기반 집계
-- MySQL
SELECT DATE_FORMAT(order_date, '%Y-%m') AS month,
COUNT(*) AS order_count,
SUM(total_amount) AS revenue
FROM orders
GROUP BY DATE_FORMAT(order_date, '%Y-%m')
ORDER BY month;
-- PostgreSQL
SELECT DATE_TRUNC('month', order_date) AS month_start,
COUNT(*) AS order_count,
SUM(total_amount) AS revenue
FROM orders
GROUP BY DATE_TRUNC('month', order_date)
ORDER BY month_start;
-- Oracle
SELECT TRUNC(order_date, 'MM') AS month_start,
COUNT(*) AS order_count,
SUM(total_amount) AS revenue
FROM orders
GROUP BY TRUNC(order_date, 'MM')
ORDER BY month_start;화면에 YYYY-MM 형식으로 보여주는 것과 월 단위로 정확히 묶는 것은 분리해서 생각하는 편이 안전합니다. 대량 데이터에서는 컬럼에 함수를 직접 씌운 GROUP BY가 인덱스 활용을 어렵게 만들 수 있으므로, 함수 기반 인덱스나 생성 컬럼, 월별 집계 테이블도 함께 검토합니다.
연속 범위 집계
SELECT CASE
WHEN price < 10000 THEN '1만원 미만'
WHEN price < 50000 THEN '1~5만원'
WHEN price < 100000 THEN '5~10만원'
ELSE '10만원 이상'
END AS price_range,
COUNT(*) AS product_count
FROM products
GROUP BY CASE
WHEN price < 10000 THEN '1만원 미만'
WHEN price < 50000 THEN '1~5만원'
WHEN price < 100000 THEN '5~10만원'
ELSE '10만원 이상'
END
ORDER BY MIN(price);흔한 실수 정리
GROUP BY 전체 정리
다음 절에서는 그룹화하면서도 행을 유지하는 윈도우 함수를 다루겠습니다.