상황: 전체 회원 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를 선택한다고 단정할 수 없다.


