상황: 2019년 3월 20일 SNS로 가입한 GOLD 회원의 2023년 상반기 주문금액 합계를 구한다. 실제 일반 테이블 startdbpg.ms_mbr, startdbpg.tr_ord에서 세 방식의 결과와 실행계획을 검증했다.
BAD SQL — 주문을 먼저 회원별로 집계하고 조건에 맞는 회원을 나중에 JOIN
SELECT m.mbr_id, m.nick_nm, a.ord_amt
FROM startdbpg.ms_mbr m
JOIN (
SELECT o.mbr_id, SUM(o.ord_amt) AS ord_amt
FROM startdbpg.tr_ord o
WHERE o.ord_dtm >= TIMESTAMP '2023-01-01'
AND o.ord_dtm < TIMESTAMP '2023-07-01'
GROUP BY o.mbr_id
) a
ON a.mbr_id = m.mbr_id
WHERE m.mbr_gd = 'GOLD'
AND m.join_dtm >= TIMESTAMP '2019-03-20'
AND m.join_dtm < TIMESTAMP '2019-03-21'
AND m.join_tp = 'SNS'
ORDER BY m.mbr_id;
SQL
복사
GOOD SQL — 조건에 맞는 회원을 JOIN해 주문을 제한한 후 집계
SELECT m.mbr_id, m.nick_nm,
SUM(o.ord_amt) AS ord_amt
FROM startdbpg.ms_mbr m
JOIN startdbpg.tr_ord o
ON o.mbr_id = m.mbr_id
WHERE m.mbr_gd = 'GOLD'
AND m.join_dtm >= TIMESTAMP '2019-03-20'
AND m.join_dtm < TIMESTAMP '2019-03-21'
AND m.join_tp = 'SNS'
AND o.ord_dtm >= TIMESTAMP '2023-01-01'
AND o.ord_dtm < TIMESTAMP '2023-07-01'
GROUP BY m.mbr_id, m.nick_nm
ORDER BY m.mbr_id;
SQL
복사
비교 대안 — LATERAL로 선별된 회원별 주문금액 집계
SELECT m.mbr_id, m.nick_nm, a.ord_amt
FROM startdbpg.ms_mbr m
JOIN LATERAL (
SELECT SUM(o.ord_amt) AS ord_amt
FROM startdbpg.tr_ord o
WHERE o.mbr_id = m.mbr_id
AND o.ord_dtm >= TIMESTAMP '2023-01-01'
AND o.ord_dtm < TIMESTAMP '2023-07-01'
HAVING COUNT(*) > 0
) a ON TRUE
WHERE m.mbr_gd = 'GOLD'
AND m.join_dtm >= TIMESTAMP '2019-03-20'
AND m.join_dtm < TIMESTAMP '2019-03-21'
AND m.join_tp = 'SNS'
ORDER BY m.mbr_id;
SQL
복사
LATERAL은 바깥쪽 m.mbr_id를 내부 집계에 공급한다. HAVING COUNT(*) > 0는 기간 내 주문이 없는 회원을 제외해 원래의 INNER JOIN 결과 의미를 유지한다. 이번 조건에서는 일반 JOIN보다 더 빠르지 않았다.
실제 데이터 및 결과 동등성 검증
•
조건에 해당하는 회원은 1명, M0427 (Mountain8)이다.
•
해당 회원의 2023년 상반기 주문은 6건, 금액 합계는 72,500이다.
•
같은 기간 전체 회원의 주문은 102,493건이며, 주문이 있는 회원은 9,688명이다.
•
세 방식 모두 (M0427, Mountain8, 72500.000) 동일한 1행을 반환한다.
•
세 방식의 결과를 양방향 EXCEPT ALL로 검증했을 때 차이 0건이다.
실제 실행계획 비교 — EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
항목 | BAD — 주문 선집계 | GOOD — 일반 JOIN 후 집계 | 대안 — LATERAL 집계 |
집계 전 주문 접근 행 수 | 102,493 | 6 | 6 |
회원 필터 결과 | 1명 | 1명 | 1명 |
집계 방식 | HashAggregate (9,688그룹) | GroupAggregate (1그룹) | Aggregate (1회) |
조인 방식 | Hash Join | Nested Loop | Nested Loop |
주문 접근 인덱스 | tr_ord_x51(ord_dtm) | tr_ord_x54(mbr_id, ord_dtm) | tr_ord_x54(mbr_id, ord_dtm) |
Buffers: shared hit | 1,647 | 144 | 144 |
실행시간 (재측정 각 1회) | 93.742ms | 2.013ms | 2.146ms |
다른 재측정에서 BAD 99.798ms, 일반 JOIN 2.081~2.233ms, LATERAL 2.146~2.298ms를 관찰했다. 일반 JOIN과 LATERAL은 버퍼 사용량이 같았으며, 이 미세한 실행시간 차이로 우열을 단정할 수 없다. 실제 소요시간은 캐시·부하·통계에 따라 변동한다.
BAD SQL — 실제 실행계획의 핵심 노드
Sort (rows=1 loops=1)
Buffers: shared hit=1647
-> Hash Join (rows=1 loops=1)
Hash Cond: (o.mbr_id = m.mbr_id)
-> HashAggregate (rows=9688 loops=1)
Group Key: o.mbr_id
Memory Usage: 4241kB
-> Index Scan using tr_ord_x51 on tr_ord o
(rows=102493 loops=1)
Index Cond: (ord_dtm >= '2023-01-01'
AND ord_dtm < '2023-07-01')
-> Hash
-> Seq Scan on ms_mbr m (rows=1 loops=1)
Rows Removed by Filter: 9998
Execution Time: 93.742 ms
Plain Text
복사
실제 노드 구조와 행 수를 유지해 핵심만 발췌·축약했다. Memory Usage는 최초 계획에서 확인된 값이며, 최종 실행시간은 재측정 결과다.
GOOD SQL — 실제 실행계획의 핵심 노드
GroupAggregate (rows=1 loops=1)
Group Key: m.mbr_id
Buffers: shared hit=144
-> Sort (rows=6 loops=1)
-> Nested Loop (rows=6 loops=1)
-> Seq Scan on ms_mbr m (rows=1 loops=1)
Rows Removed by Filter: 9998
-> Index Scan using tr_ord_x54 on tr_ord o
(rows=6 loops=1)
Index Cond: (mbr_id = m.mbr_id
AND ord_dtm >= '2023-01-01'
AND ord_dtm < '2023-07-01')
Execution Time: 2.013 ms
Plain Text
복사
위 Index Cond는 읽기 편하게 PostgreSQL의 자료형 변환 표기를 생략한 발췌다.
LATERAL — 실제 실행계획의 핵심 노드
Sort (rows=1 loops=1)
Buffers: shared hit=144
-> Nested Loop (rows=1 loops=1)
-> Seq Scan on ms_mbr m (rows=1 loops=1)
Rows Removed by Filter: 9998
-> Aggregate (rows=1 loops=1)
Filter: (count(*) > 0)
-> Index Scan using tr_ord_x54 on tr_ord o
(rows=6 loops=1)
Index Cond: (mbr_id = m.mbr_id
AND ord_dtm >= '2023-01-01'
AND ord_dtm < '2023-07-01')
Execution Time: 2.146 ms
Plain Text
복사
위 계획은 실제 실행에서 주요 노드·행 수·버퍼만 발췌했으며, Index Cond의 자료형 표기를 생략했다.
왜 달라졌나? BAD의 집계 인라인 뷰는 실제 계획에서 2023년 상반기 전체 102,493건을 읽고 회원 9,688명으로 그룹화했다. 그 후에야 회원 1명과 JOIN했다. GOOD은 회원 조건을 먼저 적용하고, 기존 복합 인덱스 tr_ord_x54(mbr_id, ord_dtm)를 통해 필요한 주문 6건만 읽고 집계했다. LATERAL 역시 같은 인덱스로 6건만 읽어 비슷한 계획과 성능을 보였다. 새 인덱스는 생성하지 않았다.
핵심
•
결과적으로 소수의 회원만 필요하다면, 전체 주문을 먼저 GROUP BY하지 말고 회원을 선별해 JOIN한 후 집계하자. LATERAL은 외부 회원 조건을 내부 집계에 전달하는 대안이지만, 일반 JOIN으로 같은 접근 범위를 구현할 수 있다면 필수는 아니다.
원본 규칙: 개편 전 06-07(일반 JOIN), 07-02(LATERAL).
참고·예외
•
세 방식 모두 INNER JOIN의 결과 의미를 가지므로 기간 내 주문이 없는 회원은 반환하지 않는다. 주문이 없는 회원도 나타내려면 LEFT JOIN을 포함해 결과 의미를 재설계해야 한다.
•
ms_mbr.mbr_id는 PK로 고유하다. 동일한 회원이 중복 행으로 JOIN되면 집계 의미가 달라질 수 있다.
•
PostgreSQL은 SQL의 작성 순서 그대로 실행하지 않는다. 이 사례에서는 실제로 BAD의 전체 선집계와 GOOD의 회원 우선 탐색이 선택됐지만 데이터 분포, 조건 선택성, 인덱스에 따라 다른 계획도 가능하다.
•
회원 조건의 선택성이 낮고 조회 대상이 많다면 선집계가 더 유리할 수 있다. LATERAL은 외부 행마다 내부 집계가 평가되므로 외부 회원 수가 많을 때 반복 접근 비용을 확인해야 한다.
•
LATERAL 집계에서 HAVING COUNT(*) > 0를 생략하면 주문이 없는 회원에 대해서도 SUM=NULL인 집계 결과 1행이 발생할 수 있어 일반 INNER JOIN과 결과가 달라진다.


