집계와 GROUP BY 결과 예측 - COUNT SUM AVG 의 NULL 처리와 HAVING (SQLD 기본 8장)
이 장에서 배우는 것
7장에서 행을 거르는 조건을 예측했다. 이 장은 여러 행을 한 행으로 줄이는 집계를 다룬다. GROUP BY 의 문법은 『SQL 실전 기초』 6단원에 있다. 여기서는 시험과 실무에서 값이 틀리게 나오는 지점, 즉 집계 함수가 NULL 을 건너뛰는 규칙에서 파생되는 결과를 예측한다.
COUNT(*),COUNT(컬럼),COUNT(DISTINCT 컬럼)의 차이와 SUM·AVG 의 분모를 확인한다.- WHERE 와 HAVING 의 경계, 빈 집합 집계가 0행인지 1행인지 예측한다.
SUM(a + b)와SUM(a) + SUM(b)가 달라지는 이유와, GROUP BY 에 없는 컬럼을 SELECT 할 때 제품마다 다른 동작을 정리한다.
핵심 개념
월말 보고서의 "평균 보너스"가 인사팀 계산과 달랐다. 인사팀은 전체 직원 수로 나눴고, 쿼리는 AVG(bonus) 였다. 보너스가 NULL 인 직원은 분모에서 빠진다. 둘 다 틀리지 않았다. 다만 무엇으로 나눌지 합의하지 않았을 뿐이다.
집계 함수와 NULL
| 함수 | NULL 처리 | 모든 행이 NULL 이거나 0행일 때 |
|---|---|---|
COUNT(*) | 행 자체를 센다. NULL 과 무관 | 0 |
COUNT(컬럼) | NULL 이 아닌 값만 센다 | 0 |
COUNT(DISTINCT 컬럼) | NULL 이 아닌 서로 다른 값의 개수 | 0 |
SUM, MAX, MIN | NULL 을 건너뛴다 | NULL |
AVG | NULL 을 건너뛰고, 분모도 NULL 이 아닌 행 수 | NULL |
GROUP BY 와 HAVING
GROUP BY 는 같은 값끼리 묶는다. NULL 도 하나의 그룹이 된다. 비교에서는 NULL = NULL 이 참이 아니지만, 그룹을 나눌 때는 NULL 끼리 한 그룹으로 모은다. WHERE 는 그룹을 만들기 전에 행을 거르고, HAVING 은 그룹을 만든 뒤에 그룹을 거른다. 그래서 집계 함수 조건은 HAVING 에만 쓸 수 있다.
GROUP BY 가 있을 때 SELECT 할 수 있는 것
표준 SQL 에서 GROUP BY 가 있는 쿼리의 SELECT 목록에는 GROUP BY 에 쓴 식, 집계 함수, 상수만 올 수 있다. 그룹 하나에 값이 여러 개인 컬럼을 그냥 쓰면 어느 값을 보여 줄지 정할 수 없기 때문이다. 이 규칙을 제품이 어떻게 지키는지는 뒤에서 본다.
예제 스키마
7장과 같은 스키마다(전체 정의는 7장에 있다). 이 장은 직원(staff)과 판매(sale)를 주로 쓴다. 판매 6번은 금액이 아직 확정되지 않아 NULL 이다.
| sale_id | staff_id | sale_month | region | product | amount |
|---|---|---|---|---|---|
| 1 | 102 | 2026-07 | 서울 | A | 100 |
| 2 | 103 | 2026-07 | 서울 | B | 50 |
| 3 | 104 | 2026-07 | 부산 | A | 70 |
| 4 | 105 | 2026-07 | 부산 | A | 30 |
| 5 | 102 | 2026-08 | 서울 | A | 120 |
| 6 | 103 | 2026-08 | 서울 | B | NULL |
| 7 | 104 | 2026-08 | 부산 | B | 80 |
| 8 | 107 | 2026-08 | 부산 | A | 40 |
SQL과 실행 결과
COUNT 세 가지와 AVG 의 분모
SELECT COUNT(*) AS all_rows,
COUNT(bonus) AS bonus_rows,
COUNT(team_id) AS team_rows,
COUNT(DISTINCT team_id) AS teams,
SUM(bonus) AS bonus_sum,
AVG(bonus) AS bonus_avg,
AVG(COALESCE(bonus, 0)) AS bonus_avg_all
FROM staff;
실행 결과:
all_rows bonus_rows team_rows teams bonus_sum bonus_avg bonus_avg_all
-------- ---------- --------- ----- --------- --------- -------------
8 4 7 3 1000 250.0 125.0
8명 중 보너스 값이 있는 사람은 4명(300, 500, 0, 200)이다. 0 은 NULL 이 아니므로 센다. AVG(bonus) 는 1,000 ÷ 4 = 250 이고, NULL 을 0 으로 바꾼 평균은 1,000 ÷ 8 = 125 다.
그림 · COUNT 와 AVG 의 분모 — 보너스 8칸 중 값이 있는 칸은 4개(0 포함)이고 합은 1000 이다. AVG(bonus) 는 4로 나눠 250.0, NULL 을 0 으로 바꾼 AVG 는 8로 나눠 125.0 이다(08-count-variants).
NULL 도 하나의 그룹
SELECT team_id, COUNT(*) AS n, SUM(salary) AS total, ROUND(AVG(salary), 1) AS avg_salary
FROM staff
GROUP BY team_id
ORDER BY team_id;
실행 결과:
team_id n total avg_salary
------- - ----- ----------
NULL 1 3100 3100.0
10 3 13400 4466.7
20 2 7900 3950.0
30 2 8700 4350.0
팀이 없는 강예린이 team_id NULL 그룹 하나를 이룬다. 그룹 수는 4개다. COUNT(DISTINCT team_id) 가 3 인 것과 비교한다.
WHERE 로 거른 뒤 묶기와, 묶은 뒤 거르기
SELECT team_id, COUNT(*) AS n
FROM staff
WHERE salary >= 4000
GROUP BY team_id
HAVING COUNT(*) >= 2;
SELECT team_id, MIN(salary) AS min_salary
FROM staff
GROUP BY team_id
HAVING MIN(salary) >= 3800
ORDER BY team_id;
실행 결과:
team_id n
------- -
10 2
team_id min_salary
------- ----------
10 3900
30 3900
첫 쿼리는 급여 4,000 이상인 사람만 남긴 뒤 팀별로 셌다. 개발팀만 2명이다. 두 번째 쿼리는 모든 사람으로 팀을 만든 뒤 "팀 최저 급여가 3,800 이상인 팀"을 골랐다. 같은 "4천 근처 조건"이라도 행을 거르는지 그룹을 거르는지에 따라 답이 완전히 다르다.
그림 · WHERE 로 행을 거른 뒤 묶고, HAVING 으로 그룹을 거른다 — salary >= 4000 인 4행만 남긴 뒤 팀별로 묶으면 10번 팀 2명, 20·30번 팀 1명씩이다. HAVING COUNT(*) >= 2 를 통과하는 그룹은 10번 팀 하나다(08-where-having).
표 · WHERE 와 HAVING 비교
| WHERE | HAVING | |
|---|---|---|
| 거르는 대상 | 행 | 그룹 |
| 처리 시점 | GROUP BY 전 | GROUP BY 뒤 |
| 집계 함수 조건 | 쓸 수 없음 | 쓸 수 있음 |
| 08-where-having 실제 결과 | salary >= 4000 으로 행을 거른 뒤 묶음 | COUNT(*) >= 2 를 통과한 팀 10 (2명) 하나 |
빈 집합의 집계: 1행인가 0행인가
SELECT COUNT(*) AS n, SUM(salary) AS total, MAX(salary) AS top
FROM staff WHERE team_id = 99;
SELECT COUNT(*) AS grouped_rows
FROM (SELECT team_id, COUNT(*) FROM staff WHERE team_id = 99 GROUP BY team_id);
실행 결과:
n total top
- ----- ----
0 NULL NULL
grouped_rows
------------
0
GROUP BY 없는 집계는 대상 행이 0개여도 항상 1행을 돌려준다. COUNT 는 0, 나머지는 NULL 이다. GROUP BY 가 있으면 그룹이 하나도 없으므로 0행이다.
SUM(a + b) 와 SUM(a) + SUM(b)
SELECT SUM(salary + bonus) AS sum_of_plus,
SUM(salary) + SUM(bonus) AS plus_of_sums,
SUM(salary + COALESCE(bonus, 0)) AS sum_safe
FROM staff;
실행 결과:
sum_of_plus plus_of_sums sum_safe
----------- ------------ --------
16300 34100 34100
salary + bonus 는 보너스가 NULL 인 행에서 NULL 이 되고, SUM 이 그 행을 통째로 건너뛴다. 그래서 급여 합계에서 4명분이 빠졌다. SUM(salary) + SUM(bonus) 는 각 열을 따로 더한 뒤 합쳐 NULL 영향을 받지 않는다(단, 한쪽 합계 전체가 NULL 이면 결과도 NULL).
NULL 금액이 섞인 판매 집계
SELECT product, COUNT(*) AS n, COUNT(amount) AS n_amount,
SUM(amount) AS total, AVG(amount) AS avg_amount
FROM sale GROUP BY product ORDER BY product;
실행 결과:
product n n_amount total avg_amount
------- - -------- ----- ----------
A 5 5 360 72.0
B 3 2 130 65.0
B 상품은 3건이지만 금액이 있는 것은 2건이다. 평균은 130 ÷ 2 = 65 다.
GROUP BY 에 없는 컬럼
SELECT team_id, name, MAX(salary) AS top
FROM staff
GROUP BY team_id
ORDER BY team_id;
실행 결과:
team_id name top
------- ------ ----
NULL 강예린 3100
10 한지수 5200
20 이도현 4300
30 정우진 4800
SQLite 는 오류 없이 실행했다. MAX() 나 MIN() 이 하나만 있을 때는 그 최댓값을 가진 행의 다른 컬럼을 보여 주는 SQLite 고유의 규칙이 있기 때문이다. Oracle 과 SQL Server 에서는 이 쿼리가 오류다. 시험은 "오류가 나는 SQL 을 고르라"는 형태로 이 규칙을 묻는다.
GROUP BY 없는 HAVING, 집계로 정렬
SELECT COUNT(*) AS n FROM staff HAVING COUNT(*) > 5;
SELECT COUNT(*) AS rows_returned FROM (SELECT COUNT(*) FROM staff HAVING COUNT(*) > 10);
실행 결과:
n
-
8
rows_returned
-------------
0
GROUP BY 없이 HAVING 을 쓰면 테이블 전체가 한 그룹이다. 조건을 만족하면 1행, 아니면 0행이다.
SELECT team_id, SUM(salary) AS total
FROM staff
WHERE team_id IS NOT NULL
GROUP BY team_id
ORDER BY SUM(salary) DESC;
실행 결과:
team_id total
------- -----
10 13400
30 8700
20 7900
표준과 구현의 차이
아래 Oracle·SQL Server 동작은 비교 설명이며 실행하지 않았다.
| 항목 | SQLite(실행) | Oracle | SQL Server |
|---|---|---|---|
| GROUP BY 에 없는 컬럼 SELECT | 허용(임의 행 또는 MAX·MIN 행의 값) | 오류 | 오류 |
| 정수 컬럼의 AVG | 실수(250.0) | 실수 | 정수 컬럼이면 정수로 잘림 |
| GROUP BY 에 SELECT 별칭 사용 | 허용 | 23ai 이전 버전은 오류 | 오류 |
집계 함수 중첩 MAX(AVG(x)) | 오류 | GROUP BY 와 함께면 허용 | 오류 |
표의 SQLite 두 줄(별칭으로 GROUP BY, 집계 함수 중첩)은 실제로 실행해 확인했다.
SELECT COALESCE(team_id, 0) AS t, COUNT(*) AS n
FROM staff GROUP BY t ORDER BY t;
실행 결과:
t n
-- -
0 1
10 3
20 2
30 2
SELECT MAX(AVG(salary)) FROM staff GROUP BY team_id;
실행 결과:
Parse error near line 1: misuse of aggregate function AVG()
SELECT MAX(AVG(salary)) FROM staff GROUP BY team_id;
^--- error here
그룹별 평균 중 최댓값이 필요하면 SQLite 에서는 인라인 뷰로 한 번 감싼다: SELECT MAX(a) FROM (SELECT AVG(salary) AS a FROM staff GROUP BY team_id). 인라인 뷰는 10장에서 다룬다.
[구현 차이] SQL Server 에서 정수 컬럼에
AVG를 쓰면 소수점이 버려진다. 평균을 정확히 보려면AVG(salary * 1.0)처럼 실수로 바꾼 뒤 집계한다. 이 책의 연습 문제가* 1.0을 붙이는 이유다.
시험에서 헷갈리는 지점
판단 1. "COUNT(bonus) 는 보너스가 0 인 행을 세지 않는다"
틀렸다. 0 은 값이다. COUNT(컬럼)이 건너뛰는 것은 NULL 뿐이다.
판단 2. "WHERE 절에 집계 함수를 쓸 수 없으므로 SUM(amount) > 100 은 HAVING 에 써야 한다"
맞다. WHERE 가 처리될 때는 아직 그룹이 없다. 다만 집계와 무관한 조건(product = 'A')은 WHERE 에 두는 편이 그룹을 만들 행 자체를 줄여 효율적이다.
판단 3. "조건에 맞는 행이 없으면 SELECT COUNT(*) FROM ... WHERE ... 는 결과가 0행이다"
틀렸다. GROUP BY 가 없으면 값 0 이 담긴 1행이 나온다. GROUP BY 가 있을 때만 0행이다.
연습 문제
SELECT COUNT(*), COUNT(team_id), COUNT(DISTINCT bonus) FROM staff의 결과를 쓰라.- 팀별 최대 보너스를 팀 번호 순으로 구하면 몇 행이고, 각 값은 무엇인가?
- A 상품 판매를 지역별로 합해 합계가 150 을 넘는 지역만 구하라.
- 개발팀(10)에 대해
ROUND(AVG(bonus), 1)과ROUND(SUM(bonus) * 1.0 / COUNT(*), 1)을 구하라. - 법무팀(40) 직원을 대상으로
COUNT(*)와MAX(salary)를 구하면 몇 행, 어떤 값이 나오는가?
정답과 해설
1. 8, 7, 4. 팀이 NULL 인 1명이 빠져 7이고, 보너스의 서로 다른 값은 300, 500, 0, 200 의 넷이다.
SELECT COUNT(*) AS a, COUNT(team_id) AS b, COUNT(DISTINCT bonus) AS c FROM staff;
실행 결과:
a b c
- - -
8 7 4
2. 4행. NULL 팀 200, 10팀 300, 20팀 500, 30팀 NULL. 30팀은 보너스가 모두 NULL 이라 MAX 도 NULL 이다. NULL 그룹이 맨 앞에 온 것은 SQLite 의 NULL 정렬 규칙 때문이다.
SELECT team_id, MAX(bonus) AS max_bonus FROM staff GROUP BY team_id ORDER BY team_id;
실행 결과:
team_id max_bonus
------- ---------
NULL 200
10 300
20 500
30 NULL
3. 서울 220 한 행. 부산은 70 + 30 + 40 = 140 이라 빠진다.
SELECT region, SUM(amount) AS total
FROM sale WHERE product = 'A'
GROUP BY region HAVING SUM(amount) > 150;
실행 결과:
region total
------ -----
서울 220
4. 300.0 과 100.0. 개발팀 보너스는 NULL, 300, NULL 이다. AVG 는 값이 있는 1명으로 나누고, 두 번째 식은 3명으로 나눈다.
SELECT ROUND(AVG(bonus), 1) AS a, ROUND(SUM(bonus) * 1.0 / COUNT(*), 1) AS b
FROM staff WHERE team_id = 10;
실행 결과:
a b
----- -----
300.0 100.0
5. 1행, 0 과 NULL. GROUP BY 가 없으므로 대상이 0명이어도 1행이 나온다.
SELECT COUNT(*) AS n, MAX(salary) AS top FROM staff WHERE team_id = 40;
실행 결과:
n top
- ----
0 NULL
다음 장에서는 여러 테이블을 잇는 조인의 결과 행 수를 예측한다.