본문 바로가기
TYLER SONGBlog
← Blog index
CS7 min read

GROUP BY: 행을 그룹으로 묶어 집계 함수로 요약하는 연산

GROUP BY는 지정한 컬럼 값이 같은 행들을 하나의 그룹으로 묶어, 그룹마다 집계 함수(COUNT, SUM, AVG 등)를 적용해 한 행으로 요약하는 SQL 절이다. 논리적 실행 순서(WHERE → GROUP BY → HAVING → SELECT)와 PostgreSQL이 그룹핑을 구현하는 방식(HashAggregate, GroupAggregate)을 이

By 송민성

1. 개념

GROUP BY는 하나 이상의 컬럼(또는 표현식) 값이 같은 행들을 하나의 그룹(group)으로 묶는 SQL 절이다. 결과는 그룹당 정확히 한 행이며, 각 그룹에는 집계 함수(aggregate function)를 적용할 수 있다.

주요 집계 함수는 다음과 같다.

함수설명
COUNT(*)그룹의 행 수
COUNT(col)col이 NULL이 아닌 행 수
SUM(col)합계
AVG(col)평균
MIN(col), MAX(col)최솟값, 최댓값
STRING_AGG(col, sep)문자열 연결 (PostgreSQL)

집계 함수 없이 GROUP BY만 쓰면 SELECT DISTINCT와 비슷하게 중복이 제거된 결과를 얻는다.

2. 왜 사용하는가

애플리케이션에서 "사용자별 주문 수", "일별 매출", "카테고리별 평균 가격" 같은 통계를 구할 때, 모든 행을 서버로 가져와 코드에서 합산하면 네트워크 전송량과 메모리 사용량이 커진다. GROUP BY를 사용하면 데이터베이스 엔진 안에서 집계를 끝내고 요약된 결과만 반환할 수 있다.

  • 전송 데이터 감소: 수백만 행 대신 그룹 수만큼의 행만 받는다.
  • 인덱스와 옵티마이저 활용: 엔진이 정렬, 해시, 인덱스 스캔 중 적절한 방식을 선택한다.
  • 선언적 표현: "어떻게 합산할지"가 아니라 "무엇으로 묶을지"만 기술한다.

3. 동작 원리

논리적 실행 순서

SQL은 작성 순서와 논리적 평가 순서가 다르다.

  1. FROM / JOIN: 대상 행 결정
  2. WHERE: 그룹핑 전에 행 필터링
  3. GROUP BY: 행을 그룹으로 묶음
  4. 집계 함수 계산
  5. HAVING: 그룹 단위 필터링
  6. SELECT: 출력 컬럼 계산
  7. ORDER BY, LIMIT

이 순서 때문에 WHERE에는 집계 함수를 쓸 수 없고(그룹이 아직 없다), 그룹 조건은 HAVING에 써야 한다. 표준 SQL에서는 SELECT의 별칭(alias)을 HAVING에서 쓸 수 없다. PostgreSQL도 GROUP BY, ORDER BY에서는 출력 컬럼 별칭을 허용하지만 HAVING에서는 허용하지 않는다.

SELECT 목록의 제약

SELECT에는 GROUP BY에 명시한 컬럼, 집계 함수, 또는 그룹 컬럼에 함수적으로 종속된 컬럼만 쓸 수 있다. PostgreSQL은 GROUP BY에 기본키(primary key)를 포함하면 같은 테이블의 다른 컬럼을 별도 명시 없이 쓰도록 허용한다.

물리적 구현

PostgreSQL 옵티마이저는 주로 두 가지 방식으로 그룹핑한다.

  • HashAggregate: 그룹 키를 해시 테이블에 넣고 키별로 집계 상태를 누적한다. 입력이 정렬될 필요가 없다. 해시 테이블이 work_mem을 넘으면 PostgreSQL 13부터 디스크로 스필(spill)할 수 있다.
  • GroupAggregate: 입력이 그룹 키 순으로 정렬되어 있어야 한다. 정렬된 입력을 순차로 읽으며 키가 바뀔 때 그룹을 마감한다. 명시적 정렬(Sort)을 하거나, 그룹 키에 맞는 인덱스 스캔으로 이미 정렬된 입력을 쓸 수 있다.

어떤 방식을 쓸지는 통계 정보와 비용 추정으로 옵티마이저가 결정한다.

4. 코드 예제

예제용 테이블과 데이터를 만든다.

sql
CREATE TABLE orders ( id SERIAL PRIMARY KEY, user_id INT NOT NULL, category TEXT NOT NULL, amount NUMERIC(10,2) NOT NULL, status TEXT NOT NULL, ordered_at DATE NOT NULL ); INSERT INTO orders (user_id, category, amount, status, ordered_at) VALUES (1, 'book', 15000, 'paid', '2026-09-01'), (1, 'book', 22000, 'paid', '2026-09-03'), (1, 'food', 8000, 'cancelled', '2026-09-03'), (2, 'food', 12000, 'paid', '2026-09-01'), (2, 'toy', 30000, 'paid', '2026-09-02'), (3, 'book', 18000, 'paid', '2026-09-02'), (3, 'toy', 25000, 'paid', '2026-09-03'), (3, 'toy', 27000, 'refunded', '2026-09-03');

기본 집계: 카테고리별 주문 수와 매출.

sql
SELECT category, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders GROUP BY category ORDER BY total_amount DESC;

WHERE와 HAVING의 차이: 결제 완료 주문만 대상으로 하고(WHERE), 합계가 30000 이상인 사용자만 남긴다(HAVING).

sql
SELECT user_id, SUM(amount) AS total_amount FROM orders WHERE status = 'paid' GROUP BY user_id HAVING SUM(amount) >= 30000;

여러 컬럼으로 그룹핑: 일자와 카테고리 조합별 집계.

sql
SELECT ordered_at, category, COUNT(*) AS cnt FROM orders GROUP BY ordered_at, category ORDER BY ordered_at, category;

FILTER 절로 조건부 집계: 한 번의 스캔으로 상태별 합계를 컬럼으로 나눈다.

sql
SELECT user_id, SUM(amount) FILTER (WHERE status = 'paid') AS paid_amount, SUM(amount) FILTER (WHERE status = 'cancelled') AS cancelled_amount, COUNT(*) FILTER (WHERE status = 'refunded') AS refunded_cnt FROM orders GROUP BY user_id ORDER BY user_id;

ROLLUP으로 소계와 총계 만들기. 소계 행에서는 해당 컬럼이 NULL로 나오며, GROUPING()으로 실제 NULL과 구분한다.

sql
SELECT category, ordered_at, SUM(amount) AS total_amount, GROUPING(category, ordered_at) AS grp_level FROM orders WHERE status = 'paid' GROUP BY ROLLUP (category, ordered_at) ORDER BY category NULLS LAST, ordered_at NULLS LAST;

실행 계획 확인.

sql
EXPLAIN ANALYZE SELECT category, COUNT(*) FROM orders GROUP BY category;

데이터가 적으면 보통 HashAggregate가 보이지만, 통계와 인덱스 상태에 따라 GroupAggregate가 선택될 수도 있다.

5. 시간 복잡도 또는 성능 특성

입력 행 수를 n, 그룹 수를 g라 하자.

방식시간 복잡도특징
해시 기반평균 O(n)정렬 불필요. 해시 테이블 메모리는 그룹 수 g에 비례
정렬 기반O(n log n)정렬 비용 발생. 이미 정렬된 입력(인덱스 스캔)이면 그룹핑 자체는 O(n)

성능에 영향을 주는 요소는 다음과 같다.

  • 그룹 수(카디널리티, cardinality): g가 크면 해시 테이블이 커져 work_mem 초과로 디스크 사용이 늘 수 있다.
  • WHERE로 먼저 행을 줄이면 그룹핑 대상 n이 줄어든다.
  • 그룹 키에 맞는 인덱스가 있으면 정렬 단계를 생략한 GroupAggregate가 가능하다. 다만 옵티마이저가 항상 인덱스를 선택하는 것은 아니다.
  • 대용량 테이블에서는 병렬 집계(Partial/Finalize Aggregate)가 쓰일 수 있다.

6. 실무 사용 사례

  • 대시보드 통계: 일별 가입자 수, 카테고리별 매출. 날짜 단위로 묶을 때는 DATE_TRUNC('day', created_at)을 쓴다.
  • 중복 탐지: 같은 이메일이 두 번 이상 있는 행 찾기.
sql
SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id HAVING COUNT(*) > 1;
  • 그룹별 상위 N개나 최신 행: 단순 GROUP BY로는 "그룹 내 최신 행 전체"를 가져올 수 없다. 이 경우 DISTINCT ON이나 윈도우 함수(ROW_NUMBER() OVER (PARTITION BY ...))를 쓴다.
  • 집계 테이블 사전 계산: 실시간 집계가 느린 경우 일별 요약 테이블이나 materialized view에 결과를 저장한다.

7. 주의할 점

  1. SELECT에 비집계 컬럼 쓰기: GROUP BY에 없는 컬럼을 집계 없이 선택하면 PostgreSQL은 오류를 낸다. MySQL은 설정(ONLY_FULL_GROUP_BY)에 따라 허용하는 경우가 있어 DB 간 이식성 문제가 생긴다.
  2. WHERE와 HAVING 혼동: 행 단위 조건은 WHERE에 쓴다. HAVING에 쓰면 그룹핑 후에 걸러 불필요한 작업을 한다.
  3. **COUNT(*)와 COUNT(col)의 차이**: COUNT(col)은 NULL을 세지 않는다. 집계 함수들은 대체로 NULL 입력을 무시하며, 모든 입력이 NULL이면 SUM은 NULL을 반환한다(COALESCE로 처리).
  4. NULL 그룹: GROUP BY에서 NULL 값들은 서로 같은 그룹으로 묶인다.
  5. JOIN 후 집계 시 중복 계산: 1:N 조인으로 행이 늘어난 상태에서 SUM을 하면 값이 부풀려진다. 집계를 먼저 하고 조인하거나 서브쿼리를 사용한다.
  6. 그룹 순서 비보장: ORDER BY 없이 GROUP BY 결과의 순서에 의존하면 안 된다. 해시 집계는 임의 순서로 결과를 낸다.
  7. 고카디널리티 그룹 키: 그룹 수가 입력 행 수에 가까우면 집계의 요약 효과가 없고 메모리 비용만 커진다.

8. 핵심 정리

  • GROUP BY는 같은 키를 가진 행을 묶어 그룹당 한 행으로 요약하며, 집계 함수와 함께 쓴다.
  • 논리적 실행 순서는 FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY이다. 행 조건은 WHERE, 그룹 조건은 HAVING에 쓴다.
  • SELECT에는 그룹 키, 집계 함수, 그룹 키에 함수적으로 종속된 컬럼만 쓸 수 있다.
  • PostgreSQL은 HashAggregate(평균 O(n))나 GroupAggregate(정렬 필요, 정렬 시 O(n log n))로 구현하며, EXPLAIN ANALYZE로 확인한다.
  • FILTER, ROLLUP, GROUPING을 쓰면 조건부 집계와 소계/총계를 한 쿼리로 처리할 수 있다.
  • NULL 처리, 조인 후 중복 집계, 결과 순서 비보장에 주의한다.