행 필터·그룹 생성·집계·HAVING의 순서와 확장 그룹 함수, GROUPING_ID, NULL·빈 입력 처리 차이를 이해합니다.
핵심 요약
행을 그룹으로 묶는 기준, NULL 그룹, 집계 함수와 HAVING의 역할을 구분한다.
WHERE와 HAVING의 논리적 위치는 SQL 공통 원리다. ROLLUP·
학습 목표
- 집계 전 행 필터와 집계 후 그룹 필터를 구분한다.
- GROUP BY가 있는 SELECT 목록의 유효성을 판단한다.
본문
개념 지도
원본 행 필터→그룹 생성→집계→HAVING 그룹 필터→결과 출력핵심 내용
집계 함수는 여러 행을 하나의 값으로 요약한다. SUM, AVG, MIN, MAX, COUNT가 대표적이며 대부분 NULL을 제외한다.
SELECT deptno,
COUNT(*) AS emp_cnt,
AVG(sal) AS avg_sal
FROM emp
WHERE status = 'ACTIVE'
GROUP BY deptno
HAVING COUNT(*) >= 5;
WHERE는 그룹을 만들기 전에 행을 줄이고, HAVING은 그룹 결과를 거른다. SELECT 목록의 일반 컬럼은 GROUP BY 기준에 포함되어야 한다. GROUP BY의 NULL 값들은 하나의 그룹으로 묶인다.
조건부 집계는 CASE와 집계 함수를 결합한다.
SUM(CASE WHEN status = 'DONE' THEN amount ELSE 0 END)
COUNT(*), COUNT(1)은 행 수를 세는 목적에서 같은 결과를 내며, 특정 컬럼의 NULL 제외 건수가 필요할 때 COUNT(col)을 쓴다.
시험 포인트
문항 풀이 보강: 확장 그룹 함수
ROLLUP· CUBE· GROUPING SETS
ROLLUP(A, B)→(A,B), (A), ()CUBE(A, B)→(A,B), (A), (B), ()GROUPING SETS((A,B), (A), ())→명시한 상세·소계·총계 그룹만()는 전체 총계를 뜻한다. (A,B)는 두 컬럼을 하나의 그룹 조합으로 묶는다. GROUPING SETS((A,B))는 일반 GROUP BY A,B와 같은 상세 그룹만 만들며 소계나 총계를 자동 추가하지 않는다.
SELECT region_id,
TO_CHAR(use_dt, 'YYYY.MM') use_month,
SUM(amount),
GROUPING(region_id) g_region
FROM usage
GROUP BY ROLLUP(region_id, TO_CHAR(use_dt, 'YYYY.MM'));
소계 행에서 그룹 대상 컬럼은 NULL로 표시된다. 원본 데이터의 실제 NULL과 소계 때문에 생긴 NULL을 구분할 때 GROUPING(col)을 쓴다. 집계된 행이면 1, 일반 상세 그룹이면 0이다.
중복 그룹 세트
GROUPING SETS(A, (B,C), (B,C))처럼 같은 그룹 세트를 두 번 쓰면 같은 집계 행도 두 번 생성될 수 있다. 원하는 집합을 한 번씩 정확히 나열한다.
GROUPING과 GROUPING_ID
ROLLUP·
SELECT deptno,
job,
SUM(sal) AS total_sal,
GROUPING(deptno) AS g_dept,
GROUPING(job) AS g_job,
GROUPING_ID(deptno, job) AS gid
FROM emp
GROUP BY ROLLUP(deptno, job);
GROUPING(expr) = 1: 해당 컬럼의 NULL은 소계·총계 행을 만들며 생긴 것GROUPING(expr) = 0: 상세 그룹 값이며 실제 NULL일 수도 있음GROUPING_ID: 여러 GROUPING 결과를 비트값으로 묶어 소계 수준을 식별
CASE
WHEN GROUPING(deptno) = 1 THEN '전체 부서'
WHEN deptno IS NULL THEN '부서 미지정'
ELSE TO_CHAR(deptno)
END
NVL(deptno, '합계')만 사용하면 원본 NULL 그룹과 합계 행을 혼동할 수 있습니다.
입력 행이 0건일 때
GROUP BY가 없는 집계 Query는 입력이 0건이어도 한 행을 반환할 수 있습니다.
SELECT COUNT(*), SUM(sal)
FROM emp
WHERE 1 = 0;
개념 결과는 COUNT(*) = 0, SUM(sal) = NULL입니다. 반면 GROUP BY deptno가 있으면 생성할 그룹이 없어 0행을 반환합니다.
문제에 적용하는 순서
- 최종 결과에서 필요한 상세·소계·총계 조합을 집합 표기로 적는다.
- 그 조합이 계층이면 ROLLUP, 모든 조합이면 CUBE, 임의 조합이면 GROUPING SETS를 고른다.
- 표시용 NULL은 GROUPING 함수로 판별한다.
JOIN 뒤 횟수 집계와 HAVING 표현
구매 이력이 있는 고객 중 구매가 3회 이상인 고객은 먼저 구매정보와 INNER JOIN하고 고객 식별자별로 묶은 뒤 건수를 검사한다. customer.customer_id는 고객을 구분하는 기본키이고, purchase의 한 행은 purchase_id로 식별되는 구매 1회를 뜻한다.
SELECT c.customer_id, c.name, c.grade
FROM customer c
INNER JOIN purchase p
ON p.customer_id = c.customer_id
GROUP BY c.customer_id, c.name, c.grade
HAVING COUNT(p.purchase_id) >= 3;
결과 한 행의 단위는 고객 1명이다. 이름과 등급이 같아도 고객 식별자가 다르면 별도 그룹으로 센다. c.name, c.grade만 묶으면 동명이인의 구매가 합쳐질 수 있다. SELECT에도 customer_id를 표시해 같은 이름·등급의 고객을 구분한다.
구매 “횟수”는 식별자의 합계가 아니라 COUNT로 센다. HAVING COUNT(p.purchase_id)는 그 값을 SELECT 목록에 출력하지 않아도 사용할 수 있다. 논리적으로 HAVING이 그룹을 거른 뒤 SELECT 결과를 만들기 때문이다.
동명이인 반례
다음은 고객과 구매정보를 조인한 구매 행이다. 고객 1과 고객 2는 이름·등급이 같고 각각 2회 구매했다.
| customer_id | name | grade | purchase_id |
|---|---|---|---|
| 1 | 김하늘 | GOLD | 101 |
| 1 | 김하늘 | GOLD | 102 |
| 2 | 김하늘 | GOLD | 103 |
| 2 | 김하늘 | GOLD | 104 |
이름·등급만 묶으면 두 고객의 4회가 합쳐져 조건을 통과한다. 위 SQL은 고객별로 2회씩 세므로 결과는 0행이다.
고객 1의 구매 행 (customer_id=1, purchase_id=105)를 한 건 추가하면 고객 1은 3회, 고객 2는 2회가 된다. 같은 SQL의 결과에는 고객 1만 남는다.
| customer_id | name | grade |
|---|---|---|
| 1 | 김하늘 | GOLD |
자주 틀리는 부분
- HAVING을 GROUP BY가 있을 때만 문법적으로 쓸 수 있다고 단정하지 않는다. 다만 의미상 그룹 필터다.
- AVG는 NULL을 0으로 바꿔 평균에 포함하지 않는다.
- SELECT에 그룹 기준이 아닌 일반 컬럼을 임의로 추가할 수 없다.
복습 문제
답을 생각한 뒤 정답과 근거를 펼쳐 보세요.
부서별 인원수가 5명 이상인 부서를 거르는 절은?
정답과 근거
GROUP BY deptno HAVING COUNT(*) >= 5처럼 HAVING으로 그룹을 거릅니다.
NULL 급여를 0으로 포함한 평균과 제외한 평균은 같은가?
정답과 근거
같지 않습니다. AVG(salary)는 NULL을 제외하지만 AVG(NVL(salary,0))은 NULL을 0으로 포함합니다.
WHERE salary IS NOT NULL을 집계 전에 적용하는 것과 HAVING COUNT(salary)>0을 그룹 뒤에 적용하는 것은 빈 그룹에 어떤 차이를 내는가?
정답과 근거
WHERE는 NULL 급여 행을 그룹 생성 전에 없앱니다. HAVING은 먼저 그룹을 만든 뒤 COUNT(salary)=0인 그룹을 제거하므로, 외부 조인이나 확장 그룹에서는 중간 결과가 달라질 수 있습니다.