infra
Platform

모듈 맵

[Database] GROUP BY와 집계함수의 효율적인 인덱스 활용

0 / 37 완료

펼치기
0 / 37 완료0%

데이터베이스 설계 & 튜닝 · 15 / 37

[Database] GROUP BY와 집계함수의 효율적인 인덱스 활용

COUNT, SUM, AVG, MAX, MIN과 GROUP BY, HAVING으로 데이터를 요약하고 분석합니다

🚨INCIDENT ALERT
HIGH

월간 매출 리포트를 만들 때 단순 SELECT만으로는 답이 나오지 않습니다. 상품별·지역별로 묶어 합계를 내고, 비정상적으로 튀는 그룹을 빠르게 찾아야 합니다. GROUP BY와 HAVING을 정확히 이해하면 대시보드와 정산 쿼리를 훨씬 안전하게 작성할 수 있습니다.

이번 챕터에서 배울 것

GROUP BY는 데이터 분석의 핵심입니다. 실행 순서(WHERE → GROUP BY → HAVING → SELECT → ORDER BY)를 이해하면 어디에 조건을 넣어야 하는지 직관적으로 판단할 수 있습니다.

  • 1COUNT, SUM, AVG, MAX, MIN의 동작과 NULL 처리를 구분하여 정확히 집계할 수 있다
  • 2GROUP BY의 실행 순서를 이해하고 의도한 단위로 그룹을 묶을 수 있다
  • 3HAVING과 WHERE를 구분해 그룹 필터와 행 필터를 올바른 위치에 작성할 수 있다
  • 4월별 매출, 카테고리별 통계 같은 실전 분석 쿼리를 작성할 수 있다
  • 5ROLLUP과 CUBE로 소계와 합계를 자동으로 생성할 수 있다
  • 6FILTER(WHERE ...)로 조건부 집계를 한 쿼리에서 처리할 수 있다

GROUP BY와 집계함수 — 데이터 분석 기초

월별 매출 리포트를 만들어야 했는데, 처음엔 Python으로 짰다. DB에서 전체 주문을 끌어다가 딕셔너리로 월별로 묶고, 합계 계산하고 — 코드가 30줄쯤 됐다. 옆에 앉은 시니어가 보더니 쿼리 하나를 써줬다. SELECT DATE_TRUNC('month', created_at), SUM(amount) FROM orders GROUP BY 1 — 끝이었다. 그 순간 깨달았다, 내가 Python으로 하고 있던 루프와 딕셔너리 작업이 전부 GROUP BY 한 줄로 DB 안에서 처리될 수 있다는 걸. 집계를 애플리케이션 레이어에서 하면 전체 데이터를 네트워크로 옮겨야 하지만, DB 안에서 하면 결과만 나온다. GROUP BY와 집계함수를 모르면 불필요한 데이터를 수십만 건씩 끌어오는 코드를 계속 짜게 된다.


💡개념

집계함수와 GROUP BY — 동작 원리 이해하기

월별 매출 집계 쿼리를 짰는데 총합이 실제 데이터와 다릅니다. GROUP BY에 잘못된 컬럼을 포함시켜서 집계 단위가 달라진 것입니다. 또 NULL이 포함된 컬럼을 COUNT하면 NULL은 세지 않는데 이 동작을 모르면 집계 결과를 잘못 해석합니다. 집계함수와 GROUP BY의 동작 원리를 정확히 알아야 의도한 집계를 얻을 수 있습니다.

집계함수와 GROUP BY — 동작 원리 이해하기확대

집계함수 종류와 NULL 처리

COUNT(*) 는 NULL 여부와 무관하게 모든 행을 셉니다. COUNT(column)은 해당 컬럼 값이 NULL인 행을 제외합니다. SUM, AVG, MIN, MAX는 모두 NULL 값을 무시하고 계산합니다. NULL을 0으로 포함시키려면 COALESCE(column, 0)으로 감싸야 합니다.

SQL
CREATE TABLE orders (
    id         BIGSERIAL PRIMARY KEY,
    user_id    BIGINT NOT NULL,
    category   VARCHAR(50),
    amount     NUMERIC(12, 2),
    status     VARCHAR(20) NOT NULL DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT NOW()
);
OUTPUT
실행 완료 또는 조회 결과가 표시됩니다.
🔍실행 후 확인할 것
  • 행 수 먼저: GROUP BY 결과 행 수와 COUNT(DISTINCT 기준컬럼) 값을 비교 — 같으면 모든 그룹이 유일하게 묶인 것
  • COUNT 차이 판단: COUNT(*)가 COUNT(amount)보다 크면 그 차이만큼 NULL 행이 존재. 5행 중 2행이 NULL이면 COUNT(*)=5, COUNT(amount)=3
  • HAVING이 필요한 시점: WHERE에 COUNT(*) 같은 집계 조건을 쓰면 오류. 집계 결과로 필터해야 하면 반드시 HAVING — WHERE는 GROUP BY 이전, HAVING은 이후에 실행됨
SQL
SELECT
    COUNT(*)                AS 총행수,
    COUNT(amount)           AS 금액있는행수,
    COUNT(DISTINCT user_id) AS 고유사용자수,
    SUM(amount)             AS 총금액,
    AVG(amount)             AS 평균금액,
    MIN(amount)             AS 최소금액,
    MAX(amount)             AS 최대금액,
    SUM(COALESCE(amount, 0)) AS 널을0포함합계
FROM orders;

GROUP BY 실행 순서가 중요한 이유

SQL은 작성 순서와 실행 순서가 다릅니다. 이 차이를 모르면 "왜 WHERE에서 집계함수를 쓸 수 없는가?"나 "왜 SELECT에서 정의한 별칭을 HAVING에서 쓸 수 없는가?" 같은 오류가 발생합니다.

논리적 실행 순서는 다음과 같습니다.

순서역할
1FROM대상 테이블 결정
2WHERE개별 행 필터링
3GROUP BY그룹화
4HAVING그룹 필터링
5SELECT출력 컬럼 선택
6ORDER BY정렬
7LIMIT / OFFSET페이지네이션

WHERE가 GROUP BY보다 먼저 실행되므로, WHERE 절에는 집계 결과가 아직 존재하지 않습니다. 집계 결과에 조건을 걸려면 반드시 HAVING을 사용해야 합니다.

아래 쿼리는 실행 순서를 모두 포함한 예시입니다. status != 'cancelled' 조건이 WHERE에 있어 취소된 주문은 그룹화 전에 제거되고, SUM(amount) > 1000000 조건은 HAVING에서 그룹 집계 후 적용됩니다.

SQL
SELECT
    category,
    COUNT(*)    AS 주문건수,
    SUM(amount) AS 총매출,
    AVG(amount) AS 평균주문금액,
    MAX(amount) AS 최대주문금액
FROM orders
WHERE status != 'cancelled'
  AND created_at >= '2024-01-01'
GROUP BY category
HAVING SUM(amount) > 1000000
ORDER BY 총매출 DESC
LIMIT 10;

HAVING vs WHERE

WHERE와 HAVING의 역할은 명확히 다릅니다.

  • WHERE: 개별 행을 필터링합니다. 집계함수를 사용할 수 없습니다.
  • HAVING: 그룹화된 결과를 필터링합니다. 집계함수를 조건으로 쓸 수 있습니다.

성능 관점에서도 차이가 납니다. WHERE로 먼저 행을 줄이면 GROUP BY가 처리해야 할 데이터가 적어지므로, 가능한 조건은 WHERE에 두는 것이 유리합니다.

SQL
SELECT
    DATE_TRUNC('month', created_at) AS month,
    category,
    COUNT(*)    AS order_count,
    SUM(amount) AS revenue
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY 1, 2
HAVING SUM(amount) >= 500000
ORDER BY 1, 4 DESC;

집계함수(COUNT, SUM, AVG 등)를 WHERE 절에서 사용하면 이 오류가 발생합니다. WHERE는 GROUP BY 이전에 실행되므로 아직 집계 결과가 존재하지 않기 때문입니다.

SQL
-- 오류 발생
SELECT user_id, COUNT(*) AS order_count
FROM orders
WHERE COUNT(*) >= 5
GROUP BY user_id;

해결 방법: 집계 결과에 조건을 걸 때는 HAVING을 사용하세요. 개별 행 조건(예: status = 'completed')은 WHERE에, 집계 결과 조건(예: COUNT(*) >= 5)은 HAVING에 두면 됩니다.

SQL
SELECT user_id, COUNT(*) AS order_count
FROM orders
WHERE status = 'completed'
GROUP BY user_id
HAVING COUNT(*) >= 5;

실전 분석 쿼리

월별 매출 트렌드, 카테고리별 성과, 상위 구매 고객 조회는 가장 자주 쓰이는 분석 쿼리 패턴입니다. 아래 예시들은 그대로 복사해서 테이블 이름만 바꿔 활용할 수 있습니다.

월별 트렌드는 날짜를 월 단위로 잘라 GROUP BY 기준으로 사용합니다.

SQL
SELECT
    TO_CHAR(created_at, 'YYYY-MM') AS 월,
    COUNT(*)                       AS 주문건수,
    SUM(amount)                    AS 총매출,
    ROUND(AVG(amount), 0)          AS 평균주문금액
FROM orders
WHERE status = 'completed'
  AND created_at >= NOW() - INTERVAL '12 months'
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
ORDER BY 1;

카테고리별 성과는 구매자 수와 건당 평균 매출을 함께 보면 단순 매출 크기와 고객 단가를 동시에 파악할 수 있습니다.

SQL
SELECT
    category,
    COUNT(*)                          AS 총주문,
    COUNT(DISTINCT user_id)           AS 구매자수,
    SUM(amount)                       AS 매출,
    ROUND(SUM(amount) / COUNT(*), 0)  AS 건당평균매출
FROM orders
WHERE status != 'cancelled'
GROUP BY category
ORDER BY 매출 DESC;

상위 구매 고객 조회는 JOIN 이후에 GROUP BY를 적용하는 패턴입니다. HAVING으로 최소 주문 횟수 조건을 걸어 우량 고객만 추립니다.

SQL
SELECT
    u.id              AS user_id,
    u.name            AS 고객명,
    COUNT(o.id)       AS 주문횟수,
    SUM(o.amount)     AS 총구매금액,
    MAX(o.created_at) AS 최근구매일
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.status = 'completed'
GROUP BY u.id, u.name
HAVING COUNT(o.id) >= 3
ORDER BY 총구매금액 DESC
LIMIT 10;
1우량 고객 추출 — JOIN 후 GROUP BY와 HAVING 필터를 직접 실행

WHERE(그룹핑 전 행 필터)와 HAVING(그룹핑 후 집계값 필터)의 역할 차이를 한 쿼리에서 확인합니다. 위 쿼리를 실행해 "주문 3회 이상인 우량 고객 상위 10명"이 추려지는지 봅니다.

SQL
SELECT
    u.name              AS 고객명,
    COUNT(o.id)         AS 주문횟수,
    SUM(o.amount)       AS 총구매금액
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.status = 'completed'   -- 행 필터(그룹핑 전)
GROUP BY u.id, u.name
HAVING COUNT(o.id) >= 3         -- 집계 필터(그룹핑 후)
ORDER BY 총구매금액 DESC
LIMIT 10;
OUTPUT
  고객명  | 주문횟수 | 총구매금액
----------+----------+------------
 김우량   |       12 |    3840000
 이단골   |        8 |    2210000
 ...
SELECT u.name, COUNT(o.id) AS 주문횟수, SUM(o.amount) AS 총구매 FROM users u JOIN orders o ON u.id=o.user_id WHERE o.status='completed' GROUP BY u.id, u.name HAVING COUNT(o.id) >= 3 ORDER BY 총구매 DESC LIMIT 10;
🔍실행 후 확인할 것
  • 결과의 주문횟수가 모두 3 이상인지 본다 — 3 미만 행이 보이면 HAVING이 아니라 WHERE에 잘못 넣었거나 누락된 것
  • WHERE를 빼고 다시 실행해 행 수가 늘면, status 필터가 그룹핑 전에 정상 적용되고 있다는 의미(취소 주문이 섞였던 것)
  • HAVING 조건을 WHERE COUNT(o.id) >= 3 으로 옮기면 'aggregate functions are not allowed in WHERE' 에러가 나는지 확인 — 집계값 필터는 반드시 HAVING이어야 한다
  • LIMIT 10인데 10행 미만이면 조건을 만족하는 우량 고객 자체가 그만큼뿐이라는 뜻이다
💼
실무 맥락월간 비즈니스 리뷰를 위한 매출 대시보드 쿼리 작성
현업 패턴

실무에서 경영진이 요청하는 "카테고리별 월 매출, 전월 대비 성장률, 구매 고객 수" 같은 리포트는 거의 대부분 GROUP BY + 집계함수 조합으로 만듭니다.

WHERE vs HAVING 구분을 명확히 알아야 필터 조건을 올바른 위치에 배치할 수 있고, 조건을 WHERE에 먼저 걸면 GROUP BY가 처리할 데이터 양이 줄어 쿼리 성능도 개선됩니다. 수백만 건의 주문 테이블에서 이 차이는 실행 시간 수 초에 영향을 줍니다.

💡개념

ROLLUP과 CUBE — 소계/합계 자동 생성

지역별-카테고리별 매출 보고서를 만들어야 합니다. 각 지역 소계, 각 카테고리 소계, 전체 합계가 모두 필요합니다. 여러 쿼리를 UNION으로 합치거나 애플리케이션에서 계산하는 방법은 복잡합니다. ROLLUP 하나로 계층적 소계를 자동으로 생성할 수 있습니다.

ROLLUP과 CUBE가 생성하는 그룹화 집합 비교 — 같은 데이터에 ROLLUP(category, status)은 개별 그룹·category별 소계·총계 3개 집합(7행)을 만들고, CUBE는 거기에 status별 소계까지 더한 4개 집합(9행)을 만든다. ROLLUP은 계층적으로 오른쪽 컬럼부터 제거, CUBE는 모든 조합 2ⁿ개를 생성확대

ROLLUP — 계층적 소계와 총계

GROUP BY ROLLUP(col1, col2)는 일반 그룹별 집계 외에 col1 단위 소계와 전체 총계를 추가로 생성합니다. 소계 행에서 하위 컬럼은 NULL로 표시되므로 GROUPING() 함수로 소계 행임을 구분할 수 있습니다.

아래 쿼리는 연도-월 계층 구조로 소계를 생성합니다. 결과에서 월이 NULL인 행은 해당 연도의 소계이고, 연도와 월이 모두 NULL인 행이 전체 총계입니다.

SQL
SELECT
    EXTRACT(YEAR FROM created_at)  AS 연도,
    EXTRACT(MONTH FROM created_at) AS 월,
    SUM(amount)                    AS 매출
FROM orders
WHERE status = 'completed'
GROUP BY ROLLUP(
    EXTRACT(YEAR FROM created_at),
    EXTRACT(MONTH FROM created_at)
)
ORDER BY 1 NULLS LAST, 2 NULLS LAST;

GROUPING() 함수를 사용하면 소계 행의 NULL을 "전체"처럼 의미 있는 레이블로 바꿀 수 있습니다.

SQL
SELECT
    CASE WHEN GROUPING(category) = 1 THEN '전체' ELSE category END AS 카테고리,
    CASE WHEN GROUPING(status)   = 1 THEN '전체' ELSE status   END AS 상태,
    COUNT(*) AS 건수,
    SUM(amount) AS 금액
FROM orders
GROUP BY ROLLUP(category, status)
ORDER BY 1, 2;

CUBE — 모든 조합의 소계

CUBE(a, b)(a, b) 개별 그룹, a별 소계, b별 소계, 전체 총계 — 이렇게 네 가지 조합을 모두 생성합니다. 컬럼이 많아질수록 생성되는 조합이 기하급수적으로 늘어나므로 2~3개 컬럼에만 사용하는 것이 일반적입니다.

SQL
SELECT
    category,
    status,
    COUNT(*) AS order_count
FROM orders
GROUP BY CUBE(category, status)
ORDER BY category NULLS LAST, status NULLS LAST;

FILTER — 조건부 집계

FILTER (WHERE ...) 절을 집계함수에 붙이면 한 번의 GROUP BY 스캔으로 여러 조건의 집계를 동시에 계산할 수 있습니다. 상태별로 별도의 서브쿼리를 작성하거나 CASE WHEN으로 분기하는 것보다 가독성이 좋고 성능도 유리합니다.

SQL
SELECT
    category,
    COUNT(*)                                               AS 총주문수,
    COUNT(*) FILTER (WHERE status = 'completed')           AS 완료수,
    COUNT(*) FILTER (WHERE status = 'cancelled')           AS 취소수,
    COUNT(*) FILTER (WHERE status = 'pending')             AS 대기수,
    SUM(amount) FILTER (WHERE status = 'completed')        AS 완료매출,
    ROUND(
        100.0 * COUNT(*) FILTER (WHERE status = 'completed') / COUNT(*),
        1
    ) AS 완료율
FROM orders
GROUP BY category
ORDER BY 총주문수 DESC;

복잡한 분석 쿼리 예시

사용자 코호트 분석은 가입 시점별로 그룹을 나누고, 각 코호트의 첫 구매 전환율을 측정하는 패턴입니다. CTE로 코호트와 첫 주문을 각각 계산한 뒤 LEFT JOIN으로 연결하면 전환 여부가 없는 사용자도 포함해 집계할 수 있습니다.

SQL
WITH user_cohorts AS (
    SELECT
        user_id,
        DATE_TRUNC('month', created_at) AS cohort_month
    FROM users
    WHERE created_at >= '2024-01-01'
),
first_orders AS (
    SELECT
        user_id,
        MIN(DATE_TRUNC('month', created_at)) AS first_order_month
    FROM orders
    WHERE status = 'completed'
    GROUP BY user_id
)
SELECT
    uc.cohort_month,
    COUNT(DISTINCT uc.user_id)  AS 가입자수,
    COUNT(DISTINCT fo.user_id)  AS 구매전환자수,
    ROUND(
        100.0 * COUNT(DISTINCT fo.user_id) / COUNT(DISTINCT uc.user_id),
        1
    ) AS 전환율
FROM user_cohorts uc
LEFT JOIN first_orders fo
    ON uc.user_id = fo.user_id
    AND uc.cohort_month = fo.first_order_month
GROUP BY uc.cohort_month
ORDER BY uc.cohort_month;

동작 원리 — GROUP BY가 행을 그룹으로 접는 과정

💡개념

GROUP BY 한 줄이 그룹을 만드는 5단계 — 행이 버킷으로 접힌다

SELECT category, name, SUM(amount) FROM orders GROUP BY category 를 실행하면 "name은 GROUP BY에 없다"는 에러가 나는데, 정작 SUM(amount)는 아무 문제가 없습니다. 왜 어떤 컬럼은 되고 어떤 컬럼은 안 될까요. GROUP BY가 여러 행을 하나의 그룹으로 "접는" 과정을 단계로 따라가 보면, 이 에러도, HAVING을 WHERE와 따로 두는 이유도, NULL이 어떻게 묶이는지도 한 번에 설명됩니다.

TEXT
[orders 테이블의 행들]
   │
   ① FROM·WHERE: 대상 행 확보            (status·기간 등으로 행 필터 — 아직 그룹 아님)
   │
   ② 그룹 키 계산 → 버킷 분할            (GROUP BY 컬럼 값이 같은 행끼리 한 버킷)
   │      category='전자' → [행, 행, 행]
   │      category='도서' → [행, 행]
   │      category=NULL   → [행, 행]      (NULL끼리도 하나의 버킷)
   │
   ③ 버킷마다 집계함수로 fold            (COUNT·SUM·AVG를 버킷 안 행들에 적용)
   │      [행, 행, 행] → COUNT=3, SUM=1200
   │
   ④ HAVING: 집계 결과로 버킷 필터        (SUM(amount) > 1000000 같은 그룹 조건)
   │
   ⑤ 버킷당 1행 출력                      (그룹 키 + 집계값 — 개별 행은 사라짐)
   ▼
[category별로 한 행씩 남은 결과]

각 단계가 하는 일과, 그 단계라서 생기는 규칙:

단계하는 일그래서 생기는 규칙·증상
① FROM·WHERE그룹으로 묶기 전에 개별 행을 먼저 거른다이 단계엔 아직 집계값이 없다 → WHERE에 COUNT(*) 같은 집계 조건을 쓰면 aggregate functions are not allowed in WHERE
② 버킷 분할GROUP BY에 적은 컬럼 값이 같은 행을 한 버킷에 모은다버킷을 가르는 그 컬럼만 그룹당 값이 하나로 확정된다. NULL 값끼리도 한 버킷으로 묶인다
③ 집계 fold버킷 안 여러 행을 집계함수가 값 하나로 접는다. COUNT(*)는 행 수, COUNT(col)·SUM·AVG는 NULL을 뺀다SELECT에 올 수 있는 건 '그룹 키' 아니면 '집계로 접은 값'뿐. GROUP BY에 없는 raw 컬럼(name 등)은 버킷 안 값이 여러 개라 하나로 못 정해 에러
④ HAVING집계가 끝난 버킷을 집계 결과로 거른다집계값 조건은 HAVING(HAVING SUM(amount) > 1000000), 개별 행 조건은 ①의 WHERE
⑤ 1행 출력버킷 하나가 결과 한 행이 된다원본 행 수가 그룹 수로 줄어든다(윈도우 함수와 정반대). NULL 버킷도 한 행으로 출력

즉 "GROUP BY에 없는 컬럼을 SELECT 못 한다"는 제약은 외우는 문법이 아니라 ③의 결과입니다 — 버킷 안엔 그 컬럼 값이 여러 개인데 출력은 버킷당 한 줄이라, DB는 "그 중 무엇을 보여줄지" 정할 수 없습니다. 그 컬럼도 함께 보고 싶으면 GROUP BY에 넣어(버킷을 더 잘게 쪼개) 값을 하나로 확정하거나, MAX()·STRING_AGG() 같은 집계로 접으면 됩니다. WHERE와 HAVING이 헷갈릴 때도 이 순서로 돌아오면 됩니다 — 행을 줄이는 조건은 ①(WHERE), 접힌 값을 거르는 조건은 ④(HAVING). 이 버킷을 DB가 실제로 해시로 만드는지 정렬로 만드는지는 이 모듈 끝의 심화에서 다룹니다.

심화 — 집계는 정렬과 해시 위에서 돌아간다

💡개념

심화: GROUP BY의 실행 전략 — HashAggregate vs GroupAggregate와 work_mem

WHERE와 HAVING의 위치를 익혔다면 다음 질문은 이것입니다 — GROUP BY는 실제로 그룹을 어떻게 묶을까요. 옵티마이저는 보통 두 전략 중 하나를 고르고, 어느 쪽이 걸리느냐에 따라 같은 쿼리의 속도가 크게 갈립니다.

  • HashAggregate: 그룹 키로 메모리에 해시 테이블을 만들어 한 번의 스캔으로 집계합니다. 정렬이 필요 없어 대개 빠르지만, 해시 테이블이 work_mem을 넘으면 디스크 임시 파일로 스필(spill)합니다.
  • GroupAggregate: 입력이 그룹 키 순으로 정렬돼 있을 때, 이웃한 같은 키를 순서대로 접어 집계합니다. 그룹 키에 정렬된 인덱스가 있으면 정렬 비용 없이 바로 흘려보낼 수 있고 메모리도 거의 쓰지 않습니다.

EXPLAIN에서 HashAggregate가 보이면 그룹 카디널리티(고유 그룹 수)와 work_mem을 함께 의심하고, GroupAggregate 위에 Sort가 얹혀 있으면 그 정렬을 인덱스로 없앨 수 있는지 봅니다. 핵심은 집계도 스캔·정렬·메모리라는 물리 자원 위에서 돌아간다는 것입니다 — WHERE로 대상을 먼저 줄이는 것이 성능에 유리한 이유도 결국 해시 테이블과 정렬 입력이 작아지기 때문입니다.

SQL
EXPLAIN (ANALYZE, BUFFERS)
SELECT category, SUM(amount)
FROM orders
GROUP BY category;
OUTPUT
 HashAggregate  (cost=... rows=8 ...) (actual time=...)
   Group Key: category
   Batches: 5  Memory Usage: 4096kB  Disk Usage: 24960kB   ← 스필 발생
   ->  Seq Scan on orders  ...

상황: 카테고리·월별 매출 집계 대시보드 쿼리가 초기에는 수십 ms였는데, 주문이 쌓이자 갑자기 몇 초로 뛰었습니다. 쿼리 문장도 인덱스도 바꾸지 않았는데 특정 시점부터 계단식으로 느려집니다.

원인: 그룹 집계가 HashAggregate로 실행되는데, 집계 대상 행이 늘면서 그룹 해시 테이블이 세션의 work_mem을 초과했습니다. PostgreSQL은 넘치는 만큼을 디스크 임시 파일로 스필하고, 이때부터 메모리 내 집계가 디스크 왕복이 섞인 집계로 바뀌어 급격히 느려집니다. 데이터가 임계선을 넘는 순간 튀는 계단식 저하가 이 현상의 특징입니다.

진단: EXPLAIN (ANALYZE, BUFFERS)로 실행하면 HashAggregate 노드에 Batches가 2 이상이고 Disk Usage가 찍힙니다(스필 확정). 서버에 log_temp_files = 0을 켜면 임시 파일이 로그에 남고, pg_stat_statementstemp_blks_written이 그 쿼리에서 커집니다.

해결: 먼저 WHERE로 집계 대상을 좁혀(예: 최근 N개월) 해시 테이블을 작게 만듭니다. 리포트성 쿼리라면 세션에서 SET work_mem = '128MB'처럼 한시적으로 올려 스필을 없앨 수 있습니다(전역 상향은 동시성 곱만큼 메모리를 먹으니 주의). 반복되는 집계는 그룹 키에 정렬된 인덱스로 GroupAggregate를 유도하거나, 사전 집계(롤업·요약 테이블·머티리얼라이즈드 뷰)로 그룹 카디널리티 자체를 낮춥니다(쿼리 실행 계획(Execution Plan) 읽는 법과 인덱스 최적화).


명령어·구문 빠른 참조

이 모듈에서 다룬 집계·GROUP BY 관련 SQL 구문을 실전 조합과 함께 모았습니다. "예" 열을 그대로 응용해도 됩니다.

구문/명령용도
COUNT(*) / COUNT(col)전체 행 수 / NULL 제외 행 수5행 중 2행이 NULL이면 COUNT(*)=5, COUNT(amount)=3
COUNT(DISTINCT col)고유값 개수COUNT(DISTINCT user_id) (고유 구매자 수)
SUM/AVG/MIN/MAX합계·평균·최소·최대(NULL 무시)SUM(amount), ROUND(AVG(amount), 0)
COALESCE(col, 0)NULL을 0으로 바꿔 집계SUM(COALESCE(amount, 0))
GROUP BY지정 컬럼 단위로 묶기GROUP BY category / GROUP BY 1, 2(위치 참조)
HAVING집계 결과로 그룹 필터HAVING SUM(amount) > 1000000
WHERE그룹핑 전 개별 행 필터WHERE status != 'cancelled'(집계함수 사용 불가)
DATE_TRUNC / TO_CHAR / EXTRACT날짜를 집계 단위로 자르기DATE_TRUNC('month', created_at), TO_CHAR(created_at, 'YYYY-MM')
GROUP BY ROLLUP(...)소계+총계 자동 생성GROUP BY ROLLUP(category, status)
GROUP BY CUBE(...)모든 조합 소계 생성GROUP BY CUBE(category, status)
GROUPING(col)소계 행(NULL) 구분·라벨링CASE WHEN GROUPING(category)=1 THEN '전체' ELSE category END
FILTER (WHERE ...)조건부 집계를 한 스캔에COUNT(*) FILTER (WHERE status='completed')
EXPLAIN (ANALYZE, BUFFERS)집계 실행계획·스필 확인HashAggregateBatches≥2·Disk Usage 확인
SET work_mem해시 집계 디스크 스필 방지SET work_mem = '128MB'(리포트 세션 한시 상향)

관련 모듈로 더 깊이:

다음 모듈에서는 NULL 값이 만들어내는 함정과 COALESCE, NULLIF로 안전하게 처리하는 방법을 다룹니다.

지식 확인

퀴즈 — 8문제

Q1

COUNT(*)와 COUNT(column_name)의 차이는?

Q2

주문 3건 이상인 고객만 추출하려 합니다. 아래 쿼리 중 올바른 것은? ① SELECT user_id, COUNT(*) FROM orders WHERE COUNT(*) >= 3 GROUP BY user_id; ② SELECT user_id, COUNT(*) FROM orders GROUP BY user_id HAVING COUNT(*) >= 3;

Q3

GROUP BY 없이 집계함수(SUM 등)를 SELECT에서 사용하면?

Q4

AVG(salary)에서 salary가 NULL인 행은 어떻게 처리되는가?

Q5

월별·카테고리별 매출 집계 리포트를 만드는데, '카테고리별 월합계'와 '전체 총합'도 한 번의 쿼리로 함께 출력하려 합니다. 가장 적합한 방법은?

Q6

GROUP BY 쿼리에서 '집계 전에 행을 거르는 WHERE'와 '집계 후 그룹을 거르는 HAVING'을 구분해야 하는 이유는?

Q7

[심화] 같은 GROUP BY 쿼리인데 EXPLAIN에서 어떤 실행은 HashAggregate, 어떤 실행은 GroupAggregate로 처리된다. GroupAggregate가 선택되기 유리한 조건은?

Q8

[심화] 평소 잘 돌던 월별 매출 집계가 데이터가 쌓이면서 계단식으로 느려졌다. 쿼리와 인덱스는 그대로다. EXPLAIN (ANALYZE)에서 가장 먼저 확인할 신호는?

0 / 8 답변

🧪 실습으로 확인하기

PostgreSQL 설치 및 기본 설정

초급

Linux 서버에 PostgreSQL을 설치하고, 데이터베이스와 사용자를 생성한 뒤 외부 접속이 가능하도록 설정한다.

50📋 5단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요