Search

REMINDER 10-01. 선별된 회원부터 JOIN하고 집계하자

상황: 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과 결과가 달라진다.