Search

REMINDER 07-01. GROUP BY에 불필요한 컬럼을 추가하지 말자

원번호 09-01에서 이동. 실제 SQL 및 실행계획·검증 결과는 유지함.
상황: 2023년 한 해의 매장별 주문 건수와 총주문금액을 집계한다. 업무에서 필요한 그룹은 매장(shop_id)별이다. 실제 일반 테이블 startdbpg.tr_ord의 209,370건을 대상으로 SQL과 실행계획을 검증했다.

BAD SQL — 조회 기간이 이미 2023년으로 고정됐는데 연도까지 GROUP BY

SELECT o.shop_id, COUNT(*) AS order_cnt, SUM(o.ord_amt) AS total_amt FROM startdbpg.tr_ord o WHERE o.ord_dtm >= TIMESTAMP '2023-01-01' AND o.ord_dtm < TIMESTAMP '2024-01-01' GROUP BY o.shop_id, DATE_TRUNC('year', o.ord_dtm) ORDER BY o.shop_id;
SQL
복사
WHERE에서 2023년 주문만 선택했으므로 DATE_TRUNC('year', o.ord_dtm)은 모든 대상 행에서 2023-01-01 00:00:00으로 동일하다. 따라서 연도는 그룹을 추가로 구분하지 않는다. 하지만 PostgreSQL은 이 표현식을 실제로 계산하고 집계 키로 사용했다.

GOOD SQL — 업무에서 필요한 매장 ID만 GROUP BY

SELECT o.shop_id, COUNT(*) AS order_cnt, SUM(o.ord_amt) AS total_amt FROM startdbpg.tr_ord o WHERE o.ord_dtm >= TIMESTAMP '2023-01-01' AND o.ord_dtm < TIMESTAMP '2024-01-01' GROUP BY o.shop_id ORDER BY o.shop_id;
SQL
복사
매장별 집계 결과는 그대로 유지하면서 불필요한 연도 집계 키를 제거한다.
실제 데이터 및 결과 동등성
•
2023년 주문: 209,370건
•
BAD, GOOD 모두 매장별 289행 반환
•
양쪽 COUNT(*), SUM(ord_amt)를 포함한 전체 결과를 EXCEPT ALL로 양방향 비교: 차이 0건
실제 실행계획 비교 — EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
항목
BAD: 매장 + 불필요한 연도
GOOD: 매장
집계 입력 행 수
209,370
209,370
최종 반환 그룹 수
289
289
집계 키
shop_id, date_trunc('year', ord_dtm)
shop_id
집계 노드
HashAggregate
Partial HashAggregate • Finalize GroupAggregate
집계 메모리
529kB (단일 집계 노드)
157kB (Partial HashAggregate, 각 작업자)
Shared Buffer Hit
3,089
3,475
실행시간 (워밍 후 각 1회 실측)
212.931ms
118.419ms
두 SQL의 실행계획은 동일하지 않다. GOOD에는 병렬 집계(작업자 1명 추가)가 선택됐다. 실행시간 차이는 불필요한 키 계산뿐 아니라 병렬 실행 선택에도 영향을 받으므로, 약 1.8배의 차이를 모든 환경에 일반화하지 않는다. GOOD의 메모리 수치는 각 Partial HashAggregate 노드 기준이므로 BAD와 전체 메모리를 단순 비교하지 않는다.

BAD SQL — 실제 실행계획 (핵심 발췌)

Sort (actual time=212.288..212.312 rows=289 loops=1) Sort Key: shop_id Buffers: shared hit=3089 -> HashAggregate (actual time=211.716..211.921 rows=289 loops=1) Group Key: shop_id, date_trunc('year'::text, ord_dtm) Batches: 1 Memory Usage: 529kB -> Index Scan using tr_ord_x51 on tr_ord o (actual time=0.045..114.715 rows=209370 loops=1) Index Cond: ((ord_dtm >= '2023-01-01 00:00:00'::timestamp without time zone) AND (ord_dtm < '2024-01-01 00:00:00'::timestamp without time zone)) Execution Time: 212.931 ms
Plain Text
복사

GOOD SQL — 실제 실행계획 (핵심 발췌)

Finalize GroupAggregate (actual time=110.060..118.036 rows=289 loops=1) Group Key: shop_id Buffers: shared hit=3475 -> Gather Merge (actual time=110.039..117.242 rows=578 loops=1) Workers Planned: 1 Workers Launched: 1 -> Sort (actual time=104.634..104.672 rows=289 loops=2) Sort Key: shop_id -> Partial HashAggregate (actual time=103.948..104.147 rows=289 loops=2) Group Key: shop_id Batches: 1 Memory Usage: 157kB -> Parallel Index Scan using tr_ord_x51 on tr_ord o (actual time=0.043..37.527 rows=104685 loops=2) Execution Time: 118.419 ms
Plain Text
복사
실행계획 해석: BAD에서는 Group Key에 업무상 필요하지 않은 date_trunc('year', ord_dtm)이 포함됐다. GOOD에서는 shop_id만으로 집계했으며, 이번 측정에서 병렬 집계까지 선택돼 실행시간이 짧았다.

핵심

•
GROUP BY에는 업무 결과를 구분하는 데 필요한 컬럼만 사용하자. 이미 WHERE에서 한 가지 값으로 확정된 컬럼·표현식을 불필요하게 그룹 키로 추가하면 계산과 집계 부담을 늘릴 수 있다.
💡
참고·예외
•
날짜 범위가 여러 연도로 확장되고 연도별 매장 매출이 필요하다면 GROUP BY shop_id, DATE_TRUNC('year', ord_dtm)은 정당한 설계다.
•
추가한 그룹 키가 실제 그룹을 세분화하면 BAD/GOOD의 결과가 달라질 수 있다. 무조건 GROUP BY 컬럼을 제거하지 말고 업무의 결과 단위부터 확인한다.
•
PostgreSQL이 불필요한 키를 최적화하는 경우도 있고, 데이터 분포·인덱스·병렬 실행 여부에 따라 성능 차이는 달라진다. 여기의 시간은 실측 1회 값이며 고정된 성능 개선 비율이 아니다.