C-4: 집계와 그룹 — 여러 행을 한 값으로 모으고, 그룹으로 나눠 묻기
목차 29
안녕하세요, 홍순구 튜터입니다. 지난 시간에 우리는 NULL을 길들이고 조건에 따라 값을 갈래로 나눴어요. NVL로 빈칸을 채우고, CASE로 등급을 나눴죠. 그런데 그동안 우리가 한 일을 가만히 보면, 전부 한 행씩 다루는 일이었어요. 한 회원의 자기소개를 채우고, 한 게시물의 해시태그를 세고요.
오늘은 시야를 확 넓혀요. 한 행이 아니라 여러 행을 한꺼번에 모아 하나의 값으로 요약해요. "회원이 전부 몇 명이지?", "게시물 해시태그를 다 더하면 몇 개지?", "한 회원이 평균 몇 개씩 글을 썼지?" 같은 질문이에요. 이런 걸 집계라고 불러요. 그리고 여기서 지난 시간에 잡아 둔 NULL이 다시 한 번 중요해져요. 시험에 단골로 나오는 함정이 기다리고 있거든요.
오늘의 여정
① 집계함수 첫걸음 — 여러 행을 한 값으로
② COUNT의 세 얼굴 — *, 컬럼, DISTINCT (NULL 함정)
③ SUM·AVG — 합계와 평균 (평균의 NULL 함정)
④ MAX·MIN — 가장 큰 값과 가장 작은 값
⑤ GROUP BY — 그룹으로 나눠 집계
⑥ GROUP BY의 규칙 — 가공식 그룹화와 SELECT 절
⑦ HAVING — 그룹에 조건 걸기
⑧ 집계의 실행 순서와 NULL 총정리
💡 오늘 수업의 핵심 — "여러 행을 한 값으로 요약하고(COUNT·SUM·AVG·MAX·MIN), 그룹으로 나눠(GROUP BY) 그룹에 조건을 건다(HAVING)"
🎯 학습 목표
- 집계함수로 여러 행을 하나의 값으로 요약하고,
COUNT(*)와COUNT(컬럼)이 NULL을 다르게 센다는 점을 이해한다. (SQLD 2과목 'SQL 기본 — 집계 함수') GROUP BY로 행을 그룹으로 나눠 집계하고,HAVING으로 그룹에 조건을 건다. (SQLD 2과목 'GROUP BY / HAVING')- 집계함수가 NULL을 어떻게 처리하는지, 그리고 집계 구문이 실제로 어떤 순서로 처리되는지 이해한다.
Step 1: "집계함수 첫걸음 — 여러 행을 한 값으로"
함수 이름을 외우기 전에 집계가 뭔지부터 잡고 갈게요. 지난 시간까지 우리가 쓴 함수들을 단일행 함수라고 불러요. 한 행이 들어가면 한 값이 나오는 함수예요. 회원이 12명이면 결과도 12줄이 나왔죠.
집계함수는 정반대예요. 여러 행을 통째로 모아서 단 하나의 값으로 요약해요. 회원이 12명이어도 "전부 몇 명인가?"의 답은 12라는 한 줄이에요. 반 학생 30명의 시험지를 한 장씩 채점하는 게 단일행이라면, 반 전체 평균 점수 하나를 내는 게 집계예요.
단일행 함수(지난 시간): 한 행 → 한 값
회원 한 명마다 결과 한 줄 → 회원 12명이면 12줄
집계함수(오늘): 여러 행 → 한 값
회원 12명을 한 덩어리로 모아 → COUNT(*) = 12 (결과 한 줄)
가장 기본 집계함수인 COUNT부터 만나요. COUNT(*)는 "행이 전부 몇 개인가?"를 세요. 회원 표의 행을 세 볼게요.
-- sql/queries/C4_aggregate_group.sql
SELECT COUNT(*) AS 회원수
FROM member;
| 회원수 |
|---|
| 12 |
회원 12명이 한 줄로 요약됐어요. 결과가 딱 한 줄이라는 게 집계의 특징이에요. 별표(*)는 "모든 행"을 뜻해요. 한 명 한 명을 꺼내 보여 주는 게 아니라, 몇 명인지만 세서 한 숫자로 돌려주는 거예요.
💡 한 줄 정리
집계함수는 여러 행을 모아 하나의 값으로 요약하며, COUNT(*)는 표 안의 행이 전부 몇 개인지 센다.
🙋 학생 질문 — "튜터님, 집계함수를 쓰면 왜 결과가 한 줄만 나와요?"
집계는 '여럿을 하나로 모으는' 일이라서 그래요. COUNT(*)는 12개의 행을 받아 "12"라는 한 값으로 줄여요. 12개의 행을 그대로 보여 주는 게 목적이 아니라, 그 행들을 요약한 결과 하나가 목적이거든요. 그래서 집계함수만 단독으로 쓰면 결과는 늘 한 줄이에요. 만약 "회원별로 따로따로 세고 싶다"면 그룹으로 나눠야 하는데, 그건 오늘 뒤쪽 GROUP BY에서 배워요. 거기서는 그룹 수만큼 줄이 나와요.
Step 2: "COUNT의 세 얼굴 — *, 컬럼, DISTINCT"
COUNT는 쓰는 방법이 세 가지인데, 결과가 미묘하게 달라요. 여기에 지난 시간에 잡아 둔 NULL이 등장해요. 회원 표로 두 가지를 한 번에 세 볼게요.
-- sql/queries/C4_aggregate_group.sql
SELECT COUNT(*) AS 전체행,
COUNT(bio) AS 자기소개_있는행
FROM member;
| 전체행 | 자기소개_있는행 |
|---|---|
| 12 | 8 |
같은 표를 셌는데 12와 8로 갈렸어요. COUNT(*)는 행 자체를 세니까 12, COUNT(bio)는 bio에 값이 있는 행만 세니까 8이에요. 지난 시간에 자기소개가 빈(NULL) 회원이 4명(3·5·8·10번)이었죠. COUNT(컬럼)은 그 NULL인 4명을 건너뛰어요. 그래서 12에서 4를 뺀 8이 나온 거예요.
이게 지난 시간 마지막에 예고한 그 함정이에요. COUNT(*)는 NULL을 포함한 모든 행을 세고, COUNT(컬럼)은 그 컬럼이 NULL인 행을 뺀다는 차이요.
⚠️ 함정 —
COUNT(*)와COUNT(컬럼)은 NULL 때문에 결과가 달라져요.COUNT(*)는 전체 행,COUNT(컬럼)은 그 컬럼이 NULL이 아닌 행만 세요. SQLD에서 NULL이 섞인 컬럼을 주고 "COUNT(컬럼)의 결과는?"을 묻는 함정이 단골이에요. 전체 행 수로 착각하면 틀려요. ★빈출
세 번째 얼굴은 COUNT(DISTINCT 컬럼)이에요. 중복을 뺀 개수를 세요. 지난 모듈에서 게시물을 쓴 글쓴이가 몇 명인지 DISTINCT로 셌던 거 기억하시죠. 그걸 COUNT와 합치면 한 번에 나와요.
SELECT COUNT(*) AS 게시물수,
COUNT(DISTINCT member_id) AS 글쓴이수
FROM post;
| 게시물수 | 글쓴이수 |
|---|---|
| 50 | 9 |
게시물은 50개지만, 그걸 쓴 회원은 9명이에요. 한 회원이 여러 글을 썼으니 member_id에 중복이 있고, DISTINCT가 그 중복을 걷어내서 9가 나왔어요. 회원은 12명인데 글쓴이가 9명이라는 건, 글을 한 개도 안 쓴 회원이 3명 있다는 뜻이에요. 이 3명은 뒤에서 다시 만나요.
💡 한 줄 정리
COUNT(*)는 NULL 포함 전체 행을, COUNT(컬럼)은 그 컬럼이 NULL이 아닌 행을, COUNT(DISTINCT 컬럼)은 중복을 뺀 값의 개수를 센다.
🙋 학생 질문 — "튜터님, COUNT(1)이나 COUNT('a')는 COUNT(*)랑 같나요?"
네, COUNT(*)와 COUNT(1)은 결과가 완전히 같아요. 둘 다 행 자체를 세거든요. COUNT(1)은 "모든 행에 1이라는 상수를 놓고 그걸 센다"는 뜻이라, 결국 행 수와 같아요. 우리 회원 표에서 둘 다 12로 똑같이 나와요. 차이가 생기는 건 COUNT(컬럼)처럼 NULL이 들어갈 수 있는 컬럼을 넣었을 때뿐이에요. 상수(1, 'a')는 NULL이 될 일이 없으니 전체 행을 세는 COUNT(*)와 같아지는 거예요. 가끔 "COUNT(1)이 더 빠르다"는 말이 도는데, 요즘 데이터베이스에서는 둘의 속도 차이가 없어요. 취향껏 쓰면 돼요.
Step 3: "SUM·AVG — 합계와 평균"
이제 숫자를 더하고 평균 내는 집계예요. SUM은 합계, AVG는 평균이에요(average의 줄임말). 더할 숫자가 필요한데, 마침 지난 시간에 쓴 게 있어요. REGEXP_COUNT(caption, '#')로 글마다 해시태그가 몇 개인지 셀 수 있었죠(0개부터 3개까지). 이 해시태그 개수를 더하고 평균 내 볼게요.
-- sql/queries/C4_aggregate_group.sql
SELECT SUM(REGEXP_COUNT(caption, '#')) AS 해시태그_합계,
AVG(REGEXP_COUNT(caption, '#')) AS 글당_평균
FROM post;
| 해시태그_합계 | 글당_평균 |
|---|---|
| 42 | 0.84 |
게시물 50개에 달린 해시태그를 다 더하면 42개고, 한 글당 평균 0.84개를 달았어요. 평균이 1보다 작은 건, 해시태그를 아예 안 단 글이 꽤 많아서예요.
그런데 여기서 평균에 숨은 함정이 하나 있어요. AVG는 NULL을 어떻게 다룰까요? 지난 시간에 배운 NULLIF로 작은 실험을 해볼게요. NULLIF(개수, 0)은 해시태그 개수가 0이면 NULL로 바꿔요. 그러면 해시태그 없는 글은 NULL이 되겠죠. 이걸 평균 내면 어떻게 될까요?
SELECT AVG(REGEXP_COUNT(caption, '#')) AS 전체_평균,
AVG(NULLIF(REGEXP_COUNT(caption, '#'), 0)) AS 태그있는글_평균
FROM post;
| 전체_평균 | 태그있는글_평균 |
|---|---|
| 0.84 | 1.5 |
같은 해시태그를 더했는데 평균이 0.84에서 1.5로 확 뛰었어요. 왜일까요? AVG는 NULL인 값을 평균 계산에서 아예 빼버리거든요. 합계는 둘 다 42로 같아요. 다른 건 나누는 수예요. 전체 평균은 42를 50으로 나눠 0.84(42 ÷ 50), 태그있는글 평균은 NULL이 된 글을 뺀 28로 나눠 1.5(42 ÷ 28)예요. 해시태그 없는 글 22개가 NULL이 되면서 분모에서 빠진 거예요.
⚠️ 함정 —
AVG의 분모는 '전체 행 수'가 아니라 'NULL이 아닌 값의 개수'예요. 즉AVG(컬럼)은SUM(컬럼) ÷ COUNT(*)가 아니라SUM(컬럼) ÷ COUNT(컬럼)이에요. SQLD에서 NULL이 섞인 컬럼의 평균을 묻고, 전체 행 수로 나눈 값을 오답으로 깔아 두는 함정이 자주 나와요. ★빈출
💡 한 줄 정리
SUM은 합계, AVG는 평균을 내며, 둘 다 NULL을 건너뛴다. 특히 AVG는 NULL이 아닌 값의 개수로 나누므로 전체 행 수로 나눈 값과 다르다.
🙋 학생 질문 — "튜터님, 합계는 똑같이 42인데 평균만 달라지는 게 신기해요. 왜 SUM은 안 변해요?"
SUM도 NULL을 건너뛰는 건 AVG와 똑같아요. 다만 더하기에서는 NULL을 빼도 합이 안 변해요. 해시태그 0개인 글을 더하든(+0), NULL로 바꿔 빼든, 합계에 보태지는 양이 0이라 결과가 42로 그대로예요. 그런데 평균은 다르죠. 0을 더할 땐 분모(나누는 수)에 그 글이 포함되지만, NULL로 바꾸면 분모에서 통째로 빠져요. 분자는 그대로인데 분모만 50에서 28로 줄어드니 평균이 커지는 거예요. "0으로 두느냐, NULL로 두느냐"가 평균을 이렇게 바꿔요. 지난 시간에 '안 본 시험을 0점으로 적으면 평균이 깎인다'고 했던 이야기가 바로 이거예요.
Step 4: "MAX·MIN — 가장 큰 값과 가장 작은 값"
MAX는 최댓값, MIN은 최솟값이에요. 가장 큰 값과 가장 작은 값 하나씩을 뽑아요. 해시태그 개수로 가장 많이 단 글과 가장 적게 단 글의 개수를 볼게요.
-- sql/queries/C4_aggregate_group.sql
SELECT MAX(REGEXP_COUNT(caption, '#')) AS 최다_해시태그,
MIN(REGEXP_COUNT(caption, '#')) AS 최소_해시태그
FROM post;
| 최다_해시태그 | 최소_해시태그 |
|---|---|
| 3 | 0 |
가장 많이 단 글은 3개, 가장 적게 단 글은 0개예요. 한 글에 해시태그가 최대 3개까지 달렸다는 걸 한눈에 알 수 있죠.
그런데 MAX·MIN은 숫자에만 쓰는 게 아니에요. 날짜에도, 문자에도 동작해요. 날짜에 쓰면 가장 최근과 가장 오래된 값이, 문자에 쓰면 정렬상 가장 뒤와 가장 앞의 값이 나와요. 게시물 작성일로 가장 최근 글과 가장 오래된 글의 날짜를 뽑아 볼게요.
SELECT TO_CHAR(MAX(created_at), 'YYYY-MM-DD') AS 가장_최근,
TO_CHAR(MIN(created_at), 'YYYY-MM-DD') AS 가장_오래된
FROM post;
| 가장_최근 | 가장_오래된 |
|---|---|
| 2026-06-27 | 2026-05-02 |
가장 최근 글은 6월 27일, 가장 오래된 글은 5월 2일에 올라왔어요. 날짜는 안에 숫자로 저장돼 있어서(지난 시간에 봤죠) MAX가 가장 미래의 날짜를, MIN이 가장 과거의 날짜를 골라줘요.
⚠️ 함정 —
MAX·MIN은 숫자뿐 아니라 날짜·문자에도 동작해요. "MAX는 숫자 컬럼에만 쓸 수 있다"는 설명은 틀린 거예요. 날짜의 최댓값은 가장 최근, 문자의 최댓값은 정렬상 가장 뒤예요. ★빈출
💡 한 줄 정리
MAX는 최댓값, MIN은 최솟값을 뽑으며, 숫자·날짜·문자 모두에 동작한다(날짜의 최댓값은 가장 최근, 문자의 최댓값은 정렬상 가장 뒤).
🙋 학생 질문 — "튜터님, MAX로 가장 긴 글자수는 알았는데, 그게 어느 게시물인지도 같이 나오나요?"
아니요, MAX는 '가장 큰 값' 하나만 돌려줘요. 어느 행에서 나온 값인지는 알려주지 않아요. 예를 들어 MAX(LENGTH(caption))을 하면 가장 긴 caption의 글자수(22)는 나오지만, 그게 몇 번 게시물인지는 안 나와요. 집계함수는 여럿을 하나로 줄이는 일이라, 그 과정에서 "어느 행이었는지"는 사라지거든요. 그 게시물을 찾고 싶으면 일단 최댓값(22)을 알아낸 뒤, WHERE LENGTH(caption) = 22처럼 다시 걸러내야 해요. 값과 그 값을 가진 행을 한 번에 묶는 더 깔끔한 방법은 나중 모듈에서 따로 배워요.
Step 5: "GROUP BY — 그룹으로 나눠 집계"
지금까지 집계는 표 전체를 한 덩어리로 모았어요. 회원 전체가 12명, 게시물 전체가 50개처럼요. 그런데 보통은 "전체"가 아니라 "각각"이 궁금해요. "회원별로 게시물을 몇 개씩 썼지?" 같은 거요. 이때 쓰는 게 GROUP BY예요.
GROUP BY는 행을 기준에 따라 그룹으로 묶어요. 빨래를 색깔별로 나눠 더미를 만든 다음, 더미마다 개수를 세는 것과 같아요. 게시물을 글쓴이(member_id)별로 묶어서, 묶음마다 몇 개인지 세 볼게요.
-- sql/queries/C4_aggregate_group.sql
SELECT member_id, COUNT(*) AS 게시물수
FROM post
GROUP BY member_id
ORDER BY member_id;
| member_id | 게시물수 |
|---|---|
| 1 | 7 |
| 2 | 7 |
| 3 | 6 |
| 4 | 6 |
| 5 | 4 |
| 6 | 5 |
| 9 | 6 |
| 11 | 4 |
| 12 | 5 |
이번엔 결과가 한 줄이 아니라 9줄이에요. GROUP BY member_id가 게시물을 회원별로 9개 묶음으로 나눴고, 각 묶음마다 COUNT(*)를 따로 센 거예요. 1번 회원은 7개, 5번 회원은 4개를 썼네요. 세어 보면 합이 50으로, 전체 게시물 수와 맞아요.
여기서 눈여겨볼 게 있어요. 회원은 12명인데 결과는 9줄뿐이에요. 7·8·10번 회원이 안 보이죠. 이 세 명은 게시물을 한 개도 안 쓴 회원이에요(Step 2에서 글쓴이가 9명이라고 했던 그 차이예요). GROUP BY는 post 표에 실제로 있는 행만 묶어요. 게시물이 0개인 회원은 post 표에 행 자체가 없으니, 묶을 게 없어서 결과에서 통째로 빠지는 거예요.
"게시물 0개인 회원도 0이라고 보여 주면 안 되나?" 싶죠. 맞아요, 그게 자연스러운데 지금 도구로는 안 돼요. 회원 표와 게시물 표를 이어 붙여야 "글이 없는 회원도 0개"라고 채울 수 있는데, 두 표를 잇는 건 다음 시간에 배울 조인의 몫이에요. 오늘은 "있는 것만 묶인다"는 점만 확실히 잡고 가요.
💡 한 줄 정리
GROUP BY 컬럼은 행을 그 컬럼 값별로 묶고 묶음마다 집계하며, 결과는 그룹 수만큼 나온다. 단 표에 행이 없는 값(게시물 0개 회원)은 그룹으로 만들어지지 않는다.
🙋 학생 질문 — "튜터님, 그냥 COUNT()랑 GROUP BY 붙인 COUNT()는 뭐가 달라요?"
집계의 '범위'가 달라요. GROUP BY 없이 COUNT(*)만 쓰면 표 전체를 한 덩어리로 보고 딱 하나의 숫자(50)를 줘요. GROUP BY member_id를 붙이면 표를 회원별 묶음으로 먼저 나눈 뒤, 묶음마다 COUNT(*)를 따로 세서 9개의 숫자를 줘요. "전체가 몇 개?"와 "각자 몇 개씩?"의 차이예요. 그래서 GROUP BY가 없으면 결과 한 줄, 있으면 그룹 수만큼 여러 줄이 나와요. 둘 중 뭘 쓸지는 "전체를 알고 싶은가, 그룹별로 알고 싶은가"로 정하면 돼요.
Step 6: "GROUP BY의 규칙 — 가공식 그룹화와 SELECT 절"
GROUP BY는 컬럼 하나로만 묶는 게 아니에요. 지난 시간에 배운 함수로 값을 가공해서, 그 가공한 결과로 묶을 수도 있어요. 작성일을 '월' 단위로 가공해서 월별 게시물 수를 세 볼게요. 날짜를 원하는 모양의 문자로 바꾸는 TO_CHAR를 그룹 기준으로 써요.
-- sql/queries/C4_aggregate_group.sql
SELECT TO_CHAR(created_at, 'YYYY-MM') AS 작성월,
COUNT(*) AS 게시물수
FROM post
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
ORDER BY 작성월;
| 작성월 | 게시물수 |
|---|---|
| 2026-05 | 23 |
| 2026-06 | 27 |
5월에 23개, 6월에 27개를 올렸어요. 50개 게시물이 두 달로 나뉘었죠. 지난 모듈에서 5월 게시물을 BETWEEN으로 셌을 때도 23건이었는데, 그 숫자가 여기서 다시 맞아떨어져요.
여기서 GROUP BY의 가장 중요한 규칙이 나와요. SELECT 절에 올 수 있는 건 두 가지뿐이에요. 그룹의 기준이 된 것(GROUP BY에 적은 것)과, 집계함수(COUNT·SUM 등)예요. 그룹 기준도 아니고 집계도 아닌 컬럼을 SELECT에 그냥 적으면 에러가 나요.
예를 들어 게시물을 회원별로 묶으면서 caption을 같이 보여 달라고 하면 어떻게 될까요? 한 회원의 묶음 안에는 게시물이 여러 개라 caption도 여러 개인데, 그 묶음을 대표하는 caption 하나를 정할 수 없어요. 그래서 데이터베이스가 "그 컬럼은 그룹 기준에 없다"며 에러로 막아요. 실제로 SELECT member_id, caption, COUNT(*) ... GROUP BY member_id를 실행하면 caption 때문에 거부당하는 걸 확인했어요.
⚠️ 함정 —
GROUP BY를 쓸 때SELECT절에는 'GROUP BY에 적은 것'과 '집계함수'만 올 수 있어요. 그 외 컬럼을 넣으면 에러예요. SQLD에서GROUP BY member_id인데SELECT에caption같은 비집계 컬럼을 슬쩍 넣은 쿼리를 주고 "결과가 무엇이냐"를 묻는 함정이 자주 나와요. 답은 '에러'예요. ★빈출
💡 한 줄 정리
함수로 가공한 값(TO_CHAR(created_at,'YYYY-MM'))으로도 그룹을 묶을 수 있으며, SELECT 절에는 'GROUP BY에 적은 것'과 '집계함수'만 올 수 있다.
🙋 학생 질문 — "튜터님, 집계 안 한 컬럼은 왜 꼭 GROUP BY에 넣어야 하죠? 그냥 보여 주면 안 돼요?"
그 컬럼의 값을 '하나로 정할 수 없어서'예요. 회원 1번 묶음에는 게시물이 7개 들어 있어요. COUNT(*)는 이 7개를 7이라는 한 값으로 요약하니 한 줄에 깔끔하게 들어가요. 그런데 caption은 7개가 다 다른 글이에요. 이 7개 중 어느 걸 한 줄에 보여 줘야 할까요? 정할 수가 없죠. 그래서 데이터베이스는 "그룹을 대표하는 값으로 쓸 수 없는 컬럼"을 발견하면 모호하다고 판단해 에러를 내요. 만약 caption도 묶음 기준으로 쓰고 싶으면 GROUP BY member_id, caption처럼 그룹 기준에 같이 넣으면 돼요. 그러면 회원과 caption 조합마다 묶이니 대표값이 분명해져요.
Step 7: "HAVING — 그룹에 조건 걸기"
이제 그룹 중에서 일부만 골라내고 싶어요. "게시물을 6개 이상 쓴 회원만 보고 싶다" 같은 거요. 행을 거를 땐 WHERE를 썼죠. 그런데 그룹을 거를 땐 WHERE가 아니라 HAVING을 써요. 게시물 6개 이상인 회원만 골라 볼게요.
-- sql/queries/C4_aggregate_group.sql
SELECT member_id, COUNT(*) AS 게시물수
FROM post
GROUP BY member_id
HAVING COUNT(*) >= 6
ORDER BY 게시물수 DESC, member_id;
| member_id | 게시물수 |
|---|---|
| 1 | 7 |
| 2 | 7 |
| 3 | 6 |
| 4 | 6 |
| 9 | 6 |
게시물 6개 이상인 회원이 5명 나왔어요. Step 5에서 9명이 나왔던 결과에서, 6개 미만인 회원(5·6·11·12번)이 걸러지고 5명만 남은 거예요. HAVING COUNT(*) >= 6이 그룹을 짓고 난 다음에, 각 그룹의 집계 결과를 보고 조건에 맞는 그룹만 남겨요.
여기서 WHERE와 HAVING의 결정적인 차이가 나와요. WHERE는 그룹을 짓기 '전에' 행을 걸러요. HAVING은 그룹을 짓고 집계한 '후에' 그룹을 걸러요. 그래서 COUNT(*) >= 6 같은 집계 조건은 WHERE에 쓸 수 없어요. 그룹을 짓기 전에는 COUNT(*) 값이 아직 없으니까요. 실제로 WHERE COUNT(*) >= 6이라고 쓰면 데이터베이스가 거부해요.
WHERE 와 HAVING — 거르는 시점이 다르다
전체 행
│
▼ WHERE ← 그룹 짓기 '전', 행을 거른다 (집계 조건 못 씀)
남은 행
│
▼ GROUP BY ← 행을 그룹으로 묶는다
그룹들
│
▼ HAVING ← 집계 '후', 그룹을 거른다 (집계 조건 씀)
남은 그룹
⚠️ 함정 — 집계 결과로 거를 땐
WHERE가 아니라HAVING이에요.WHERE COUNT(*) >= 6처럼WHERE에 집계함수를 쓰면 에러예요. SQLD에서WHERE와HAVING을 바꿔치기해 놓고 "어디가 틀렸나"를 묻는 함정이 단골이에요. ★빈출
💡 한 줄 정리
HAVING은 그룹을 짓고 집계한 후에 그룹을 거르며, COUNT(*) >= 6 같은 집계 조건은 WHERE가 아닌 HAVING에만 쓸 수 있다.
🙋 학생 질문 — "튜터님, 그럼 WHERE랑 HAVING을 한 쿼리에 같이 쓸 수도 있어요?"
네, 같이 쓸 수 있고 실무에서 자주 그렇게 해요. 역할이 다르니까요. WHERE로 먼저 필요 없는 행을 쳐내서 그룹 지을 대상을 줄이고, HAVING으로 집계 결과가 조건에 맞는 그룹만 남기는 거예요. 예를 들어 "6월 게시물만(WHERE), 회원별로 묶어서, 2개 이상 쓴 회원만(HAVING)"처럼요. WHERE가 먼저 일하고 HAVING이 나중에 일해요. 이 둘을 같이 쓰는 쿼리는 바로 다음 Step에서 직접 만들어 봐요. 거기서 처리 순서가 한눈에 들어올 거예요.
Step 8: "집계의 실행 순서와 NULL 총정리"
오늘 배운 걸 한 쿼리에 모아 볼게요. "6월에 올라온 게시물만 골라서, 회원별로 묶고, 그중 2개 이상 쓴 회원을, 많이 쓴 순으로" 보는 쿼리예요. WHERE·GROUP BY·HAVING·ORDER BY가 다 들어가요.
-- sql/queries/C4_aggregate_group.sql
SELECT member_id, COUNT(*) AS 유월_게시물수
FROM post
WHERE created_at >= DATE '2026-06-01'
GROUP BY member_id
HAVING COUNT(*) >= 2
ORDER BY 유월_게시물수 DESC, member_id;
| member_id | 유월_게시물수 |
|---|---|
| 1 | 4 |
| 2 | 4 |
| 3 | 4 |
| 4 | 4 |
| 5 | 3 |
| 6 | 3 |
| 9 | 2 |
| 12 | 2 |
8명이 나왔어요. 6월 게시물은 전부 27개인데, 그걸 회원별로 묶어 2개 이상인 회원만 남긴 결과예요. 11번 회원은 6월에 1개만 써서 HAVING에서 걸러졌어요.
이 쿼리가 중요한 이유는, SQL이 우리가 적은 순서대로 처리되지 않기 때문이에요. 우리는 SELECT를 맨 위에 적지만, 데이터베이스는 SELECT를 거의 마지막에 처리해요. 실제 처리 순서는 이래요.
SQL이 실제로 처리되는 순서 (우리가 적는 순서와 다르다)
① FROM 어느 표에서 가져올지 정한다 (post)
② WHERE 행을 먼저 거른다 (6월 게시물 27개만 남음)
③ GROUP BY 남은 행을 그룹으로 묶는다 (회원별)
④ HAVING 묶인 그룹을 거른다 (2개 이상만)
⑤ SELECT 보여 줄 값을 고르고 집계한다
⑥ ORDER BY 맨 마지막에 정렬한다 (많은 순)
이 순서를 알면 헷갈리던 게 많이 풀려요. WHERE가 GROUP BY보다 먼저라서, WHERE에는 집계 조건을 못 쓰는 거예요(그 시점엔 아직 그룹도 집계도 없으니까요). HAVING은 GROUP BY 다음이라 집계 조건을 쓸 수 있고요. ORDER BY가 맨 마지막이라, SELECT에서 붙인 별칭(유월_게시물수)을 ORDER BY에서는 쓸 수 있어요.
⚠️ 함정 — SQL의 논리적 처리 순서는
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY예요.SELECT가WHERE·GROUP BY보다 나중에 처리돼요. SQLD에서 이 순서를 묻거나, 순서를 근거로 "이 쿼리가 왜 에러인지"를 묻는 문제가 나와요. ★빈출
마지막으로 오늘 만난 집계와 NULL을 한 표로 정리해요. 집계함수마다 NULL을 어떻게 다루는지가 시험의 핵심이에요.
| 집계함수 | NULL 처리 | 기억할 점 |
|---|---|---|
COUNT(*) |
NULL 포함 (행을 셈) | 전체 행 수 |
COUNT(컬럼) |
NULL 제외 | 그 컬럼이 NULL인 행은 안 셈 |
SUM |
NULL 제외 | NULL은 더하지 않음 |
AVG |
NULL 제외 | 분모가 NULL 아닌 값의 개수 ⚠️ |
MAX·MIN |
NULL 제외 | NULL은 후보에서 빠짐 |
COUNT(*)만 NULL을 포함하고, 나머지는 전부 NULL을 건너뛴다는 게 핵심이에요. 지난 시간에 NULL을 단단히 잡아 둔 게 오늘 집계에서 이렇게 그대로 힘이 됐어요.
💡 한 줄 정리
SQL은 FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY 순으로 처리되며, COUNT(*)만 NULL을 포함하고 나머지 집계함수는 모두 NULL을 건너뛴다.
🙋 학생 질문 — "튜터님, SELECT가 마지막에 처리되면, WHERE에서는 왜 별칭을 못 쓰나요?"
별칭은 SELECT에서 만들어지는데, WHERE가 SELECT보다 먼저 처리되기 때문이에요. WHERE가 일할 시점엔 별칭이 아직 생기지 않은 거죠. 그래서 SELECT COUNT(*) AS 게시물수 ... WHERE 게시물수 >= 6처럼 WHERE에서 별칭을 부르면 "그런 이름 없다"며 에러가 나요. 반대로 ORDER BY는 SELECT보다 나중에 처리되니, 별칭을 그대로 쓸 수 있어요. 오늘 마지막 쿼리에서 ORDER BY 유월_게시물수가 잘 동작한 게 그 덕분이에요. "별칭이 어디서 통하고 어디서 안 통하나"는 이 처리 순서 하나로 다 설명돼요.
마무리
오늘은 여러 행을 한 값으로 모으는 집계를 배웠어요. COUNT로 개수를 세고, SUM·AVG로 더하고 평균 내고, MAX·MIN으로 양 끝값을 뽑았죠. 그다음 GROUP BY로 행을 그룹으로 나눠 묶음마다 집계하고, HAVING으로 그룹을 골라냈어요. 그리고 그 안에서 지난 시간에 잡아 둔 NULL이 COUNT와 AVG의 결과를 어떻게 가르는지 똑똑히 봤고요.
오늘 배운 핵심 세 가지
- 💡 하나 — 집계함수는 여러 행을 한 값으로 요약하며,
COUNT(*)는 NULL을 포함하지만COUNT(컬럼)·SUM·AVG·MAX·MIN은 NULL을 건너뛴다. 특히AVG의 분모는 NULL 아닌 값의 개수다. - 💡 둘 —
GROUP BY는 행을 그룹으로 묶어 묶음마다 집계하고,SELECT절에는 'GROUP BY에 적은 것'과 '집계함수'만 올 수 있다. 표에 행이 없는 값은 그룹이 되지 않는다. - 💡 셋 —
HAVING은 집계 후 그룹을 거르고(집계 조건은WHERE가 아닌HAVING), SQL은FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY순으로 처리된다.
다음 시간 예고
오늘 GROUP BY로 회원별 게시물 수를 셀 때, 게시물이 0개인 회원 3명이 결과에서 빠졌던 거 기억하시죠. 그리고 결과에 회원 번호(member_id)만 나오고 회원 이름은 못 붙였고요. 회원 이름은 member 표에 있는데, 게시물 수는 post 표에서 세니까요. 다음 시간엔 드디어 이 두 표를 이어 붙여요. 조인(JOIN)이에요. 두 표를 연결하면 "회원 이름과 그 회원의 게시물 수를 한 줄에", 그리고 "글이 없는 회원도 0개로" 보여 줄 수 있어요. SQLD에서 가장 자주, 가장 깊게 나오는 주제예요. 오늘 집계를 익힌 다음이라, 조인 위에 집계를 얹는 것까지 자연스럽게 이어 갈 거예요.
과제
오늘 배운 집계와 그룹을 직접 써 보는 과제예요. 결과가 몇 건 나올지, 어떤 값이 나올지 먼저 예상한 뒤 실행해 확인해 보세요.
[기초] 회원과 게시물 세어 보기
다음을 조회하는 SQL을 써 보세요. (가) 회원 표에서 전체 회원 수와 자기소개(bio)가 있는 회원 수를 한 번에 세어 보세요. 두 숫자가 왜 다른지 한 줄로 적어 보세요. (나) 게시물 표에서 전체 게시물 수와, 게시물을 쓴 글쓴이 수(중복 제거)를 한 번에 세어 보세요. (다) 게시물의 작성일 중 가장 최근 날짜와 가장 오래된 날짜를 뽑아 보세요.
[응용] 평균의 함정 확인하기
게시물 표에서 다음을 해 보세요. (가) caption의 글자 수(LENGTH)에 대해 합계·평균·최댓값·최솟값을 한 번에 구해 보세요. (나) 해시태그 개수(REGEXP_COUNT(caption, '#'))의 전체 평균과, NULLIF로 0을 NULL로 바꾼 뒤의 평균을 나란히 구해 보세요. 두 평균이 다른 이유를 분모 관점에서 설명해 보세요. (다) COUNT(*)와 COUNT(NULLIF(REGEXP_COUNT(caption,'#'), 0))을 같이 세어, (나)의 분모가 실제로 몇이었는지 확인해 보세요.
[심화] 그룹으로 묶어 조건 걸기
게시물 표에서 다음을 해 보세요. (가) 회원별 게시물 수를 구하되, 게시물이 5개 이상인 회원만 게시물 많은 순으로 보여 주세요. (나) 작성일을 월 단위(TO_CHAR(created_at, 'YYYY-MM'))로 묶어 월별 게시물 수를 구하고, 5월·6월이 각각 몇 개인지 확인해 보세요. (다) 5월에 올라온 게시물만 골라 회원별로 묶고, 그중 2개 이상 쓴 회원만 많은 순으로 보여 주세요. WHERE와 HAVING을 각각 어디에 썼는지 짚어 보세요.
생각해볼 주제
1. COUNT(*)와 COUNT(컬럼), 무엇을 언제 쓸까
같은 COUNT인데 COUNT(*)는 전체 행을, COUNT(컬럼)은 그 컬럼이 NULL이 아닌 행을 세요. "회원이 몇 명인가"를 셀 때와 "자기소개를 작성한 회원이 몇 명인가"를 셀 때, 어느 쪽을 써야 할지 생각해 보세요. 그리고 무심코 COUNT(컬럼)을 썼다가 NULL인 행이 빠져 숫자가 적게 나오는 실수가 실무에서 어떤 문제로 이어질 수 있는지도 함께 고민해 보세요.
2. 평균을 낼 때 NULL을 어떻게 다뤄야 할까
AVG는 NULL을 분모에서 빼요. 그래서 "값이 없는 것"과 "값이 0인 것"의 평균이 완전히 달라져요. 예를 들어 설문에서 응답하지 않은 사람을 0점으로 채울 때와 NULL로 둘 때, 평균이 어떻게 달라지는지 생각해 보세요. 그리고 데이터를 모을 때 "아직 모르는 값"을 0으로 채우는 것과 NULL로 비워 두는 것 중 어느 쪽이 정직한 설계인지, 어떤 상황에서 그 선택이 결과를 바꾸는지 고민해 보세요.
3. WHERE와 HAVING을 가르는 기준은 무엇일까
행을 거를 땐 WHERE, 그룹을 거를 땐 HAVING을 썼어요. 둘은 거르는 시점이 다르고, 그래서 WHERE에는 집계 조건을 못 써요. 이 차이가 단순한 문법 규칙이 아니라 SQL의 처리 순서(WHERE가 GROUP BY보다 먼저)에서 비롯된다는 점을 떠올려 보세요. 그리고 "6월 게시물 중, 회원별 2개 이상"처럼 두 조건을 함께 걸 때, 어떤 조건을 WHERE로 보내고 어떤 조건을 HAVING에 남겨야 효율적인지 생각해 보세요.
✅ 예시 답안정답 보기
과제와 생각해볼 주제의 예시답안이에요. 정답을 그대로 베끼기보다, 왜 그 집계함수를 그렇게 쓰는지 흐름을 따라와 주세요. "여러 행을 어떻게 한 값으로 모을까", "이 조건을 행에 걸까 그룹에 걸까"를 떠올리는 감각을 기르는 게 목표예요.
🎯 [과제 1 예시답안] 회원과 게시물 세어 보기
채점 포인트
| 항목 | 배점 | 핵심 |
|---|---|---|
(가) COUNT(*) vs COUNT(bio) |
35% | 12 vs 8 · NULL 4명 차이 |
(나) 전체 수 + COUNT(DISTINCT) |
35% | 50 vs 9 · 중복 글쓴이 제거 |
(다) MAX·MIN 날짜 |
30% | 가장 최근·가장 오래된 작성일 |
풀이 예시
(가) 전체 회원 vs 자기소개 있는 회원
SELECT COUNT(*) AS 전체회원,
COUNT(bio) AS 소개있는회원
FROM member;
| 전체회원 | 소개있는회원 |
|---|---|
| 12 | 8 |
두 숫자가 다른 이유는, COUNT(*)는 행 전체를 세고 COUNT(bio)는 bio가 NULL인 행을 건너뛰기 때문이에요. 자기소개가 빈 회원 4명(3·5·8·10번)이 빠져서 12와 8로 갈렸어요.
(나) 전체 게시물 vs 글쓴이 수
SELECT COUNT(*) AS 게시물수,
COUNT(DISTINCT member_id) AS 글쓴이수
FROM post;
| 게시물수 | 글쓴이수 |
|---|---|
| 50 | 9 |
게시물은 50개지만 글쓴이는 9명이에요. 한 회원이 여러 글을 써서 member_id에 중복이 있는데, DISTINCT가 그 중복을 걷어냈어요.
(다) 가장 최근·가장 오래된 작성일
SELECT TO_CHAR(MAX(created_at), 'YYYY-MM-DD') AS 가장_최근,
TO_CHAR(MIN(created_at), 'YYYY-MM-DD') AS 가장_오래된
FROM post;
| 가장_최근 | 가장_오래된 |
|---|---|
| 2026-06-27 | 2026-05-02 |
💡 튜터의 한마디 — (가)가 오늘의 핵심 함정이에요. 무심코 COUNT(bio)를 쓰면 "회원 수"를 묻는데 8을 답하게 돼요. "행을 셀 건가, 값이 있는 행만 셀 건가"를 먼저 정하고 COUNT(*)와 COUNT(컬럼)을 고르세요. 그 한 끗이 숫자를 4만큼 바꿔요.
🎯 [과제 2 예시답안] 평균의 함정 확인하기
채점 포인트
| 항목 | 배점 | 핵심 |
|---|---|---|
(가) LENGTH 4종 집계 |
30% | SUM·AVG·MAX·MIN 한 번에 |
(나) 전체 평균 vs NULLIF 평균 |
40% | 0.84 vs 1.5 · 분모 차이 |
(다) COUNT로 분모 확인 |
30% | 50 vs 28 |
풀이 예시
(가) caption 글자 수 집계
SELECT SUM(LENGTH(caption)) AS 글자_합계,
AVG(LENGTH(caption)) AS 글자_평균,
MAX(LENGTH(caption)) AS 최대_글자,
MIN(LENGTH(caption)) AS 최소_글자
FROM post;
| 글자_합계 | 글자_평균 | 최대_글자 | 최소_글자 |
|---|---|---|---|
| 499 | 9.98 | 22 | 5 |
게시물 50개의 caption을 다 합치면 499자, 평균 약 9.98자예요. 가장 긴 글은 22자, 가장 짧은 글은 5자고요. 네 가지 집계를 한 줄에 나란히 뽑았어요.
(나) 전체 평균 vs 해시태그 있는 글의 평균
SELECT AVG(REGEXP_COUNT(caption, '#')) AS 전체_평균,
AVG(NULLIF(REGEXP_COUNT(caption, '#'), 0)) AS 태그있는글_평균
FROM post;
| 전체_평균 | 태그있는글_평균 |
|---|---|
| 0.84 | 1.5 |
두 평균이 다른 이유는 분모예요. 합계는 둘 다 42로 같아요. 전체 평균은 42를 게시물 전체 50으로 나눠 0.84, 태그있는글 평균은 NULLIF가 0개인 글을 NULL로 바꿔 분모에서 빼면서 42를 28로 나눠 1.5가 됐어요.
(다) 분모가 실제로 몇이었는지 확인
SELECT COUNT(*) AS 전체글,
COUNT(NULLIF(REGEXP_COUNT(caption,'#'), 0)) AS 태그있는글
FROM post;
| 전체글 | 태그있는글 |
|---|---|
| 50 | 28 |
(나)의 두 평균이 나눈 수가 각각 50과 28이었다는 게 여기서 확인돼요. 해시태그 없는 글 22개가 NULL이 되며 분모에서 빠진 거예요.
💡 튜터의 한마디 — (나)와 (다)를 묶어 보면 평균의 함정이 또렷해져요. AVG는 SUM ÷ COUNT(*)가 아니라 SUM ÷ COUNT(컬럼)이에요. 분자(42)가 같아도 분모가 NULL 때문에 달라지면 평균이 통째로 바뀌어요. 시험에서 "전체 행 수로 나눈 값"을 오답으로 깔아 두는 게 바로 이 지점이에요.
🎯 [과제 3 예시답안] 그룹으로 묶어 조건 걸기
채점 포인트
| 항목 | 배점 | 핵심 |
|---|---|---|
(가) GROUP BY + HAVING |
35% | HAVING COUNT(*) >= 5 |
| (나) 가공식 그룹화 | 30% | GROUP BY TO_CHAR(created_at,'YYYY-MM') |
(다) WHERE + HAVING 함께 |
35% | 행 필터와 그룹 필터 구분 |
풀이 예시
(가) 게시물 5개 이상 회원, 많은 순
SELECT member_id, COUNT(*) AS 게시물수
FROM post
GROUP BY member_id
HAVING COUNT(*) >= 5
ORDER BY 게시물수 DESC, member_id;
| member_id | 게시물수 |
|---|---|
| 1 | 7 |
| 2 | 7 |
| 3 | 6 |
| 4 | 6 |
| 9 | 6 |
| 6 | 5 |
| 12 | 5 |
7명이 나와요. 회원별로 묶은 9개 그룹 중, 5개 미만인 두 명(5·11번, 각 4개)이 HAVING에서 걸러졌어요.
(나) 월별 게시물 수
SELECT TO_CHAR(created_at, 'YYYY-MM') AS 작성월,
COUNT(*) AS 게시물수
FROM post
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
ORDER BY 작성월;
| 작성월 | 게시물수 |
|---|---|
| 2026-05 | 23 |
| 2026-06 | 27 |
5월 23개, 6월 27개로 나뉘어요. 함수로 가공한 '월'을 그룹 기준으로 삼은 거예요.
(다) 5월 게시물, 회원별 2개 이상, 많은 순
SELECT member_id, COUNT(*) AS 오월_게시물수
FROM post
WHERE created_at >= DATE '2026-05-01'
AND created_at < DATE '2026-06-01'
GROUP BY member_id
HAVING COUNT(*) >= 2
ORDER BY 오월_게시물수 DESC, member_id;
| member_id | 오월_게시물수 |
|---|---|
| 9 | 4 |
| 1 | 3 |
| 2 | 3 |
| 11 | 3 |
| 12 | 3 |
| 3 | 2 |
| 4 | 2 |
| 6 | 2 |
WHERE는 5월 게시물만 골라내는 '행 필터'로 그룹 짓기 전에 일하고, HAVING COUNT(*) >= 2는 묶인 그룹 중 2개 이상인 것만 남기는 '그룹 필터'로 집계 후에 일해요. 5월 게시물은 전부 23개인데, 그중 1개만 쓴 회원(5번)이 HAVING에서 빠져 8명이 남았어요.
💡 튜터의 한마디 — (다)가 WHERE와 HAVING을 한 쿼리에서 함께 쓰는 연습이에요. 두 조건의 '시점'이 다르다는 걸 손으로 직접 느껴 보세요. 날짜 조건은 행 하나하나에 거는 거라 WHERE로, 개수 조건은 묶은 다음에야 알 수 있어 HAVING으로 가요. 이 구분이 머리에 박히면 집계 쿼리가 안 헷갈려요.
🤔 [생각해볼 주제 1] COUNT(*)와 COUNT(컬럼), 무엇을 언제 쓸까
문제 상황 요약
같은 COUNT인데 COUNT(*)는 전체 행을, COUNT(컬럼)은 그 컬럼이 NULL이 아닌 행을 세요. "회원이 몇 명인가"와 "자기소개를 작성한 회원이 몇 명인가"는 어느 쪽을 써야 할까요?
튜터의 가이드 및 해설
기준은 "내가 세려는 게 행인가, 값인가"예요. "회원이 전부 몇 명인가"는 회원이라는 행 자체를 세는 거라 COUNT(*)가 맞아요. NULL이든 아니든 회원은 회원이니까요. 반면 "자기소개를 작성한 회원"은 bio에 값이 있는 행만 세는 거라 COUNT(bio)가 맞아요. 질문이 "존재하는 행"을 묻는지 "값이 채워진 행"을 묻는지로 갈려요.
실수는 보통 반대 방향에서 나와요. "회원 수"를 세려고 했는데 무심코 COUNT(bio)를 쓰는 경우예요. 그러면 자기소개를 안 적은 회원이 통째로 빠져서, 12명이어야 할 답이 8명으로 나와요. 숫자가 적게 나오는데 에러는 안 나니 알아채기 어렵죠. 그래서 "행을 셀 땐 COUNT(*)"를 기본으로 두고, COUNT(컬럼)은 "이 컬럼에 값이 있는 것만"이라는 의도가 분명할 때만 쓰는 게 안전해요.
🎯 SQLD는 이렇게 나온다
NULL이 섞인 컬럼을 주고 COUNT(*)와 COUNT(컬럼)의 결과를 비교하는 문제가 단골이에요. 전체 행 수와 같은 값을 COUNT(컬럼)의 답인 척 깔아 두는 함정을 조심하세요. COUNT(*)만 NULL을 포함하고 COUNT(컬럼)은 NULL을 뺀다는 한 줄을 정확히 기억하면 안 틀려요.
💡 실무에선
"개수를 세는 컬럼에 NULL이 들어갈 수 있는가"를 먼저 확인해요. NULL이 절대 없는 컬럼(예: 기본키)이라면 COUNT(*)와 COUNT(그컬럼)이 같지만, NULL이 들어갈 수 있는 컬럼이면 결과가 달라져요. 지표를 집계할 때 이 차이를 놓치면 "가입자 수"나 "주문 수" 같은 숫자가 조용히 적게 잡히는 사고가 나요.
🤔 [생각해볼 주제 2] 평균을 낼 때 NULL을 어떻게 다뤄야 할까
문제 상황 요약
AVG는 NULL을 분모에서 빼요. 그래서 "값이 없는 것"과 "값이 0인 것"의 평균이 완전히 달라져요. 응답하지 않은 사람을 0으로 채울 때와 NULL로 둘 때, 평균이 어떻게 갈릴까요?
튜터의 가이드 및 해설
핵심은 "0은 분모에 포함되지만, NULL은 분모에서 빠진다"는 거예요. 설문에서 응답을 안 한 사람을 0점으로 채우면, 그 사람도 평균 계산에 들어가요. 100명 중 50명이 응답하고 50명을 0으로 채우면, 분자는 응답자 점수의 합 그대로인데 분모가 100이 돼서 평균이 절반으로 깎여요. 반대로 안 한 사람을 NULL로 두면 분모가 50이 돼서, 응답한 사람들만의 진짜 평균이 나와요.
그래서 "아직 모르는 값"을 0으로 채울지 NULL로 둘지는 평균을 바꾸는 중요한 결정이에요. 정직한 설계는 "값이 없으면 NULL로 둔다"예요. 0은 '0이라는 측정값이 실제로 있다'는 뜻이어야 해요. 좋아요가 0개인 글은 0이 맞지만, 아직 집계가 안 된 글을 0으로 적으면 그 0이 평균을 왜곡해요. "이 0이 진짜 0인가, 아니면 모름인가"를 데이터를 넣는 시점에 구분하는 게 중요해요.
🎯 SQLD는 이렇게 나온다
NULL이 섞인 컬럼의 AVG를 묻고, 전체 행 수로 나눈 값을 오답으로 까는 문제가 자주 나와요. AVG(컬럼)은 SUM(컬럼) / COUNT(컬럼)이지 SUM(컬럼) / COUNT(*)가 아니라는 점, 그리고 SUM도 AVG도 NULL을 건너뛴다는 점이 핵심이에요. "NULL을 0으로 친다"는 보기는 함정이에요.
💡 실무에선
평균 지표를 만들 땐 "NULL을 어떻게 볼 것인가"를 먼저 합의해요. "미응답을 평균에서 빼는 게 맞는가, 아니면 0으로 보고 포함할 것인가"는 비즈니스 판단이에요. 같은 데이터라도 이 결정에 따라 평균이 달라지니, AVG를 그냥 쓰기 전에 그 컬럼에 NULL이 무슨 뜻인지부터 확인하는 습관이 사고를 막아요.
🤔 [생각해볼 주제 3] WHERE와 HAVING을 가르는 기준은 무엇일까
문제 상황 요약
행을 거를 땐 WHERE, 그룹을 거를 땐 HAVING을 썼어요. 둘은 거르는 시점이 다르고, 그래서 WHERE에는 집계 조건을 못 써요. 이 차이는 어디서 비롯될까요?
튜터의 가이드 및 해설
이 차이는 단순한 문법 규칙이 아니라 SQL의 처리 순서에서 나와요. SQL은 WHERE를 먼저, GROUP BY를 그다음, HAVING을 그 후에 처리해요. WHERE가 일할 시점엔 아직 그룹도 집계도 만들어지지 않았어요. 그러니 WHERE COUNT(*) >= 6처럼 집계 결과로 거르려 하면, 그 시점엔 COUNT(*) 값이 없어서 에러가 나요. HAVING은 GROUP BY 다음이라 집계 결과가 이미 있으니 쓸 수 있고요.
그래서 "행 하나하나를 보고 판단할 수 있는 조건"은 WHERE로, "그룹을 묶어 집계해야 알 수 있는 조건"은 HAVING으로 가요. 날짜가 6월인지(created_at >= ...)는 행 하나만 봐도 아니까 WHERE, 게시물이 2개 이상인지(COUNT(*) >= 2)는 묶어 봐야 아니까 HAVING이에요.
효율 면에서도 순서가 중요해요. 두 조건을 함께 걸 땐, 거를 수 있는 건 WHERE로 최대한 먼저 쳐내는 게 좋아요. WHERE로 6월 게시물만 남겨 그룹 지을 대상을 줄이면, 그다음 묶고 집계하는 일이 가벼워져요. 행 단위로 거를 수 있는 조건을 HAVING까지 끌고 가면, 쓸데없이 다 묶었다가 버리는 셈이라 비효율적이에요.
🎯 SQLD는 이렇게 나온다
WHERE와 HAVING을 바꿔치기한 쿼리를 주고 "무엇이 틀렸나"를 묻거나, 집계함수를 WHERE에 쓴 쿼리의 결과(에러)를 묻는 문제가 단골이에요. 그리고 처리 순서 FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY를 직접 묻기도 해요. "WHERE는 집계 전, HAVING은 집계 후"를 기억하면 다 풀려요.
💡 실무에선
"거를 수 있는 건 최대한 빨리 거른다"가 원칙이에요. 행 단위 조건을 WHERE로 먼저 처리해 데이터 양을 줄이면, 뒤이은 그룹화와 집계가 가벼워져요. 같은 결과를 내는 쿼리라도 조건을 WHERE에 두느냐 HAVING에 두느냐로 처리량이 달라질 수 있으니, "이 조건은 행만 봐도 아는가"를 먼저 따져 보세요.