5부 · 여러 테이블을 잇고 요약하기 (2) 집계와 GROUP BY
← JOIN · 목차 · 다음: 6부 트랜잭션과 무결성 →
지금까지는 개별 행을 골라냈습니다. 이제 행들을 묶어서 요약합니다. "전체 주문은 몇 건?", "고객별 주문 수는?", "책별 총 판매량은?", "월별 매출은?" — 이런 질문은 흩어진 행을 합쳐야 답이 나옵니다. 집계 함수와 GROUP BY가 그 일을 합니다. 데이터 분석의 심장입니다.
flowchart LR
F[합치기 · JOIN] --> G["요약하기<br/>COUNT · SUM · GROUP BY"]
classDef here fill:#d5f2e0,stroke:#27ae60,color:#000,stroke-width:3px
classDef dim fill:#eee,stroke:#bbb,color:#888
class G here
class F dim
예제는 공용 데이터를 씁니다. 출력은 실제 실행 결과입니다.
5.8 집계 함수: 여러 행을 한 값으로
집계 함수(aggregate function)는 여러 행의 값을 받아 하나의 요약 값을 돌려줍니다. 다섯 가지가 기본입니다.
| 함수 | 하는 일 |
|---|---|
COUNT(*) | 행의 개수 |
SUM(칸) | 합계 |
AVG(칸) | 평균 |
MIN(칸) / MAX(칸) | 최솟값 / 최댓값 |
SELECT COUNT(*) AS total_orders FROM orders;
출력:
total_orders ------------ 8
한 번에 여러 개도 됩니다.
SELECT COUNT(*) AS n, SUM(price) AS sum_price, AVG(price) AS avg_price, MIN(price) AS min_price, MAX(price) AS max_price FROM books;
출력:
n sum_price avg_price min_price max_price - --------- ----------------- --------- --------- 6 107000 17833.33333333... 9000 50000
COUNT(*)vsCOUNT(칸)vsCOUNT(DISTINCT 칸).COUNT(*)는 행 수를 셉니다(NULL 포함).COUNT(author)는author가 NULL이 아닌 행만 셉니다 — 그래서 저자 미상인 '고서 필사본'은 빠져 5가 나옵니다.COUNT(DISTINCT customer_id)는 중복을 뺀 고유 개수(=주문한 고객 수). 세 개의 미묘한 차이가 실무에서 자주 헷갈리니 기억해 두세요. NULL은SUM·AVG에서도 무시됩니다(평균의 분모에 안 들어감).
5.9 GROUP BY: 묶어서 집계
위 COUNT(*)는 전체를 하나로 셌습니다. 그런데 보통은 "~별로" 묶어 보고 싶습니다 — 고객별, 책별, 월별. 이때 GROUP BY가 행을 그룹으로 묶고, 집계 함수가 각 그룹마다 계산됩니다.
-- 고객별 주문 건수 SELECT c.name, COUNT(*) AS order_count FROM orders o JOIN customers c ON c.id = o.customer_id GROUP BY c.id ORDER BY order_count DESC;
출력:
name order_count ------ ----------- 김하나 3 박세찬 2 이두리 2 최넷별 1
GROUP BY c.id가 같은 고객의 주문들을 한 묶음으로 만들고, COUNT(*)가 묶음마다 행을 셉니다. 김하나의 주문 3건이 한 줄 "김하나 · 3"으로 요약됐습니다.
flowchart LR
raw["주문 8건 (낱개)"] --> grp["GROUP BY 고객<br/>같은 고객끼리 묶음"]
grp --> agg["COUNT(*)로 묶음마다 세기"]
agg --> out["고객별 주문 수<br/>4줄로 요약"]
classDef a fill:#d5e8f2,stroke:#3498db,color:#000
classDef b fill:#d5f2e0,stroke:#27ae60,color:#000
class raw,grp a
class agg,out b
합계도 그룹마다
"책별 총 판매 수량"은 SUM을 그룹마다 적용합니다.
SELECT b.title, SUM(o.quantity) AS sold FROM orders o JOIN books b ON b.id = o.book_id GROUP BY b.id ORDER BY sold DESC;
출력:
title sold ---------- ---- 데미안 5 노인과 바다 3 모비딕 3 이방인 1
데미안이 5권으로 가장 많이 팔렸네요(주문 2건 × 수량 2,3). JOIN으로 이름을 붙이고 GROUP BY로 묶어 합산하는 — 이것이 "가장 많이 팔린 책" 같은 분석 질문의 표준 형태입니다.
GROUP BY의 규칙 하나
GROUP BY를 쓰면, SELECT에 올 수 있는 건 (1) 묶음 기준 칸과 (2) 집계 함수 뿐입니다. 묶지도 집계하지도 않은 일반 칸을 넣으면 "그 칸의 어느 값을 보여줘야 하나?"가 모호해집니다(김하나의 주문 3건은 날짜가 제각각인데 무엇을 보여줄지 불명). SQLite는 이걸 관대하게 허용하지만 결과가 예측 불가하니, 묶음 기준이나 집계만 SELECT에 두는 습관을 들이세요.
5.10 HAVING: 그룹을 거르기
"주문을 2건 이상 한 고객만" 보고 싶습니다. WHERE로 될까요? 안 됩니다 — WHERE는 실행 순서상 GROUP BY보다 먼저 돌아서, 아직 그룹이 만들어지지 않았고 COUNT(*)도 없습니다. 그룹을 만든 다음에 거르는 도구가 따로 있습니다: HAVING.
SELECT c.name, COUNT(*) AS cnt FROM orders o JOIN customers c ON c.id = o.customer_id GROUP BY c.id HAVING COUNT(*) >= 2;
출력:
name cnt ------ --- 김하나 3 이두리 2 박세찬 2
최넷별(주문 1건)이 빠졌습니다. WHERE는 묶기 전 개별 행을, HAVING은 묶은 후 그룹을 거릅니다. 둘은 함께 쓸 수도 있습니다.
flowchart LR
rows["개별 행"] --> w["WHERE<br/>행을 거름 (묶기 전)"]
w --> g["GROUP BY<br/>묶음 생성"]
g --> h["HAVING<br/>그룹을 거름 (묶은 후)"]
h --> sel["SELECT · ORDER BY"]
classDef pre fill:#fdf0d5,stroke:#e67e22,color:#000
classDef post fill:#d5f2e0,stroke:#27ae60,color:#000
class rows,w pre
class g,h,sel post
월별 매출 — 종합 예제
지금까지 배운 걸 모읍니다. strftime으로 주문일에서 "연-월"을 뽑고, 그달의 매출(가격×수량 합)을 집계합니다.
SELECT strftime('%Y-%m', o.order_date) AS month, SUM(b.price * o.quantity) AS revenue FROM orders o JOIN books b ON b.id = o.book_id GROUP BY month ORDER BY month;
출력:
month revenue ------- ------- 2026-01 54000 2026-02 55000 2026-03 33000
JOIN(이름·가격 붙이기) + 계산(price * quantity) + GROUP BY(월별 묶기) + SUM(합산)이 한 쿼리에 어우러진, 현실적인 분석 쿼리입니다. 시트로 피벗 테이블 만들 일을 한 문장으로 한 셈입니다.
pandas를 안다면: 이 쿼리는df.groupby('month')['revenue'].sum()에 해당합니다.GROUP BY는 pandas의groupby, 집계 함수는.sum()·.mean()·.count()와 그대로 대응됩니다.
5.11 서브쿼리: 쿼리 속의 쿼리
질문이 복잡하면 "쿼리의 결과를 다른 쿼리의 재료로" 쓰면 단계적으로 풀립니다. 괄호 안에 넣은 SELECT를 서브쿼리(subquery, 하위 질의) 라고 합니다.
-- 평균 가격보다 비싼 책 SELECT title, price FROM books WHERE price > (SELECT AVG(price) FROM books) ORDER BY price;
출력:
title price ---------- ----- 고서 필사본 50000
(SELECT AVG(price) FROM books)가 먼저 평균(약 17833)을 구하고, 바깥 쿼리가 그보다 비싼 책을 거릅니다. 서브쿼리는 IN과도 잘 어울립니다.
-- '모비딕'을 주문한 적 있는 고객들 SELECT name FROM customers WHERE id IN ( SELECT o.customer_id FROM orders o JOIN books b ON b.id = o.book_id WHERE b.title = '모비딕' );
출력:
name ------ 김하나 이두리 박세찬
서브쿼리 vs JOIN. 위 질문은JOIN으로도 풀 수 있습니다. 둘 다 맞고, 읽기 쉬운 쪽을 고르면 됩니다. 단계적 사고("먼저 이걸 구하고, 그걸로 저걸 거른다")엔 서브쿼리가, 여러 표의 칸을 한꺼번에 보려면JOIN이 대개 자연스럽습니다. 복잡한 서브쿼리는 6부에서 배울 공통 테이블 식(CTE,WITH) 으로 더 읽기 좋게 풀어 쓸 수 있습니다.
5.12 윈도우 함수 — 맛보기
GROUP BY는 여러 행을 한 줄로 접어 버립니다(주문 3건 → 1줄). 그런데 "각 행은 그대로 두면서, 옆에 순위나 누계를 같이 보고 싶을" 때가 있습니다. 이때 윈도우 함수(window function) 를 씁니다.
-- 책을 가격순으로 두되, 옆에 순위를 매긴다 (행은 안 접힘) SELECT title, price, RANK() OVER (ORDER BY price DESC) AS price_rank FROM books;
출력:
title price price_rank ---------- ----- ---------- 고서 필사본 50000 1 모비딕 15000 2 데미안 12000 3 1984 11000 4 이방인 10000 5 노인과 바다 9000 6
OVER (...)가 윈도우 함수의 표시입니다. RANK()는 순위, SUM(...) OVER (...)는 누계, ROW_NUMBER()는 행 번호 등을 원래 행을 유지한 채 덧붙입니다. 입문 단계에선 "이런 게 있다"만 알아 두고, 데이터 분석으로 더 들어갈 때 깊이 배우면 됩니다. 윈도우 함수는 분석 SQL의 강력한 도구지만, GROUP BY보다 한 단계 어려우니 서두를 필요는 없습니다.
5.13 정리
- 집계 함수(
COUNT·SUM·AVG·MIN·MAX)는 여러 행을 한 요약 값으로 만든다.COUNT(*)/COUNT(칸)/COUNT(DISTINCT 칸)의 차이에 주의. NULL은 집계에서 무시된다. GROUP BY로 "~별" 묶음을 만들면 집계가 그룹마다 적용된다.SELECT엔 묶음 기준 칸과 집계 함수만 두는 게 안전.- 그룹을 거를 땐
HAVING(묶은 후), 개별 행을 거를 땐WHERE(묶기 전). 실행 순서가 이 구분을 만든다. - 서브쿼리로 "쿼리의 결과를 다른 쿼리의 재료로" 쓴다.
JOIN과 상호 보완. - 윈도우 함수(
OVER)는 행을 접지 않고 순위·누계를 덧붙인다 — 분석용 고급 도구(맛보기).
이로써 SQL의 핵심 질의 능력(SELECT·JOIN·GROUP BY)을 모두 갖췄습니다. 다음 부부터는 시야를 넓혀, 데이터를 안전하게(트랜잭션·무결성) 그리고 빠르게(인덱스·뷰) 다루는 운영의 기초로 넘어갑니다.
직접 해 보기
- 전체 책의 평균 가격과 최고가를 한 번에 구해 보세요.
- 고객별 "총 구매 수량"(
SUM(quantity))을 많은 순으로 보여 주세요. - 책별 매출(
SUM(price * quantity))을 구하고, 매출이 20000 이상인 책만HAVING으로 거르세요. strftime을 이용해 "월별 주문 건수"를 구해 보세요.- 서브쿼리로, "한 번도 주문되지 않은 책"을 찾아보세요. (힌트:
WHERE id NOT IN (SELECT book_id FROM orders)— 7부LEFT JOIN방식과 비교해 보세요.)
← JOIN · 목차 · 다음: 6부 트랜잭션과 무결성 →