SQL 4회차 : 집계 함수와 GROUP BY (COUNT/SUM/AVG/MIN/MAX, GROUP BY/HAVING)


SQL 4회차 : 집계 함수와 GROUP BY (COUNT/SUM/AVG/MIN/MAX, GROUP BY/HAVING) + 실습 2개

한눈에 보는 요약

집계 함수는 여러 행을 하나의 숫자로 요약합니다. 예를 들어 주문 테이블에서 “총 주문 수(COUNT)”, “총 매출(SUM)”, “평균 단가(AVG)”, “최소/최대값(MIN/MAX)” 같은 지표를 만들 수 있습니다.

GROUP BY는 데이터를 “묶는 기준”입니다. 일별, 카테고리별, 사용자별처럼 기준을 정해 그룹을 만들고, 각 그룹마다 집계 함수를 계산합니다.

HAVING은 GROUP BY로 만들어진 “그룹 결과”를 조건으로 거르는 필터입니다. WHERE는 행을 거르고, HAVING은 그룹을 거른다는 차이를 이번 글에서 확실히 정리합니다.

목차


1. 집계 함수 핵심(COUNT/SUM/AVG/MIN/MAX)

집계 함수는 여러 행을 한 값으로 “요약”합니다. 여기서 초보자분들이 반드시 알아야 하는 핵심은 NULL 처리COUNT(*) vs COUNT(컬럼) 차이입니다.

집계 함수 요약 표(초보자 필수)

함수 의미 NULL 처리 자주 쓰는 예시
COUNT(*) 행 개수 NULL과 무관(행 자체를 셈) 총 주문 행 수, 총 로그 수
COUNT(col) col이 NULL이 아닌 행 개수 NULL은 제외 이메일이 있는 사용자 수
COUNT(DISTINCT col) 중복 제거 후 개수 대부분 NULL은 제외(DB별 세부 동작은 확인 권장) 구매한 사용자 수(유니크)
SUM(col) 합계 NULL은 제외 총 매출, 총 수량
AVG(col) 평균 NULL은 제외 평균 단가, 평균 주문 금액
MIN(col) / MAX(col) 최소/최대 NULL은 제외 최초 가입일, 최고 가격

특히 COUNT(*)는 “행을 세는 것”이고, COUNT(컬럼)은 “그 컬럼 값이 존재하는 행만 세는 것”입니다. 이 차이를 모르고 쓰면, 결과가 예상보다 작거나 크게 나오는 경우가 자주 발생합니다.

2. GROUP BY 규칙: 무엇을 기준으로 묶을지 먼저 정합니다

GROUP BY는 “어떤 기준으로 묶을지”를 지정합니다. 일별로 보고 싶으면 날짜를 기준으로 묶고, 카테고리별로 보고 싶으면 카테고리를 기준으로 묶습니다.

가장 중요한 규칙은 다음 한 줄로 정리됩니다.

SELECT에 집계 함수가 아닌 컬럼을 쓰려면, 그 컬럼은 GROUP BY에 포함되어야 합니다.

예를 들어 “카테고리 + 평균 가격”을 보고 싶다면 category는 GROUP BY에 있어야 하고, price는 AVG로 집계되어야 합니다. 반대로 category를 SELECT에 넣어놓고 GROUP BY를 빼먹으면 대부분의 DB에서 에러가 나거나(표준), DB 설정에 따라 애매한 결과가 나올 수 있어 추천되지 않습니다.

3. HAVING vs WHERE: 초보자가 가장 많이 헷갈리는 지점

WHERE는 그룹화 전에 행을 필터링합니다. HAVING은 그룹화 후에 집계 결과(예: SUM, COUNT)를 기준으로 그룹을 필터링합니다.

쿼리 실행 흐름(암기용)

단계 역할 대표 키워드
1 대상 행 가져오기/조인 FROM, JOIN
2 행 필터링(그룹화 전) WHERE
3 그룹 만들기 GROUP BY
4 그룹 필터링(집계 후) HAVING
5 결과 정렬/자르기 ORDER BY, LIMIT/FETCH

따라서 “최근 7일만” 같은 조건은 WHERE에, “매출이 100만원 이상인 날만” 같은 조건은 HAVING에 두는 것이 기본입니다.


4. 실습 1: “일별 주문 수/매출”

실무에서 가장 많이 나오는 리포트가 “일별 지표”입니다. 여기서 초보자분들이 자주 실수하는 포인트는 주문 수를 COUNT(*)로 세는 것입니다. 주문(order)과 주문상세(order_items)를 JOIN하면, 주문 1건이 상품 라인 수만큼 늘어나기 때문에 COUNT(*)는 “주문 수”가 아니라 “주문 라인 수”가 됩니다.

주문 수를 정확히 세려면 COUNT(DISTINCT o.order_id)처럼 고유 주문 ID를 기준으로 세는 방식이 안전합니다.

-- (예시 스키마)
-- orders(order_id, user_id, ordered_at)
-- order_items(order_item_id, order_id, product_id, quantity, unit_price)

-- 일별 주문 수/매출(기본형)
SELECT
  DATE(o.ordered_at) AS order_date,
  COUNT(DISTINCT o.order_id) AS orders_cnt,
  SUM(oi.quantity * oi.unit_price) AS total_revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.ordered_at >= '2025-12-01'
  AND o.ordered_at <  '2026-01-01'
GROUP BY DATE(o.ordered_at)
ORDER BY order_date ASC;

-- 일별 매출이 1,000,000 이상인 날만 보고 싶다면(HAVING 사용)
SELECT
  DATE(o.ordered_at) AS order_date,
  COUNT(DISTINCT o.order_id) AS orders_cnt,
  SUM(oi.quantity * oi.unit_price) AS total_revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.ordered_at >= '2025-12-01'
  AND o.ordered_at <  '2026-01-01'
GROUP BY DATE(o.ordered_at)
HAVING SUM(oi.quantity * oi.unit_price) >= 1000000
ORDER BY total_revenue DESC;

위 쿼리는 “기간(WHERE) → 일별 그룹(GROUP BY) → 매출 조건(HAVING) → 정렬(ORDER BY)” 흐름이 깔끔하게 들어가 있습니다. 이 순서를 습관처럼 고정해두면 복잡한 리포트 쿼리도 안정적으로 만들 수 있습니다.

5. 실습 2: “카테고리별 평균 가격”

“카테고리별 평균 가격”은 두 가지 의미로 해석될 수 있습니다. (1) 상품 마스터(products)에 등록된 정가/표준가의 평균, (2) 주문 데이터(order_items)를 기반으로 한 평균 판매 단가입니다. 초보 단계에서는 (1)부터 확실히 잡고, 필요하면 (2)로 확장하는 흐름이 좋습니다.

-- (예시 스키마)
-- products(product_id, product_name, category, price)

-- (1) 상품 마스터 기준: 카테고리별 평균 가격
SELECT
  category,
  AVG(price) AS avg_price,
  MIN(price) AS min_price,
  MAX(price) AS max_price,
  COUNT(*) AS products_cnt
FROM products
GROUP BY category
ORDER BY avg_price DESC;

-- (2) 판매 데이터 기준: 카테고리별 평균 판매 단가(주문상세의 unit_price 사용)
-- products(product_id, category), order_items(product_id, unit_price, quantity) 가 있다고 가정
SELECT
  p.category,
  AVG(oi.unit_price) AS avg_sold_unit_price,
  SUM(oi.quantity) AS total_qty
FROM order_items oi
JOIN products p ON p.product_id = oi.product_id
GROUP BY p.category
ORDER BY avg_sold_unit_price DESC;

평균(AVG)은 NULL을 제외하고 계산되는 경우가 일반적입니다. 가격 데이터에 NULL이 들어갈 수 있는 구조라면, “NULL이 왜 생기는지(미입력/미정/품절 등)”를 먼저 확인하고, 필요하면 COALESCE로 기본값을 채우는 전략을 고려하시면 됩니다.

6. DB별 날짜 자르기/문법 차이 표

일별 집계를 만들 때 가장 많이 막히는 부분이 “타임스탬프를 날짜로 자르는 방법”입니다. 아래 표에서 본인 DB에 맞는 표현을 선택해 DATE(...) 부분만 교체하시면 됩니다.

DB 일별 그룹 키 예시 비고
MySQL / MariaDB DATE(ordered_at) 가장 단순한 형태로 자주 사용합니다.
PostgreSQL DATE(ordered_at) 또는 DATE_TRUNC('day', ordered_at) 시간대/타입에 따라 캐스팅을 추가할 수 있습니다.
SQL Server CAST(ordered_at AS date) date 타입으로 변환해 그룹화합니다.
Oracle TRUNC(ordered_at) 날짜(일 단위)로 절삭합니다.
SQLite DATE(ordered_at) 문자열/시간 형식에 따라 포맷을 맞춰야 할 수 있습니다.

7. 따라하기: 20분 실습 루틴

  1. (1) 테이블과 컬럼명을 확인합니다. orders의 주문 시간(ordered_at)과 order_items의 수량(quantity), 단가(unit_price)가 어디에 있는지 먼저 확인하세요.

  2. (2) “일별 매출”부터 만듭니다. DATE(ordered_at)로 날짜를 만들고, SUM(quantity * unit_price)로 매출을 계산합니다. 먼저 매출만 성공시키면 이후 확장이 쉬워집니다.

  3. (3) 주문 수는 COUNT(DISTINCT order_id)로 안정화합니다. JOIN이 들어간 순간 COUNT(*)는 의미가 달라질 수 있습니다. “무엇을 세고 싶은가(주문/라인/상품)”를 문장으로 적고 함수에 반영하세요.

  4. (4) HAVING으로 조건을 붙여봅니다. “매출이 일정 금액 이상인 날만”, “주문 수가 10건 이상인 날만” 등 집계 결과 기반 필터를 직접 만들어보면 HAVING 감각이 빠르게 잡힙니다.

  5. (5) 카테고리별 평균 가격으로 확장합니다. products 기준(정가 평균) → order_items 기준(평균 판매 단가) 순서로 확장하면 의미가 자연스럽게 연결됩니다.

8. 자주 하는 실수 체크리스트

  • GROUP BY 누락: category를 SELECT에 넣고 AVG(price)만 쓰면 대부분 에러가 납니다.
  • WHERE에 집계 조건 작성: WHERE SUM(...) > 0 같은 형태는 불가능합니다. 집계 조건은 HAVING으로 옮기세요.
  • 주문 수를 COUNT(*)로 계산: order_items를 JOIN했다면 COUNT(*)는 “주문 라인 수”가 됩니다. 주문 수는 COUNT(DISTINCT order_id)를 기본으로 두세요.
  • 날짜 그룹 키를 DB 문법과 다르게 사용: DATE/CAST/TRUNC/DATE_TRUNC 중 본인 DB에 맞는 것을 사용해야 합니다.
  • 지표 정의가 모호함: “매출”이 결제금액인지(할인/쿠폰/반품 포함) 먼저 정의해야 쿼리도 흔들리지 않습니다.

추가로 생각해볼 점

  • 집계는 ‘정의’가 먼저입니다. 같은 “매출”이라도 기준이 다르면 쿼리가 달라집니다. 지표 정의를 문장으로 먼저 적어두면 실수가 줄어듭니다.
  • 대용량에서는 인덱스/파티션이 성능을 좌우합니다. 특히 기간 조건(WHERE ordered_at ...)은 인덱스가 있으면 체감이 크게 달라집니다.
  • COUNT(DISTINCT)는 비용이 클 수 있습니다. 정확도가 필요하면 쓰되, 성능이 문제라면 요약 테이블(일별 집계 테이블) 전략도 함께 검토해볼 만합니다.


이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

Reactions

댓글 쓰기

0 댓글