상세 페이지
SECTION 07에서는 GROUP BY의 결과 단위를 먼저 정의한다. 이어서 불필요한 JOIN·DISTINCT를 제거하고 FILTER·UNION ALL로 중복 작업을 줄인다. 이 기본기를 익힌 후 SECTION 06에서 반복 서브쿼리를 최적화한다.
난이도: 초급 후반~중급 초반 · 설명 방식: BAD SQL → GOOD SQL → 핵심 규칙. 실행계획 전문 용어는 후반 SECTION 11에서 다룬다.
REMINDER 07-01. GROUP BY에 불필요한 컬럼을 추가하지 말자
원번호 09-01에서 이동. 실제 SQL 및 실행계획·검증 결과는 유지함.
상황: 2023년 한 해의 매장별 주문 건수와 총주문금액을 집계한다. 업무에서 필요한 그룹은 매장(shop_id)별이다. 실제 일반 테이블 startdbpg.tr_ord의 209,370건을 대상으로 SQL과 실행계획을 검증했다.
BAD — 조회 기간이 이미 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 — 업무에서 필요한 매장 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 실제 실행계획 (핵심 발췌)
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 실제 실행계획 (핵심 발췌)
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회 값이며 고정된 성능 개선 비율이 아니다.
REMINDER 07-02. DISTINCT로 중복을 제거하기 전에 불필요한 JOIN부터 확인하자
상황: 2023년 12월 주문의 주문번호, 주문일시, 매장명, 주문금액을 조회한다. tr_ord는 주문 1건당 1행이지만, tr_ord_det는 주문 하나에 상세가 여러 건인 1:N 관계다. 상세 컬럼이나 상세 조건이 필요하지 않은데 상세 테이블을 JOIN하면 행이 늘어나고, 이를 DISTINCT로 다시 줄이는 불필요한 작업이 발생한다.
실습 테이블: startdbpg.tr_ord(주문), startdbpg.ms_shop(매장), startdbpg.tr_ord_det(주문상세). 실제 일반 테이블에서 실행해 검증했다.
BAD — 불필요한 1:N JOIN으로 만든 중복을 DISTINCT로 제거
SELECT DISTINCT
o.ord_no,
o.ord_dtm,
s.shop_nm,
o.ord_amt
FROM startdbpg.tr_ord o
JOIN startdbpg.ms_shop s
ON s.shop_id = o.shop_id
LEFT JOIN startdbpg.tr_ord_det d
ON d.ord_no = o.ord_no
WHERE o.ord_dtm >= TIMESTAMP '2023-12-01'
AND o.ord_dtm < TIMESTAMP '2024-01-01';
SQL
복사
주문상세의 컬럼을 조회하거나 조건으로 사용하지 않는다. 그러나 상세 테이블과 JOIN하면서 주문 20,359건이 40,864행으로 증가하고, 이 중복을 DISTINCT의 HashAggregate로 다시 20,359행으로 줄인다.
GOOD — 사용하지 않는 JOIN과 DISTINCT를 함께 제거
SELECT o.ord_no,
o.ord_dtm,
s.shop_nm,
o.ord_amt
FROM startdbpg.tr_ord o
JOIN startdbpg.ms_shop s
ON s.shop_id = o.shop_id
WHERE o.ord_dtm >= TIMESTAMP '2023-12-01'
AND o.ord_dtm < TIMESTAMP '2024-01-01';
SQL
복사
매장명 shop_nm을 얻기 위한 ms_shop JOIN만 유지한다. tr_ord.ord_no는 PK이고, ms_shop.shop_id도 PK이므로 이 JOIN은 주문 한 행을 여러 행으로 늘리지 않는다. 따라서 DISTINCT가 필요 없다.
실제 데이터·결과 검증
•
2023년 12월 주문: 20,359건
•
주문상세 JOIN 후: 40,864행
•
상세 2건 이상인 주문: 14,697건
•
BAD 결과 / GOOD 결과: 모두 20,359행
•
EXCEPT ALL 양방향 비교: 차이 0건
실제 실행계획 비교 (EXPLAIN (ANALYZE, BUFFERS, COSTS OFF))
비교 항목 | BAD | GOOD |
최종 반환 행 수 | 20,359 | 20,359 |
중간 JOIN 행 수 | 40,864 | 20,359 |
실행시간(각 1회) | 490.411ms | 16.710ms |
Buffers: shared hit | 61,291 (추가 read=96) | 309 |
주요 연산 | Nested Loop Left Join → HashAggregate | Hash Join |
BAD 실제 실행계획
HashAggregate (actual time=479.942..485.734 rows=20359 loops=1)
Group Key: o.ord_no, o.ord_dtm, s.shop_nm, o.ord_amt
Batches: 1 Memory Usage: 2321kB
Buffers: shared hit=61291 read=96
-> Nested Loop Left Join (actual time=6.443..445.767 rows=40864 loops=1)
Buffers: shared hit=61291 read=96
-> Hash Join (actual time=2.397..271.250 rows=20359 loops=1)
Hash Cond: ((o.shop_id)::text = (s.shop_id)::text)
Buffers: shared hit=309
-> Index Scan using tr_ord_x51 on tr_ord o (actual time=0.797..256.183 rows=20359 loops=1)
Index Cond: ((ord_dtm >= '2023-12-01 00:00:00'::timestamp without time zone) AND (ord_dtm < '2024-01-01 00:00:00'::timestamp without time zone))
Buffers: shared hit=305
-> Hash (actual time=1.568..1.569 rows=300 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 24kB
Buffers: shared hit=4
-> Seq Scan on ms_shop s (actual time=0.058..0.814 rows=300 loops=1)
Buffers: shared hit=4
-> Index Only Scan using tr_ord_det_fk01 on tr_ord_det d (actual time=0.007..0.008 rows=2 loops=20359)
Index Cond: (ord_no = o.ord_no)
Heap Fetches: 0
Buffers: shared hit=60982 read=96
Planning:
Buffers: shared hit=387
Planning Time: 56.707 ms
Execution Time: 490.411 ms
Plain Text
복사
GOOD 실제 실행계획
Hash Join (actual time=0.231..15.261 rows=20359 loops=1)
Hash Cond: ((o.shop_id)::text = (s.shop_id)::text)
Buffers: shared hit=309
-> Index Scan using tr_ord_x51 on tr_ord o (actual time=0.044..5.737 rows=20359 loops=1)
Index Cond: ((ord_dtm >= '2023-12-01 00:00:00'::timestamp without time zone) AND (ord_dtm < '2024-01-01 00:00:00'::timestamp without time zone))
Buffers: shared hit=305
-> Hash (actual time=0.158..0.160 rows=300 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 24kB
Buffers: shared hit=4
-> Seq Scan on ms_shop s (actual time=0.017..0.073 rows=300 loops=1)
Buffers: shared hit=4
Planning:
Buffers: shared hit=326
Planning Time: 1.589 ms
Execution Time: 16.710 ms
Plain Text
복사
실행계획 읽기: BAD에서는 tr_ord_det_fk01에 대한 Index Only Scan이 20,359회(loops) 수행되고, 결과가 40,864행으로 증가한다. 이후 HashAggregate가 중복을 제거한다. GOOD에서는 불필요한 주문상세 탐색과 HashAggregate가 모두 사라진다.
핵심: DISTINCT를 제거할 수 있는지 판단하기 전에, 중복을 발생시키는 JOIN이 꼭 필요한지 먼저 확인하자. 조회나 필터에 사용하지 않는 1:N JOIN을 제거하면, 결과의 중복과 그 중복을 제거하는 비용을 함께 없앨 수 있다.
주의할 예외: 주문상세의 컬럼을 출력하거나 주문상세 조건으로 주문을 선택해야 하는 경우에는 해당 JOIN을 단순 삭제하면 안 된다. 상세 존재 여부만 확인하는 목적이라면 EXISTS도 비교할 수 있다(SECTION 05). 여러 결과 행 자체가 업무적으로 필요하거나 중복 제거가 요구사항이라면 DISTINCT가 필요할 수 있다. 위 성능 수치는 각각 1회 실측 결과이므로 시간 차이의 절대적 배수는 일반화하지 않는다.
REMINDER 07-03. 동일한 데이터에서 여러 값을 집계할 때는 FILTER를 활용하자
상황: 한 매장의 2023년 3월 전체 주문 수와 픽업 완료 주문 수를 함께 출력한다.
BAD — 기존 또는 비교 대상
SELECT
(SELECT COUNT(*)
FROM tr_ord_big AS o
WHERE o.shop_id = 'S033'
AND o.ord_dtm >= TIMESTAMP '2023-03-01'
AND o.ord_dtm < TIMESTAMP '2023-04-01') AS all_cnt,
(SELECT COUNT(*)
FROM tr_ord_big AS o
WHERE o.shop_id = 'S033'
AND o.ord_dtm >= TIMESTAMP '2023-03-01'
AND o.ord_dtm < TIMESTAMP '2023-04-01'
AND o.ord_st = 'PKUP') AS pkup_cnt;
SQL
복사
GOOD — 권장 또는 대안
SELECT COUNT(*) AS all_cnt,
COUNT(*) FILTER (WHERE o.ord_st = 'PKUP') AS pkup_cnt
FROM tr_ord_big AS o
WHERE o.shop_id = 'S033'
AND o.ord_dtm >= TIMESTAMP '2023-03-01'
AND o.ord_dtm < TIMESTAMP '2023-04-01';
SQL
복사
핵심: 집계 대상이 같고 조건만 다르면 PostgreSQL의 FILTER (WHERE ...)로 여러 집계를 한 번에 표현할 수 있다.
주의할 예외: COUNT(CASE WHEN 조건 THEN 1 ELSE 0 END)는 ELSE 0이 NULL이 아니므로 전체 행을 세는 잘못된 작성법이다. 비교할 때는 COUNT(*) FILTER (...) 또는 NULL을 반환하는 CASE를 사용한다. 두 독립 인덱스 조회가 매우 선택적이면 BAD가 더 유리할 수도 있다.
REMINDER 07-04. 중복 제거가 불필요하면 UNION ALL을 검토하자
상황: 2023년 3월의 카드(CARD)·현금(CASH) 결제 주문번호와 매장 ID를 하나로 조회한다. 실제 일반 테이블 startdbpg.tr_ord에서 검증했다. 이 테이블의 ord_no는 PK이며, pay_tp는 주문별 단일 결제유형이다.
BAD — 중복 제거가 필요하지 않은데 UNION 사용
SELECT o.ord_no, o.shop_id
FROM startdbpg.tr_ord o
WHERE o.ord_dtm >= TIMESTAMP '2023-03-01'
AND o.ord_dtm < TIMESTAMP '2023-04-01'
AND o.pay_tp = 'CARD'
UNION
SELECT o.ord_no, o.shop_id
FROM startdbpg.tr_ord o
WHERE o.ord_dtm >= TIMESTAMP '2023-03-01'
AND o.ord_dtm < TIMESTAMP '2023-04-01'
AND o.pay_tp = 'CASH';
SQL
복사
UNION은 두 SELECT의 결과를 합치면서 전체 출력 컬럼 기준 중복 행을 제거한다. 그러나 주문 한 건이 카드 결제이면서 동시에 현금 결제일 수 없고, 출력한 ord_no도 PK이므로 여기서는 중복 제거가 불필요하다.
GOOD 1 — 중복 제거가 필요 없다면 UNION ALL
SELECT o.ord_no, o.shop_id
FROM startdbpg.tr_ord o
WHERE o.ord_dtm >= TIMESTAMP '2023-03-01'
AND o.ord_dtm < TIMESTAMP '2023-04-01'
AND o.pay_tp = 'CARD'
UNION ALL
SELECT o.ord_no, o.shop_id
FROM startdbpg.tr_ord o
WHERE o.ord_dtm >= TIMESTAMP '2023-03-01'
AND o.ord_dtm < TIMESTAMP '2023-04-01'
AND o.pay_tp = 'CASH';
SQL
복사
UNION ALL은 중복 제거를 수행하지 않는다. 다만 동일 테이블을 카드·현금 조건으로 두 번 검색하는 구조는 남아 있다.
GOOD 2 — 이 사례의 최종 권장안: WHERE IN으로 통합
SELECT o.ord_no, o.shop_id
FROM startdbpg.tr_ord o
WHERE o.ord_dtm >= TIMESTAMP '2023-03-01'
AND o.ord_dtm < TIMESTAMP '2023-04-01'
AND o.pay_tp IN ('CARD', 'CASH');
SQL
복사
하나의 주문 테이블에서 결제유형 조건만 다르게 조회하므로, WHERE IN으로 통합하면 두 번 검색하지 않고 한 번만 검색할 수 있다. 서로 다른 테이블 또는 독립적인 결과 집합을 결합해야 한다면 이 방식으로 대체할 수 없고 UNION ALL 등을 검토해야 한다.
실제 데이터 확인
•
2023년 3월 CARD 주문: 16,878건
•
2023년 3월 CASH 주문: 704건
•
전체 조회 대상: 17,582건
•
tr_ord.ord_no는 실제 PRIMARY KEY
•
UNION, UNION ALL, WHERE IN 모두 17,582행 반환
•
세 결과의 EXCEPT ALL 양방향 검증: 차이 0건
실제 실행계획 비교 (EXPLAIN (ANALYZE, BUFFERS, COSTS OFF))
비교 항목 | BAD: UNION | GOOD 1: UNION ALL | GOOD 2: WHERE IN |
반환 행 수 | 17,582 | 17,582 | 17,582 |
실행시간 (각 1회) | 29.931ms | 14.934ms | 8.819ms |
Shared Buffer Hit | 524 | 524 | 262 |
주문 테이블 검색 | 2회 | 2회 | 1회 |
주요 실행 노드 | Append → HashAggregate | Append | Index Scan |
BAD: UNION 실제 실행계획
HashAggregate (actual time=24.320..28.198 rows=17582 loops=1)
Group Key: o.ord_no, o.shop_id
Batches: 1 Memory Usage: 1553kB
Buffers: shared hit=524
-> Append (actual time=0.097..15.269 rows=17582 loops=1)
Buffers: shared hit=524
-> Index Scan using tr_ord_x51 on tr_ord o (actual time=0.096..8.022 rows=16878 loops=1)
Index Cond: ((ord_dtm >= '2023-03-01 00:00:00'::timestamp without time zone) AND (ord_dtm < '2023-04-01 00:00:00'::timestamp without time zone))
Filter: ((pay_tp)::text = 'CARD'::text)
Rows Removed by Filter: 704
Buffers: shared hit=262
-> Index Scan using tr_ord_x51 on tr_ord o_1 (actual time=0.035..5.158 rows=704 loops=1)
Index Cond: ((ord_dtm >= '2023-03-01 00:00:00'::timestamp without time zone) AND (ord_dtm < '2023-04-01 00:00:00'::timestamp without time zone))
Filter: ((pay_tp)::text = 'CASH'::text)
Rows Removed by Filter: 16878
Buffers: shared hit=262
Planning:
Buffers: shared hit=173
Planning Time: 0.963 ms
Execution Time: 29.931 ms
Plain Text
복사
GOOD 1: UNION ALL 실제 실행계획
Append (actual time=0.057..13.668 rows=17582 loops=1)
Buffers: shared hit=524
-> Index Scan using tr_ord_x51 on tr_ord o (actual time=0.057..6.741 rows=16878 loops=1)
Index Cond: ((ord_dtm >= '2023-03-01 00:00:00'::timestamp without time zone) AND (ord_dtm < '2023-04-01 00:00:00'::timestamp without time zone))
Filter: ((pay_tp)::text = 'CARD'::text)
Rows Removed by Filter: 704
Buffers: shared hit=262
-> Index Scan using tr_ord_x51 on tr_ord o_1 (actual time=0.034..4.964 rows=704 loops=1)
Index Cond: ((ord_dtm >= '2023-03-01 00:00:00'::timestamp without time zone) AND (ord_dtm < '2023-04-01 00:00:00'::timestamp without time zone))
Filter: ((pay_tp)::text = 'CASH'::text)
Rows Removed by Filter: 16878
Buffers: shared hit=262
Planning:
Buffers: shared hit=150
Planning Time: 0.817 ms
Execution Time: 14.934 ms
Plain Text
복사
GOOD 2: WHERE IN 실제 실행계획
Index Scan using tr_ord_x51 on tr_ord o (actual time=0.049..7.651 rows=17582 loops=1)
Index Cond: ((ord_dtm >= '2023-03-01 00:00:00'::timestamp without time zone) AND (ord_dtm < '2023-04-01 00:00:00'::timestamp without time zone))
Filter: ((pay_tp)::text = ANY ('{CARD,CASH}'::text[]))
Buffers: shared hit=262
Planning:
Buffers: shared hit=202
Planning Time: 0.930 ms
Execution Time: 8.819 ms
Plain Text
복사
실행계획 읽기: UNION은 Append로 두 집합을 결합한 다음 HashAggregate로 중복을 제거한다. UNION ALL은 중복 제거용 HashAggregate가 없지만, 두 브랜치에서 tr_ord_x51을 각각 사용하여 해당 월의 주문을 반복 검색한다. WHERE IN은 한 번의 Index Scan으로 조회한다. 이 실습에서는 실행시간뿐 아니라 버퍼 접근량도 524 → 262로 줄었다.
핵심: 중복 제거가 필요 없는 집합 결합은 UNION ALL을 우선 검토하자. 더 나아가 동일 테이블의 OR 조건으로 표현할 수 있다면 WHERE IN 등으로 한 번의 조회로 통합하는 편이 단순할 수 있다.
예외·주의:
•
서로 다른 브랜치에 같은 출력 행이 생길 수 있으면 UNION과 UNION ALL의 결과는 달라진다. UNION은 출력된 컬럼의 조합으로 중복을 제거하며, 원본의 PK가 출력되지 않으면 서로 다른 원본 행도 중복으로 판단될 수 있다.
•
중복을 보존해야 하는 업무에서는 UNION ALL을 사용해야 한다.
•
서로 다른 테이블의 결과를 결합하는 경우 WHERE IN은 일반적인 대체안이 아니다.
•
이번 수치는 각각 1회 측정값이다. UNION ALL 또는 WHERE IN이 항상 같은 비율로 빠르다는 뜻은 아니므로 실제 실행계획을 비교해야 한다.
SQL 작성 후 체크리스트
GROUP BY와 출력 컬럼의 단위가 맞는가?
JOIN 때문에 집계 대상 행이 증폭되지 않았는가?
COUNT·SUM의 NULL·중복 처리 정책이 정확한가?
중복 제거가 불필요한 UNION은 UNION ALL로 바꿀 수 있는가?
한 줄 요약: 집계는 SQL이 빠른지보다 어떤 단위로 정확하게 세고 합산하는지가 먼저다. 한 번 읽어 함께 집계할 수 있는 데이터는 되풀이해서 읽지 말자.

