Devin.KR

집계와 GROUP BY 결과 예측 - COUNT SUM AVG 의 NULL 처리와 HAVING (SQLD 기본 8장)

개발자 조회 1

이 장에서 배우는 것

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, MINNULL 을 건너뛴다NULL
AVGNULL 을 건너뛰고, 분모도 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_idstaff_idsale_monthregionproductamount
11022026-07서울A100
21032026-07서울B50
31042026-07부산A70
41052026-07부산A30
51022026-08서울A120
61032026-08서울BNULL
71042026-08부산B80
81072026-08부산A40

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 다.

보너스 8칸 중 값이 있는 칸은 4개(0 포함)이고 합은 1000 이다. AVG(bonus) 는 4로 나눠 250.0, NULL 을 0 으로 바꾼 AVG 는 8로 나눠 125.0 이다(08-count-variants).

그림 · 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천 근처 조건"이라도 행을 거르는지 그룹을 거르는지에 따라 답이 완전히 다르다.

salary >= 4000 인 4행만 남긴 뒤 팀별로 묶으면 10번 팀 2명, 20·30번 팀 1명씩이다. HAVING COUNT(*) >= 2 를 통과하는 그룹은 10번 팀 하나다(08-where-having).

그림 · WHERE 로 행을 거른 뒤 묶고, HAVING 으로 그룹을 거른다 — salary >= 4000 인 4행만 남긴 뒤 팀별로 묶으면 10번 팀 2명, 20·30번 팀 1명씩이다. HAVING COUNT(*) >= 2 를 통과하는 그룹은 10번 팀 하나다(08-where-having).

표 · WHERE 와 HAVING 비교

WHEREHAVING
거르는 대상그룹
처리 시점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(실행)OracleSQL 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행이다.

연습 문제

  1. SELECT COUNT(*), COUNT(team_id), COUNT(DISTINCT bonus) FROM staff 의 결과를 쓰라.
  2. 팀별 최대 보너스를 팀 번호 순으로 구하면 몇 행이고, 각 값은 무엇인가?
  3. A 상품 판매를 지역별로 합해 합계가 150 을 넘는 지역만 구하라.
  4. 개발팀(10)에 대해 ROUND(AVG(bonus), 1)ROUND(SUM(bonus) * 1.0 / COUNT(*), 1) 을 구하라.
  5. 법무팀(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

다음 장에서는 여러 테이블을 잇는 조인의 결과 행 수를 예측한다.

참고 자료

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.