월간 매출 리포트를 만들 때 단순 SELECT만으로는 답이 나오지 않습니다. 상품별·지역별로 묶어 합계를 내고, 비정상적으로 튀는 그룹을 빠르게 찾아야 합니다. GROUP BY와 HAVING을 정확히 이해하면 대시보드와 정산 쿼리를 훨씬 안전하게 작성할 수 있습니다.
GROUP BY는 데이터 분석의 핵심입니다. 실행 순서(WHERE → GROUP BY → HAVING → SELECT → ORDER BY)를 이해하면 어디에 조건을 넣어야 하는지 직관적으로 판단할 수 있습니다.
- 1집계함수 — COUNT, SUM, AVG, MAX, MIN의 정확한 동작
- 2GROUP BY — 실행 순서와 내부 동작 이해
- 3HAVING vs WHERE — 개별 행 필터 vs 그룹 필터
- 4실전 분석 쿼리 — 월별 매출, 카테고리별 통계
- 5ROLLUP과 CUBE — 소계/합계 자동 생성
- 6FILTER(WHERE ...) — 조건부 집계
GROUP BY와 집계함수 — 데이터 분석 기초
월별 매출 리포트를 만들어야 했는데, 처음엔 Python으로 짰다. DB에서 전체 주문을 끌어다가 딕셔너리로 월별로 묶고, 합계 계산하고 — 코드가 30줄쯤 됐다. 옆에 앉은 시니어가 보더니 쿼리 하나를 써줬다. SELECT DATE_TRUNC('month', created_at), SUM(amount) FROM orders GROUP BY 1 — 끝이었다. 그 순간 깨달았다, 내가 Python으로 하고 있던 루프와 딕셔너리 작업이 전부 GROUP BY 한 줄로 DB 안에서 처리될 수 있다는 걸. 집계를 애플리케이션 레이어에서 하면 전체 데이터를 네트워크로 옮겨야 하지만, DB 안에서 하면 결과만 나온다. GROUP BY와 집계함수를 모르면 불필요한 데이터를 수십만 건씩 끌어오는 코드를 계속 짜게 된다.
집계함수와 GROUP BY — 동작 원리 이해하기
월별 매출 집계 쿼리를 짰는데 총합이 실제 데이터와 다릅니다. GROUP BY에 잘못된 컬럼을 포함시켜서 집계 단위가 달라진 것입니다. 또 NULL이 포함된 컬럼을 COUNT하면 NULL은 세지 않는데 이 동작을 모르면 집계 결과를 잘못 해석합니다. 집계함수와 GROUP BY의 동작 원리를 정확히 알아야 의도한 집계를 얻을 수 있습니다.

집계함수 종류와 NULL 처리
COUNT(*) 는 NULL 여부와 무관하게 모든 행을 셉니다. COUNT(column)은 해당 컬럼 값이 NULL인 행을 제외합니다. SUM, AVG, MIN, MAX는 모두 NULL 값을 무시하고 계산합니다. NULL을 0으로 포함시키려면 COALESCE(column, 0)으로 감싸야 합니다.
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
category VARCHAR(50),
amount NUMERIC(12, 2),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at TIMESTAMP DEFAULT NOW()
);
실행 완료 또는 조회 결과가 표시됩니다.
- 그룹 수—GROUP BY 결과가 기대한 기준(상품·지역·월)별로 한 행씩 묶였는지 확인합니다.
- 집계 값—SUM·COUNT 값이 원본 행 수나 금액과 논리적으로 맞는지 봅니다.
- HAVING 적용—집계 후 필터링된 그룹만 남았는지 확인합니다.
SELECT
COUNT(*) AS 총행수,
COUNT(amount) AS 금액있는행수,
COUNT(DISTINCT user_id) AS 고유사용자수,
SUM(amount) AS 총금액,
AVG(amount) AS 평균금액,
MIN(amount) AS 최소금액,
MAX(amount) AS 최대금액,
SUM(COALESCE(amount, 0)) AS 널을0포함합계
FROM orders;
GROUP BY 실행 순서가 중요한 이유
SQL은 작성 순서와 실행 순서가 다릅니다. 이 차이를 모르면 "왜 WHERE에서 집계함수를 쓸 수 없는가?"나 "왜 SELECT에서 정의한 별칭을 HAVING에서 쓸 수 없는가?" 같은 오류가 발생합니다.
논리적 실행 순서는 다음과 같습니다.
| 순서 | 절 | 역할 |
|---|---|---|
| 1 | FROM | 대상 테이블 결정 |
| 2 | WHERE | 개별 행 필터링 |
| 3 | GROUP BY | 그룹화 |
| 4 | HAVING | 그룹 필터링 |
| 5 | SELECT | 출력 컬럼 선택 |
| 6 | ORDER BY | 정렬 |
| 7 | LIMIT / OFFSET | 페이지네이션 |
WHERE가 GROUP BY보다 먼저 실행되므로, WHERE 절에는 집계 결과가 아직 존재하지 않습니다. 집계 결과에 조건을 걸려면 반드시 HAVING을 사용해야 합니다.
아래 쿼리는 실행 순서를 모두 포함한 예시입니다. status != 'cancelled' 조건이 WHERE에 있어 취소된 주문은 그룹화 전에 제거되고, SUM(amount) > 1000000 조건은 HAVING에서 그룹 집계 후 적용됩니다.
SELECT
category,
COUNT(*) AS 주문건수,
SUM(amount) AS 총매출,
AVG(amount) AS 평균주문금액,
MAX(amount) AS 최대주문금액
FROM orders
WHERE status != 'cancelled'
AND created_at >= '2024-01-01'
GROUP BY category
HAVING SUM(amount) > 1000000
ORDER BY 총매출 DESC
LIMIT 10;
HAVING vs WHERE
WHERE와 HAVING의 역할은 명확히 다릅니다.
- WHERE: 개별 행을 필터링합니다. 집계함수를 사용할 수 없습니다.
- HAVING: 그룹화된 결과를 필터링합니다. 집계함수를 조건으로 쓸 수 있습니다.
성능 관점에서도 차이가 납니다. WHERE로 먼저 행을 줄이면 GROUP BY가 처리해야 할 데이터가 적어지므로, 가능한 조건은 WHERE에 두는 것이 유리합니다.
SELECT
DATE_TRUNC('month', created_at) AS month,
category,
COUNT(*) AS order_count,
SUM(amount) AS revenue
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY 1, 2
HAVING SUM(amount) >= 500000
ORDER BY 1, 4 DESC;
집계함수(COUNT, SUM, AVG 등)를 WHERE 절에서 사용하면 이 오류가 발생합니다. WHERE는 GROUP BY 이전에 실행되므로 아직 집계 결과가 존재하지 않기 때문입니다.
-- 오류 발생
SELECT user_id, COUNT(*) AS order_count
FROM orders
WHERE COUNT(*) >= 5
GROUP BY user_id;
해결 방법: 집계 결과에 조건을 걸 때는 HAVING을 사용하세요. 개별 행 조건(예: status = 'completed')은 WHERE에, 집계 결과 조건(예: COUNT(*) >= 5)은 HAVING에 두면 됩니다.
SELECT user_id, COUNT(*) AS order_count
FROM orders
WHERE status = 'completed'
GROUP BY user_id
HAVING COUNT(*) >= 5;
실전 분석 쿼리
월별 매출 트렌드, 카테고리별 성과, 상위 구매 고객 조회는 가장 자주 쓰이는 분석 쿼리 패턴입니다. 아래 예시들은 그대로 복사해서 테이블 이름만 바꿔 활용할 수 있습니다.
월별 트렌드는 날짜를 월 단위로 잘라 GROUP BY 기준으로 사용합니다.
SELECT
TO_CHAR(created_at, 'YYYY-MM') AS 월,
COUNT(*) AS 주문건수,
SUM(amount) AS 총매출,
ROUND(AVG(amount), 0) AS 평균주문금액
FROM orders
WHERE status = 'completed'
AND created_at >= NOW() - INTERVAL '12 months'
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
ORDER BY 1;
카테고리별 성과는 구매자 수와 건당 평균 매출을 함께 보면 단순 매출 크기와 고객 단가를 동시에 파악할 수 있습니다.
SELECT
category,
COUNT(*) AS 총주문,
COUNT(DISTINCT user_id) AS 구매자수,
SUM(amount) AS 매출,
ROUND(SUM(amount) / COUNT(*), 0) AS 건당평균매출
FROM orders
WHERE status != 'cancelled'
GROUP BY category
ORDER BY 매출 DESC;
상위 구매 고객 조회는 JOIN 이후에 GROUP BY를 적용하는 패턴입니다. HAVING으로 최소 주문 횟수 조건을 걸어 우량 고객만 추립니다.
SELECT
u.id AS user_id,
u.name AS 고객명,
COUNT(o.id) AS 주문횟수,
SUM(o.amount) AS 총구매금액,
MAX(o.created_at) AS 최근구매일
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.status = 'completed'
GROUP BY u.id, u.name
HAVING COUNT(o.id) >= 3
ORDER BY 총구매금액 DESC
LIMIT 10;
실무에서 경영진이 요청하는 "카테고리별 월 매출, 전월 대비 성장률, 구매 고객 수" 같은 리포트는 거의 대부분 GROUP BY + 집계함수 조합으로 만듭니다.
WHERE vs HAVING 구분을 명확히 알아야 필터 조건을 올바른 위치에 배치할 수 있고, 조건을 WHERE에 먼저 걸면 GROUP BY가 처리할 데이터 양이 줄어 쿼리 성능도 개선됩니다. 수백만 건의 주문 테이블에서 이 차이는 실행 시간 수 초에 영향을 줍니다.
ROLLUP과 CUBE — 소계/합계 자동 생성
지역별-카테고리별 매출 보고서를 만들어야 합니다. 각 지역 소계, 각 카테고리 소계, 전체 합계가 모두 필요합니다. 여러 쿼리를 UNION으로 합치거나 애플리케이션에서 계산하는 방법은 복잡합니다. ROLLUP 하나로 계층적 소계를 자동으로 생성할 수 있습니다.
ROLLUP — 계층적 소계와 총계
GROUP BY ROLLUP(col1, col2)는 일반 그룹별 집계 외에 col1 단위 소계와 전체 총계를 추가로 생성합니다. 소계 행에서 하위 컬럼은 NULL로 표시되므로 GROUPING() 함수로 소계 행임을 구분할 수 있습니다.
아래 쿼리는 연도-월 계층 구조로 소계를 생성합니다. 결과에서 월이 NULL인 행은 해당 연도의 소계이고, 연도와 월이 모두 NULL인 행이 전체 총계입니다.
SELECT
EXTRACT(YEAR FROM created_at) AS 연도,
EXTRACT(MONTH FROM created_at) AS 월,
SUM(amount) AS 매출
FROM orders
WHERE status = 'completed'
GROUP BY ROLLUP(
EXTRACT(YEAR FROM created_at),
EXTRACT(MONTH FROM created_at)
)
ORDER BY 1 NULLS LAST, 2 NULLS LAST;
GROUPING() 함수를 사용하면 소계 행의 NULL을 "전체"처럼 의미 있는 레이블로 바꿀 수 있습니다.
SELECT
CASE WHEN GROUPING(category) = 1 THEN '전체' ELSE category END AS 카테고리,
CASE WHEN GROUPING(status) = 1 THEN '전체' ELSE status END AS 상태,
COUNT(*) AS 건수,
SUM(amount) AS 금액
FROM orders
GROUP BY ROLLUP(category, status)
ORDER BY 1, 2;
CUBE — 모든 조합의 소계
CUBE(a, b)는 (a, b) 개별 그룹, a별 소계, b별 소계, 전체 총계 — 이렇게 네 가지 조합을 모두 생성합니다. 컬럼이 많아질수록 생성되는 조합이 기하급수적으로 늘어나므로 2~3개 컬럼에만 사용하는 것이 일반적입니다.
SELECT
category,
status,
COUNT(*) AS order_count
FROM orders
GROUP BY CUBE(category, status)
ORDER BY category NULLS LAST, status NULLS LAST;
FILTER — 조건부 집계
FILTER (WHERE ...) 절을 집계함수에 붙이면 한 번의 GROUP BY 스캔으로 여러 조건의 집계를 동시에 계산할 수 있습니다. 상태별로 별도의 서브쿼리를 작성하거나 CASE WHEN으로 분기하는 것보다 가독성이 좋고 성능도 유리합니다.
SELECT
category,
COUNT(*) AS 총주문수,
COUNT(*) FILTER (WHERE status = 'completed') AS 완료수,
COUNT(*) FILTER (WHERE status = 'cancelled') AS 취소수,
COUNT(*) FILTER (WHERE status = 'pending') AS 대기수,
SUM(amount) FILTER (WHERE status = 'completed') AS 완료매출,
ROUND(
100.0 * COUNT(*) FILTER (WHERE status = 'completed') / COUNT(*),
1
) AS 완료율
FROM orders
GROUP BY category
ORDER BY 총주문수 DESC;
복잡한 분석 쿼리 예시
사용자 코호트 분석은 가입 시점별로 그룹을 나누고, 각 코호트의 첫 구매 전환율을 측정하는 패턴입니다. CTE로 코호트와 첫 주문을 각각 계산한 뒤 LEFT JOIN으로 연결하면 전환 여부가 없는 사용자도 포함해 집계할 수 있습니다.
WITH user_cohorts AS (
SELECT
user_id,
DATE_TRUNC('month', created_at) AS cohort_month
FROM users
WHERE created_at >= '2024-01-01'
),
first_orders AS (
SELECT
user_id,
MIN(DATE_TRUNC('month', created_at)) AS first_order_month
FROM orders
WHERE status = 'completed'
GROUP BY user_id
)
SELECT
uc.cohort_month,
COUNT(DISTINCT uc.user_id) AS 가입자수,
COUNT(DISTINCT fo.user_id) AS 구매전환자수,
ROUND(
100.0 * COUNT(DISTINCT fo.user_id) / COUNT(DISTINCT uc.user_id),
1
) AS 전환율
FROM user_cohorts uc
LEFT JOIN first_orders fo
ON uc.user_id = fo.user_id
AND uc.cohort_month = fo.first_order_month
GROUP BY uc.cohort_month
ORDER BY uc.cohort_month;
다음 모듈에서는 NULL 값이 만들어내는 함정과 COALESCE, NULLIF로 안전하게 처리하는 방법을 다룹니다.