icon

안동민 개발노트

6장 : SQL 심화 — 집계와 윈도우 함수

GROUP BY와 집계 함수


카테고리별 평균 가격, 월별 주문 수, 부서별 최고 연봉 — 이런 질문에 답하려면 행을 그룹으로 묶고 각 그룹을 요약(집계)해야 합니다. GROUP BY가 행을 그룹으로 묶는 역할을 하고, 집계 함수가 각 그룹을 하나의 값으로 요약하는 역할을 합니다.

개별 행 하나하나를 보는 것이 미시적 관점이라면, 집계는 전체 또는 그룹 단위의 거시적 관점으로 데이터를 바라보는 것입니다.


집계 함수 (Aggregate Functions)

집계 함수는 여러 행의 값을 받아 하나의 결과를 반환하는 함수입니다. GROUP BY 없이 사용하면 전체 테이블이 하나의 그룹으로 간주됩니다.

기본 집계 함수 6가지

함수용도NULL 처리
COUNT(*)행 수NULL 포함 (모든 행)
COUNT(컬럼)NULL이 아닌 행 수NULL 제외
SUM(컬럼)합계NULL 무시
AVG(컬럼)평균NULL 무시
MAX(컬럼)최대값NULL 무시
MIN(컬럼)최소값NULL 무시
집계 함수 기본 사용
SELECT COUNT(*) AS 전체상품수,
       COUNT(category) AS 카테고리있는수,
       SUM(stock) AS 전체재고,
       AVG(price) AS 평균가격,
       MAX(price) AS 최고가,
       MIN(price) AS 최저가
FROM products;
실행 결과 예시
전체상품수 | 카테고리있는수 | 전체재고 | 평균가격 | 최고가  | 최저가
-----------|----------------|----------|----------|---------|-------
6          | 5              | 150      | 75000    | 150000  | 15000

COUNT(*) vs COUNT(컬럼) vs COUNT(DISTINCT 컬럼)

이 세 가지는 자주 혼동되지만 동작이 완전히 다릅니다.

COUNT의 세 가지 형태
-- 예제 데이터:
-- id | category
-- 1  | 전자
-- 2  | 전자
-- 3  | 식품
-- 4  | NULL
-- 5  | 식품

SELECT COUNT(*)                AS total_rows,      -- 5 (NULL 행 포함)
       COUNT(category)         AS non_null_count,   -- 4 (NULL 제외)
       COUNT(DISTINCT category) AS distinct_count;   -- 2 (전자, 식품)
COUNT 동작 정리
COUNT(*)             → 행의 수 (NULL 포함)
COUNT(컬럼)          → 해당 컬럼이 NULL이 아닌 행의 수
COUNT(DISTINCT 컬럼)  → 해당 컬럼의 고유 값 수 (NULL 제외)

AVG와 NULL의 함정

AVG는 NULL을 무시하고 계산합니다. 이것이 의도와 다를 수 있습니다.

AVG와 NULL
-- 점수 데이터: 80, 90, NULL, 70, NULL

SELECT AVG(score) AS avg_score FROM students;
-- 결과: (80 + 90 + 70) / 3 = 80
-- NULL인 2명은 분모에서도 제외됨!

-- NULL을 0으로 포함하여 계산하고 싶으면:
SELECT AVG(COALESCE(score, 0)) AS avg_score FROM students;
-- 결과: (80 + 90 + 0 + 70 + 0) / 5 = 48

-- Oracle에서는 NVL 사용
SELECT AVG(NVL(score, 0)) AS avg_score FROM students;

미응시자를 0점으로 처리할지, 제외할지는 업무 요구에 따라 다릅니다. AVG의 NULL 처리 방식을 이해하고, 의도에 맞게 COALESCE를 사용해야 합니다.

SUM과 NULL

SUM도 NULL을 무시합니다. 모든 값이 NULL이면 SUM의 결과도 NULL입니다 (0이 아님).

SUM과 NULL 주의
-- 주문이 없는 회원의 총 주문금액
SELECT COALESCE(SUM(total_amount), 0) AS total_spent
FROM orders
WHERE user_id = 999;  -- 해당 주문 없음
-- SUM → NULL, COALESCE → 0

GROUP BY

GROUP BY는 지정한 컬럼의 값이 같은 행들을 하나의 그룹으로 묶습니다. 각 그룹에 대해 집계 함수가 적용되어, 그룹당 하나의 결과 행이 생성됩니다.

GROUP BY 기본
-- 카테고리별 통계
SELECT category,
       COUNT(*) AS 상품수,
       AVG(price) AS 평균가격,
       SUM(stock) AS 총재고
FROM products
GROUP BY category;
GROUP BY 동작 과정
원본 데이터
  id=1 | category=전자 | price=150000 | stock=10
  id=2 | category=전자 | price=50000  | stock=30
  id=3 | category=식품 | price=5000   | stock=100
  id=4 | category=식품 | price=3000   | stock=200

GROUP BY category
  그룹 '전자': [행1, 행2]  → 2개, 평균 100000, 재고 40
  그룹 '식품': [행3, 행4]  → 2개, 평균 4000, 재고 300

결과
  category | 상품수 | 평균가격 | 총재고
  전자     | 2     | 100000  | 40
  식품     | 2     | 4000    | 300

GROUP BY 규칙 (매우 중요)

SELECT에 집계 함수가 아닌 컬럼이 있으면, 그 컬럼은 반드시 GROUP BY에 포함되어야 합니다.
올바른 GROUP BY
-- ✓ category가 GROUP BY에 있으므로 SELECT에 사용 가능
SELECT category, AVG(price)
FROM products
GROUP BY category;

-- ✓ 여러 컬럼으로 그룹화
SELECT category, brand, AVG(price)
FROM products
GROUP BY category, brand;
잘못된 GROUP BY
-- ✗ name이 GROUP BY에 없음 → 오류
SELECT name, AVG(price)
FROM products
GROUP BY category;
-- ERROR: 'name' is not in GROUP BY clause

왜 이 규칙이 필요할까요? category='전자'인 그룹에 여러 상품이 있는데, 그중 어떤 상품의 name을 반환해야 할지 알 수 없기 때문입니다. 그룹 내에서 하나의 확정된 값이 아닌 컬럼은 SELECT에 올 수 없습니다.

규칙의 이유
그룹 '전자': name=노트북, name=마우스
  → AVG(price) = 100000 (확정)
  → name = ? (노트북? 마우스? 불확정!)
  → 오류 발생

MySQL의 ONLY_FULL_GROUP_BY 모드가 꺼져 있으면 이 오류가 발생하지 않고 임의의 값이 반환됩니다. 이는 잘못된 결과의 원인이므로, ONLY_FULL_GROUP_BY를 항상 켜두는 것을 권장합니다.

다중 컬럼 GROUP BY

여러 컬럼으로 그룹화하면 컬럼1의 값 + 컬럼2의 값의 조합으로 그룹이 결정됩니다.

다중 컬럼 GROUP BY
-- 연도별, 카테고리별 매출 통계
SELECT YEAR(order_date) AS 연도,
       p.category AS 카테고리,
       SUM(oi.quantity * oi.unit_price) AS 매출
FROM orders o
INNER JOIN order_items oi ON o.order_id = oi.order_id
INNER JOIN products p ON oi.product_id = p.product_id
GROUP BY YEAR(order_date), p.category
ORDER BY 연도, 매출 DESC;
결과 예시
연도  | 카테고리 | 매출
------|---------|----------
2023  | 전자    | 12000000
2023  | 식품    | 3000000
2024  | 전자    | 15000000
2024  | 식품    | 4500000

HAVING

HAVING은 GROUP BY로 만들어진 그룹을 필터링합니다. WHERE가 개별 행을 필터링한다면, HAVING은 그룹을 필터링합니다.

WHERE vs HAVING

실행 순서
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY

WHERE:  그룹화하기 전에 행을 필터링 (개별 행 대상)
HAVING: 그룹화한 후에 그룹을 필터링 (그룹 대상)
WHERE와 HAVING 함께 사용
SELECT category, AVG(price) AS avg_price
FROM products
WHERE stock > 0              -- 먼저: 재고 있는 상품만 (행 필터링)
GROUP BY category
HAVING AVG(price) > 50000;   -- 이후: 평균 가격 5만 이상인 그룹만 (그룹 필터링)
실행 과정
1. FROM products           → 전체 6개 행
2. WHERE stock > 0         → 재고 없는 행 제거 → 5개 행
3. GROUP BY category       → 카테고리별 그룹 생성 → 3개 그룹
4. HAVING AVG(price) > 50000 → 조건 미달 그룹 제거 → 1개 그룹
5. SELECT category, AVG()  → 결과 행 생성

HAVING의 조건

HAVING 절에는 집계 함수GROUP BY에 포함된 컬럼만 사용할 수 있습니다.

HAVING 사용 예시
-- 상품이 2개 이상인 카테고리만
SELECT category, COUNT(*) AS cnt
FROM products
GROUP BY category
HAVING COUNT(*) >= 2;

-- 총 매출 100만 이상이고 주문 수 10건 이상인 회원
SELECT u.username,
       SUM(o.total_amount) AS total,
       COUNT(o.order_id) AS orders
FROM users u
INNER JOIN orders o ON u.user_id = o.user_id
GROUP BY u.username
HAVING SUM(o.total_amount) >= 1000000
   AND COUNT(o.order_id) >= 10;

WHERE vs HAVING 선택 기준

집계 함수 없이 필터링할 수 있는 조건은 WHERE에 쓰는 것이 성능상 유리합니다. WHERE는 그룹화 전에 행을 줄이므로 처리할 데이터가 줄어들기 때문입니다.

HAVING 대신 WHERE 사용 (성능 개선)
-- ✗ 비효율: 모든 행을 그룹화한 후 카테고리 필터링
SELECT category, AVG(price)
FROM products
GROUP BY category
HAVING category = '전자';

-- ✓ 효율: 먼저 행을 줄인 후 그룹화
SELECT category, AVG(price)
FROM products
WHERE category = '전자'
GROUP BY category;

GROUP BY와 정렬

GROUP BY의 결과 순서는 보장되지 않습니다. 특정 순서가 필요하면 반드시 ORDER BY를 추가해야 합니다.

GROUP BY + ORDER BY
-- 매출이 높은 카테고리 순서로 정렬
SELECT category, SUM(price * stock) AS estimated_value
FROM products
GROUP BY category
ORDER BY estimated_value DESC;

일부 DBMS(특히 MySQL의 이전 버전)에서는 GROUP BY가 자동으로 정렬하는 것처럼 보이지만, 이는 보장되지 않는 동작입니다. 항상 명시적으로 ORDER BY를 사용해야 합니다.


GROUP BY와 NULL

NULL 값은 GROUP BY에서 하나의 그룹으로 취급됩니다. 즉, category가 NULL인 모든 행은 하나의 NULL 그룹으로 묶입니다.

NULL 그룹
-- category가 NULL인 상품도 하나의 그룹으로
SELECT category, COUNT(*) AS cnt
FROM products
GROUP BY category;
결과
category | cnt
---------|----
전자     | 2
식품     | 3
NULL     | 1    ← NULL도 하나의 그룹

ROLLUP, CUBE, GROUPING SETS

그룹화의 여러 수준에서 동시에 집계하는 확장 기능입니다.

ROLLUP

소계와 총계를 자동으로 생성합니다. 컬럼 순서대로 오른쪽에서부터 하나씩 제거하며 집계합니다.

ROLLUP
SELECT category, brand, SUM(price) AS total_price
FROM products
GROUP BY ROLLUP(category, brand);
결과
category | brand  | total_price
---------|--------|------------
전자     | 삼성    | 150000      ← 전자+삼성 소계
전자     | LG     | 100000      ← 전자+LG 소계
전자     | NULL   | 250000      ← 전자 소계 (brand 제거)
식품     | CJ     | 8000        ← 식품+CJ 소계
식품     | NULL   | 8000        ← 식품 소계
NULL     | NULL   | 258000      ← 전체 총계 (모두 제거)

ROLLUP(A, B, C)는 (A,B,C), (A,B), (A), () 네 수준의 집계를 생성합니다.

CUBE

가능한 모든 조합의 집계를 생성합니다.

CUBE
SELECT category, brand, SUM(price) AS total_price
FROM products
GROUP BY CUBE(category, brand);

CUBE(A, B)는 (A,B), (A), (B), () 네 수준의 집계를 생성합니다. ROLLUP보다 더 많은 집계 행을 생성합니다.

GROUPING SETS

원하는 그룹 조합만 지정할 수 있습니다.

GROUPING SETS
SELECT category, brand, SUM(price) AS total_price
FROM products
GROUP BY GROUPING SETS (
    (category, brand),   -- 카테고리+브랜드별
    (category),          -- 카테고리별
    ()                   -- 전체
);

GROUPING 함수

소계/총계 행에서 NULL이 데이터의 NULL인지 집계로 인한 NULL인지 구분하기 위해 GROUPING 함수를 사용합니다.

GROUPING 함수
SELECT CASE WHEN GROUPING(category) = 1 THEN '전체'
            ELSE COALESCE(category, '미분류')
       END AS category,
       SUM(price) AS total_price
FROM products
GROUP BY ROLLUP(category);

집계 함수와 DISTINCT

집계 함수 안에서 DISTINCT를 사용하면 중복을 제거한 후 집계합니다.

집계와 DISTINCT
-- 주문한 적 있는 고유 회원 수
SELECT COUNT(DISTINCT user_id) AS unique_customers
FROM orders;

-- 카테고리별 고유 브랜드 수
SELECT category, COUNT(DISTINCT brand) AS brand_count
FROM products
GROUP BY category;

실무 집계 패턴

조건부 집계 (CASE + 집계)

조건부 집계
-- 상태별 주문 수를 한 행으로 피벗
SELECT COUNT(CASE WHEN status = 'PAID' THEN 1 END) AS paid,
       COUNT(CASE WHEN status = 'SHIPPED' THEN 1 END) AS shipped,
       COUNT(CASE WHEN status = 'DELIVERED' THEN 1 END) AS delivered
FROM orders;
결과
paid | shipped | delivered
-----|---------|----------
15   | 8       | 42

비율/퍼센트 계산

비율 계산
-- 카테고리별 매출 비율
SELECT category,
       SUM(price * stock) AS value,
       ROUND(SUM(price * stock) * 100.0 /
             (SELECT SUM(price * stock) FROM products), 1) AS pct
FROM products
GROUP BY category
ORDER BY pct DESC;

TOP-N 그룹

매출 상위 3개 카테고리
SELECT category, SUM(total_amount) AS revenue
FROM orders o
INNER JOIN products p ON o.product_id = p.product_id
GROUP BY category
ORDER BY revenue DESC
LIMIT 3;  -- MySQL, PostgreSQL
-- SQL Server: TOP 3, Oracle: ROWNUM <= 3 또는 FETCH FIRST 3 ROWS ONLY

날짜 기반 집계

월별 매출 통계
-- MySQL
SELECT DATE_FORMAT(order_date, '%Y-%m') AS month,
       COUNT(*) AS order_count,
       SUM(total_amount) AS revenue
FROM orders
GROUP BY DATE_FORMAT(order_date, '%Y-%m')
ORDER BY month;

-- PostgreSQL
SELECT TO_CHAR(order_date, 'YYYY-MM') AS month,
       COUNT(*) AS order_count,
       SUM(total_amount) AS revenue
FROM orders
GROUP BY TO_CHAR(order_date, 'YYYY-MM')
ORDER BY month;

-- Oracle
SELECT TO_CHAR(order_date, 'YYYY-MM') AS month,
       COUNT(*) AS order_count,
       SUM(total_amount) AS revenue
FROM orders
GROUP BY TO_CHAR(order_date, 'YYYY-MM')
ORDER BY month;

연속 범위 집계

가격대별 상품 수
SELECT CASE
         WHEN price < 10000 THEN '1만원 미만'
         WHEN price < 50000 THEN '1~5만원'
         WHEN price < 100000 THEN '5~10만원'
         ELSE '10만원 이상'
       END AS price_range,
       COUNT(*) AS product_count
FROM products
GROUP BY CASE
         WHEN price < 10000 THEN '1만원 미만'
         WHEN price < 50000 THEN '1~5만원'
         WHEN price < 100000 THEN '5~10만원'
         ELSE '10만원 이상'
       END
ORDER BY MIN(price);

흔한 실수 정리

GROUP BY 관련 실수
1. SELECT에 GROUP BY에 없는 컬럼 사용
   → SELECT name, AVG(price) ... GROUP BY category → 오류
   → ONLY_FULL_GROUP_BY 모드 활성화 필수

2. HAVING에서 별칭 사용
   → HAVING avg_price > 50000  (일부 DBMS에서 오류)
   → HAVING AVG(price) > 50000 (집계 함수 직접 사용)

3. WHERE에서 집계 함수 사용
   → WHERE COUNT(*) >= 2  (오류: WHERE는 행 단위)
   → HAVING COUNT(*) >= 2 (정상: HAVING은 그룹 단위)

4. GROUP BY 결과 순서 의존
   → GROUP BY만으로 정렬 보장 안 됨
   → 반드시 ORDER BY 추가

5. COUNT(*)와 COUNT(col) 혼동
   → LEFT JOIN 집계에서 특히 주의

GROUP BY 전체 정리

GROUP BY 핵심 요약
┌─────────────────────────────────────────────────────────┐
│ 집계 함수: COUNT, SUM, AVG, MAX, MIN                    │
│   COUNT(*) → NULL 포함     COUNT(col) → NULL 제외       │
│   AVG, SUM → NULL 무시     모든 값 NULL → 결과 NULL     │
├─────────────────────────────────────────────────────────┤
│ GROUP BY: 같은 값의 행을 그룹으로 묶음                  │
│   규칙: SELECT의 비집계 컬럼은 GROUP BY에 필수          │
│   NULL: 하나의 NULL 그룹으로 취급                       │
│   정렬: 보장되지 않음 → ORDER BY 필수                   │
├─────────────────────────────────────────────────────────┤
│ WHERE : 개별 행 필터 (GROUP BY 전)                      │
│ HAVING: 그룹 필터 (GROUP BY 후)                         │
│   → 집계 없는 조건은 WHERE에 쓰는 것이 효율적           │
├─────────────────────────────────────────────────────────┤
│ 확장: ROLLUP(소계/총계), CUBE(모든 조합), GROUPING SETS │
│ 실행순서: FROM → WHERE → GROUP BY → HAVING → SELECT     │
└─────────────────────────────────────────────────────────┘

다음 절에서는 그룹화하면서도 행을 유지하는 윈도우 함수를 다루겠습니다.

목차