Search

REMINDER 10-04. 회원이 많다면 LATERAL 반복 집계보다 선집계를 검토하자

상황: 전체 회원 9,999명의 2023년 상반기(1월 1일~6월 30일) 주문금액 합계를 구한다. 회원별 집계가 필요하지만 대상 회원을 줄이는 별도 필터는 없다. 일반 테이블 startdbpg.ms_mbr와 startdbpg.tr_ord로 실제 실행계획과 결과를 검증했다.

BAD SQL — 회원별 LATERAL 내부 집계가 반복 실행

SELECT m.mbr_id, m.nick_nm, x.ord_amt FROM startdbpg.ms_mbr m LEFT 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' ) x ON TRUE ORDER BY m.mbr_id;
SQL
복사
LATERAL이 항상 나쁜 것은 아니다. 이 사례에서는 외부 회원 9,999명에 대해 내부 Aggregate와 주문 인덱스 조회가 9,999회 반복됐다.

GOOD SQL — 기간 내 주문을 한 번에 집계한 뒤 회원과 LEFT JOIN

SELECT m.mbr_id, m.nick_nm, x.ord_amt FROM startdbpg.ms_mbr m LEFT 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 ) x ON x.mbr_id = m.mbr_id ORDER BY m.mbr_id;
SQL
복사
상반기 주문을 한 번 읽어 회원별로 GROUP BY한 후 전체 회원과 JOIN한다. 실제 계획은 HashAggregate + Merge Left Join으로, 이전의 예상 Hash Join과 달랐다.
실제 데이터 및 결과 검증
•
전체 회원 9,999명, 기간 내 주문 102,493건, 기간 내 주문이 있는 회원 9,688명.
•
기간 내 주문이 없는 311명도 두 SQL 모두 반환하며, ord_amt는 NULL이다.
•
두 SQL의 출력은 각각 9,999행으로 같고, 양방향 EXCEPT ALL에서 차이 0건이었다.
실제 실행계획 비교 — EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
항목
BAD — LATERAL 반복 집계
GOOD — 선집계 후 JOIN
외부 회원 접근
9,999행
9,999행
주문 검색
회원별 인덱스 조회 9,999회
2023년 상반기 주문 102,493건 일괄 조회
주요 실행 노드
Nested Loop Left Join → Aggregate
HashAggregate → Sort → Merge Left Join
주문 인덱스
tr_ord_x54(mbr_id, ord_dtm)
tr_ord_x51(ord_dtm)
Shared Buffer Hit
122,811
1,673
실행시간 (반복 측정)
194.679ms
116.589ms
동일한 SQL을 교차 재실행했으며, 다른 측정에서도 BAD 198.909ms, GOOD 115.290ms였다. 처음 실행에서는 BAD 228.907ms, GOOD 133.246ms였다. 실행시간은 캐시·시스템 부하에 따라 달라지므로 각 값은 해당 실행의 실측치다.

BAD SQL — 실제 실행계획의 핵심 노드

Nested Loop Left Join (rows=9999 loops=1) Buffers: shared hit=122811 -> Index Scan using ms_mbr_pk on ms_mbr m (rows=9999 loops=1) -> Aggregate (rows=1 loops=9999) -> Index Scan using tr_ord_x54 on tr_ord o (rows=10 loops=9999) Index Cond: (mbr_id = m.mbr_id AND ord_dtm >= '2023-01-01' AND ord_dtm < '2023-07-01') Execution Time: 194.679 ms
Plain Text
복사
Index Scan의 rows=10은 루프당 평균을 반올림한 수치다. 주문 접근이 전체 10건이라는 뜻이 아니다.

GOOD SQL — 실제 실행계획의 핵심 노드

Merge Left Join (rows=9999 loops=1) Buffers: shared hit=1673 -> Index Scan using ms_mbr_pk on ms_mbr m (rows=9999 loops=1) -> Sort (rows=9688 loops=1) Sort Key: x.mbr_id -> HashAggregate (rows=9688 loops=1) Group Key: o.mbr_id Batches: 1 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') Execution Time: 116.589 ms
Plain Text
복사
노드 구조, 실제 행 수, Buffer Hit와 실행시간을 발췌했으며 자료형 변환 등 부가 표기는 생략했다.
왜 달라졌나? BAD는 주문 인덱스를 활용했지만 회원 수만큼 반복 접근해 Shared Buffer Hit가 122,811이었다. GOOD은 상반기 주문을 한 번 읽어 9,688개 회원 그룹으로 집계하고 회원과 결합했다. Shared Buffer Hit는 1,673으로 감소했다. 기존 인덱스만 사용했으며 새 인덱스는 생성하지 않았다.

핵심

•
외부 집합이 크고 같은 범위의 주문을 전체 회원별로 집계해야 한다면, LATERAL의 반복 접근 대신 먼저 GROUP BY하고 JOIN하는 방식을 검토하자. 10-01은 소수 회원을 먼저 선별하는 정반대 사례로, 함께 비교하면 효과적이다.
원본 규칙: 개편 전 07-09.
💡
참고·예외
•
LEFT JOIN으로 회원을 모두 유지하기 때문에 주문이 없는 회원의 집계금액은 두 방식 모두 NULL이다. COALESCE(..., 0)를 요구한다면 양쪽에 같은 기준으로 적용해야 한다.
•
외부 회원 수가 적거나 회원별 주문 접근이 매우 효율적이면 LATERAL이 더 유리할 수 있다. 반복 횟수(loops)와 Buffer Hit를 실제 확인해야 한다.
•
선집계에는 HashAggregate 메모리와 JOIN·정렬 비용이 발생한다. 데이터 분포·메모리·실행계획에 따라 결과는 달라질 수 있다. SQL에 LATERAL이 쓰였다는 사실만으로 반드시 Nested Loop를 선택한다고 단정할 수 없다.