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(칸)최솟값 / 최댓값
SQL
SELECT COUNT(*) AS total_orders FROM orders;

출력:

CODE
total_orders
------------
8

한 번에 여러 개도 됩니다.

SQL
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;

출력:

CODE
n  sum_price  avg_price          min_price  max_price
-  ---------  -----------------  ---------  ---------
6  107000     17833.33333333...  9000       50000
COUNT(*) vs COUNT(칸) vs COUNT(DISTINCT 칸). COUNT(*)는 행 수를 셉니다(NULL 포함). COUNT(author)authorNULL이 아닌 행만 셉니다 — 그래서 저자 미상인 '고서 필사본'은 빠져 5가 나옵니다. COUNT(DISTINCT customer_id)는 중복을 뺀 고유 개수(=주문한 고객 수). 세 개의 미묘한 차이가 실무에서 자주 헷갈리니 기억해 두세요. NULL은 SUM·AVG에서도 무시됩니다(평균의 분모에 안 들어감).

5.9 GROUP BY: 묶어서 집계

COUNT(*)는 전체를 하나로 셌습니다. 그런데 보통은 "~별로" 묶어 보고 싶습니다 — 고객별, 책별, 월별. 이때 GROUP BY가 행을 그룹으로 묶고, 집계 함수가 각 그룹마다 계산됩니다.

SQL
-- 고객별 주문 건수
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;

출력:

CODE
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을 그룹마다 적용합니다.

SQL
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;

출력:

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

SQL
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;

출력:

CODE
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으로 주문일에서 "연-월"을 뽑고, 그달의 매출(가격×수량 합)을 집계합니다.

SQL
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;

출력:

CODE
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, 하위 질의) 라고 합니다.

SQL
-- 평균 가격보다 비싼 책
SELECT title, price
FROM books
WHERE price > (SELECT AVG(price) FROM books)
ORDER BY price;

출력:

CODE
title       price
----------  -----
고서 필사본   50000

(SELECT AVG(price) FROM books)가 먼저 평균(약 17833)을 구하고, 바깥 쿼리가 그보다 비싼 책을 거릅니다. 서브쿼리는 IN과도 잘 어울립니다.

SQL
-- '모비딕'을 주문한 적 있는 고객들
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 = '모비딕'
);

출력:

CODE
name
------
김하나
이두리
박세찬
서브쿼리 vs JOIN. 위 질문은 JOIN으로도 풀 수 있습니다. 둘 다 맞고, 읽기 쉬운 쪽을 고르면 됩니다. 단계적 사고("먼저 이걸 구하고, 그걸로 저걸 거른다")엔 서브쿼리가, 여러 표의 칸을 한꺼번에 보려면 JOIN이 대개 자연스럽습니다. 복잡한 서브쿼리는 6부에서 배울 공통 테이블 식(CTE, WITH) 으로 더 읽기 좋게 풀어 쓸 수 있습니다.

5.12 윈도우 함수 — 맛보기

GROUP BY는 여러 행을 한 줄로 접어 버립니다(주문 3건 → 1줄). 그런데 "각 행은 그대로 두면서, 옆에 순위나 누계를 같이 보고 싶을" 때가 있습니다. 이때 윈도우 함수(window function) 를 씁니다.

SQL
-- 책을 가격순으로 두되, 옆에 순위를 매긴다 (행은 안 접힘)
SELECT title, price, RANK() OVER (ORDER BY price DESC) AS price_rank
FROM books;

출력:

CODE
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)을 모두 갖췄습니다. 다음 부부터는 시야를 넓혀, 데이터를 안전하게(트랜잭션·무결성) 그리고 빠르게(인덱스·뷰) 다루는 운영의 기초로 넘어갑니다.

직접 해 보기

  1. 전체 책의 평균 가격과 최고가를 한 번에 구해 보세요.
  2. 고객별 "총 구매 수량"(SUM(quantity))을 많은 순으로 보여 주세요.
  3. 책별 매출(SUM(price * quantity))을 구하고, 매출이 20000 이상인 책만 HAVING으로 거르세요.
  4. strftime을 이용해 "월별 주문 건수"를 구해 보세요.
  5. 서브쿼리로, "한 번도 주문되지 않은 책"을 찾아보세요. (힌트: WHERE id NOT IN (SELECT book_id FROM orders) — 7부 LEFT JOIN 방식과 비교해 보세요.)
← JOIN · 목차 · 다음: 6부 트랜잭션과 무결성 →