숏컷 코드
SELECT user_id, COUNT(*) AS post_count
FROM posts
GROUP BY user_id
HAVING COUNT(*) >= 5;문법
GROUP BY는 행을 묶어 집계 단위를 만든다
GROUP BY는 지정한 열의 같은 값을 가진 행들을 하나의 그룹으로 묶습니다. 이후 COUNT, SUM, AVG, MAX, MIN 같은 집계 함수는 각 그룹 내 행에 대해 계산됩니다. SELECT에 나열하는 열은 반드시 GROUP BY에 포함되거나 집계 함수로 감싸야 합니다. PostgreSQL에서 이 규칙을 어기면 오류가 발생합니다. 그룹 기준과 맞는 인덱스가 있으면 정렬된 입력을 활용하는 집계 경로가 유리해질 수 있지만, 실제 선택은 데이터 분포와 비용 추정에 따라 달라집니다.
-- 카테고리별 상품 수와 평균 가격
SELECT
category,
COUNT(*) AS product_count,
AVG(price) AS avg_price
FROM products
GROUP BY category
ORDER BY product_count DESC;집계 쿼리는 GROUP BY와 HAVING 역할을 나눠서 읽으면 된다
먼저 어떤 기준으로 묶을지(GROUP BY), 그 그룹마다 무엇을 계산할지(COUNT, SUM, AVG 등), 계산이 끝난 뒤 어떤 그룹만 남길지(HAVING) 순서로 읽으면 됩니다. 집계 쿼리는 "행 한 줄"보다 "묶음 한 단위"를 다룬다고 보면 구조가 훨씬 단순해집니다.
SELECT user_id, COUNT(*) AS post_count
FROM posts
GROUP BY user_id
HAVING COUNT(*) >= 5;WHERE와 HAVING은 평가 시점이 달라 역할이 분리된다
WHERE는 GROUP BY 이전에 평가됩니다. 즉, 그룹을 만들기 전에 행 단위로 필터링합니다. HAVING은 GROUP BY 이후 집계된 결과에 조건을 겁니다. 집계 함수(COUNT, SUM 등)는 행이 그룹으로 묶인 뒤에만 계산되므로, WHERE 절 안에서 집계 함수를 쓸 수 없습니다. 이 시점 차이를 이해하면 "어떤 조건을 WHERE에, 어떤 조건을 HAVING에 넣을지"가 자연스럽게 결정됩니다.
-- WHERE: 그룹화 전 행 필터 (2026년 주문만)
-- HAVING: 그룹화 후 필터 (합계 10만 원 이상인 사용자만)
SELECT user_id, SUM(amount) AS total
FROM orders
WHERE created_at >= '2026-01-01'
GROUP BY user_id
HAVING SUM(amount) >= 100000;행 조건은 WHERE에, 집계 조건은 HAVING에 둔다
행 단위에서 미리 줄일 수 있는 조건을 HAVING까지 끌고 가면, 불필요하게 큰 그룹을 만든 뒤 다시 버리게 됩니다. 날짜 범위, 상태 값처럼 집계 전에 알 수 있는 조건은 WHERE에 두고, 집계 결과 기준만 HAVING에 두는 편이 읽기와 성능 양쪽에서 더 자연스럽습니다.
-- 집계 전 필터
SELECT user_id, COUNT(*) AS paid_count
FROM orders
WHERE status = 'paid'
GROUP BY user_id
HAVING COUNT(*) >= 3;집계 함수 종류마다 NULL 처리 방식이 다르다
COUNT(*)는 모든 행을 셉니다(NULL 포함). COUNT(column)은 해당 컬럼이 NULL이 아닌 행만 셉니다. SUM, AVG는 NULL 값을 무시합니다. AVG(column)은 NULL을 분모에서도 제외하므로, NULL이 많은 컬럼의 평균은 실제 전체 행 기준 평균과 다릅니다. 의도하는 집계 방식에 따라 COALESCE로 NULL을 0으로 치환하거나, COUNT(*)와 COUNT(col)을 구분해서 씁니다.
SELECT
COUNT(*) AS total_rows, -- NULL 포함 전체 행 수
COUNT(email) AS with_email, -- email이 NULL이 아닌 행 수
AVG(score) AS avg_score, -- NULL 행 제외한 평균
AVG(COALESCE(score, 0)) AS avg_with_zero -- NULL을 0으로 치환한 평균
FROM users;GROUP BY 없이 집계 함수를 쓰면 전체 테이블이 하나의 그룹이 된다
GROUP BY를 생략하면 테이블 전체가 하나의 그룹으로 처리됩니다. 이 경우 SELECT에는 집계 함수만 올 수 있고, 집계되지 않은 일반 열은 넣을 수 없습니다. 전체 합계, 전체 개수, 전체 평균처럼 테이블 전체에 대한 단일 결과가 필요할 때 이 패턴을 씁니다.
-- GROUP BY 없는 전체 집계 (결과 행이 항상 1개)
SELECT
COUNT(*) AS total_orders,
SUM(amount) AS total_revenue,
AVG(amount) AS avg_order_value
FROM orders
WHERE created_at >= '2026-01-01';체크포인트
| 상황 | 적합한 선택 |
|---|---|
| 그룹화 전 행을 걸러낼 때 | WHERE (집계 함수 사용 불가) |
| 그룹화 후 집계 결과를 걸러낼 때 | HAVING (집계 함수 사용 가능) |
| NULL 포함 전체 행 수를 셀 때 | COUNT(*) |
| 특정 컬럼이 NULL이 아닌 행만 셀 때 | COUNT(column) |
주의할 점
WHERE COUNT(*) > 1처럼 WHERE 절에 집계 함수를 쓰면 문법 오류가 발생합니다. 집계 결과에 조건을
걸려면 반드시 HAVING을 써야 합니다. 또한 SELECT에 나열한 열이 GROUP BY에 없고 집계 함수로도
감싸지 않으면 PostgreSQL이 오류를 냅니다. 이 두 제약을 기준으로 쿼리를 짜는 습관이 중요합니다.
SELECT user_id, title, COUNT(*)
FROM posts
GROUP BY user_id;title처럼 그룹 기준에도 없고 집계 함수로 감싸지도 않은 열을 SELECT에 같이 두면 PostgreSQL이 오류를 냅니다. "그룹마다 하나로 결정되지 않는 값"은 그대로 꺼낼 수 없다고 보면 됩니다.
SELECT user_id, COUNT(*) AS post_count
FROM posts
HAVING COUNT(*) >= 5
GROUP BY user_id;HAVING을 GROUP BY 앞에 두는 식으로 흐름을 섞어 쓰면 문법도 틀리고, 집계 쿼리를 읽는 순서도 무너집니다. 집계 쿼리는 항상 FROM -> WHERE -> GROUP BY -> HAVING -> ORDER BY 흐름으로 읽는 편이 안전합니다.
참고 링크
1 sources