Search

SECTION 07 원문 백업

재편집 전 원문 백업 | SECTION 07 | 2026-10-14
LATERAL은 앞에서 선택한 행의 값을 뒤쪽 서브쿼리에 전달한다.
하지만 조건을 잘 전달한다는 사실이 성능 개선을 보장하지는 않는다.
이 섹션은 SECTION 01. SQL REMINDER 소개로 PostgreSQL LATERAL의 사용·제거를 학습한다. 이미 배운 서브쿼리와 인라인 뷰를 바탕으로 조건 공급, 외부 행당 반복 횟수, 최신 행 검색, 페이징 순서, 인덱스 적합성에 초점을 맞춘다.
⚠️
실행계획 표기 원칙
•
이 문서의 모든 실행계획은 교육용 예상 형태이며 실제 SQL을 실행해 얻은 계획이 아니다. cost, actual rows, Buffers, 실행시간을 만들어 쓰지 않는다.
•
일반적으로 외부 참조가 있는 LATERAL은 외부 입력에 의존하므로 Nested Loop 형태가 유력하지만, PostgreSQL이 단순화·변환·캐시하거나 다른 접근을 선택할 가능성을 배제하지 않는다.
•
아래 인덱스 중 tr_ord_big_x04(mbr_id, ord_dtm)는 원본 실습 SQL에 등장한다. 실제 DB에 생성되어 있다는 뜻은 아니다.
•
원본 204건 요약에는 LATERAL 처리 30건, 제거 2건이 분류되어 있다. 적용이 많다는 사실은 모든 SQL에 LATERAL을 쓰라는 규칙이 아니다.

이번 섹션의 핵심 규칙

ID
구분
기억할 규칙
07-01
MUST
LATERAL은 외부 행을 참조하는 SQL이다
07-02
TIP
선택된 회원 조건을 집계 뷰에 직접 공급하자
07-03
MUST
LEFT JOIN LATERAL과 CROSS JOIN LATERAL의 차이를 알자
07-04
TIP
외부 키별 최신 1건을 LIMIT으로 구하자
07-05
TIP
같은 테이블의 LATERAL 집계를 합칠 수 있다
07-06
MUST
페이징 후 LATERAL을 수행할 수 있는지 검토하자
07-07
TIP
선별한 매장에만 연관 집계를 적용하자
07-08
CHECK
LATERAL 내부 검색 인덱스를 확인하자
07-09
CHECK
외부 행이 많으면 LATERAL 제거를 검토하자
07-10
CHECK
불필요한 LATERAL로 일반 JOIN을 복잡하게 하지 말자
MUST — 작성 시 기억할 규칙 / TIP — 실전 작성 패턴 / CHECK — 실제 행 수·실행계획으로 판단할 항목

LATERAL 문법 한눈에 보기

-- m의 현재 회원ID를 오른쪽 서브쿼리 o에서 직접 참조한다. SELECT m.mbr_id, x.ord_amt FROM ms_mbr_big AS m LEFT JOIN LATERAL ( SELECT SUM(o.ord_amt) AS ord_amt FROM tr_ord_big AS o WHERE o.mbr_id = m.mbr_id ) AS x ON TRUE WHERE m.mbr_id = 'M00001';
SQL
복사
핵심: LATERAL 서브쿼리에서는 같은 FROM 절의 앞에 있는 항목을 참조할 수 있다. ON TRUE는 이미 내부 WHERE에서 회원 조건을 처리하고 있으므로 별도 조인 조건이 없음을 나타낸다. LEFT JOIN LATERAL은 내부가 0행을 반환해도 왼쪽 회원을 남긴다.

REMINDER 07-01. LATERAL에서는 외부 키를 반드시 올바르게 연결하자

MUST · 상황: 회원 M00001의 주문금액을 구한다.
BAD — 내부 조건에 회원 키가 없어 모든 회원의 주문금액이 표시됨
SELECT m.mbr_id, x.ord_amt FROM ms_mbr_big AS m CROSS JOIN ( SELECT SUM(o.ord_amt) AS ord_amt FROM tr_ord_big AS o ) AS x WHERE m.mbr_id = 'M00001';
SQL
복사
GOOD — 바깥 회원ID를 내부에서 참조
SELECT m.mbr_id, x.ord_amt FROM ms_mbr_big AS m LEFT JOIN LATERAL ( SELECT SUM(o.ord_amt) AS ord_amt FROM tr_ord_big AS o WHERE o.mbr_id = m.mbr_id ) AS x ON TRUE WHERE m.mbr_id = 'M00001';
SQL
복사
예상 실행계획
BAD (의미부터 잘못된 SQL) Nested Loop / Cross Join -> 회원 M00001 조회 -> Aggregate -> 전체 주문 검색 => 전체 주문 합계 1값을 회원에 결합 GOOD (가능한 계획) Nested Loop Left Join -> 회원 M00001 조회 -> Aggregate -> Index Scan on tr_ord_big Index Cond: (mbr_id = m.mbr_id) => 외부 회원의 주문만 집계
Plain Text
복사
기억할 것: LATERAL 키워드가 자동으로 조인 조건을 만들어주지는 않는다. 내부 WHERE에 올바른 외부 키 조건이 있어야 한다.
주의: 여기서는 전 회원 주문합계와 개인 주문합계를 비교하는 의도적으로 잘못된 BAD SQL이다. 인덱스가 없어도 결과 오류는 동일하다.

REMINDER 07-02. 필터된 외부 집합을 내부 집계에 연결하자

TIP · 상황: 가입 조건에 맞는 GOLD 회원의 2023년 상반기 주문금액을 구한다.
BAD — 전체 회원 주문을 먼저 집계할 위험
SELECT m.mbr_id, m.nick_nm, a.ord_amt FROM ms_mbr_big AS m JOIN ( SELECT o.mbr_id, SUM(o.ord_amt) AS ord_amt FROM tr_ord_big AS o WHERE o.ord_dtm >= TIMESTAMP '2023-01-01' AND o.ord_dtm < TIMESTAMP '2023-07-01' GROUP BY o.mbr_id ) AS 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';
SQL
복사
GOOD — 해당 회원의 주문만 집계하도록 상관 조건 공급
SELECT m.mbr_id, m.nick_nm, a.ord_amt FROM ms_mbr_big AS m JOIN LATERAL ( SELECT SUM(o.ord_amt) AS ord_amt FROM tr_ord_big AS 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 ) AS 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';
SQL
복사
예상 실행계획
BAD (조건 공급이 실패한 가능한 상황) Hash Join -> HashAggregate (모든 회원의 상반기 주문) -> tr_ord_big 범위 스캔 -> 조건에 맞는 GOLD 회원 집합 GOOD (외부 회원이 적고 인덱스가 맞는 경우) Nested Loop -> GOLD 회원 필터링 -> Aggregate -> Index Scan using tr_ord_big_x04 Index Cond: (mbr_id = m.mbr_id AND ord_dtm 범위) => 내부 조회가 외부 회원별로 반복됨
Plain Text
복사
기억할 것: 외부 회원이 소수라면 복잡한 GROUP BY 인라인 뷰에서 모든 회원의 주문을 처리하는 대신 필요한 회원의 주문만 집계할 수 있다.
주의: HAVING COUNT(*) > 0는 주문이 전혀 없는 회원에 대해 내부 0행을 만들어 원래 INNER JOIN의 결과를 유지하기 위해 넣었다. 이것이 없다면 집계 함수만 있는 서브쿼리는 주문 0건에도 NULL 합계 1행을 반환해 결과가 달라진다. PostgreSQL이 BAD를 이미 최적화할 수도 있다.
교재 근거: StartUP Tuning PG PDF p.240~241, SQL-맛튜닝-조건공급2-1/2-2.

REMINDER 07-03. LEFT와 CROSS LATERAL의 행 보존을 구분하자

MUST · 상황: 회원마다 최신 주문 한 건을 보여주되 주문이 없는 회원도 포함해야 한다.
BAD — 일치 주문이 없는 회원이 사라짐
SELECT m.mbr_id, x.ord_no, x.ord_dtm FROM ms_mbr_big AS m CROSS JOIN LATERAL ( SELECT o.ord_no, o.ord_dtm FROM tr_ord_big AS o WHERE o.mbr_id = m.mbr_id ORDER BY o.ord_dtm DESC, o.ord_no DESC LIMIT 1 ) AS x WHERE m.mbr_gd = 'GOLD';
SQL
복사
GOOD — LEFT JOIN LATERAL로 회원 행 보존
SELECT m.mbr_id, x.ord_no, x.ord_dtm FROM ms_mbr_big AS m LEFT JOIN LATERAL ( SELECT o.ord_no, o.ord_dtm FROM tr_ord_big AS o WHERE o.mbr_id = m.mbr_id ORDER BY o.ord_dtm DESC, o.ord_no DESC LIMIT 1 ) AS x ON TRUE WHERE m.mbr_gd = 'GOLD';
SQL
복사
예상 실행계획
BAD: Nested Loop (Inner/Cross 성격) -> GOLD 회원 -> Limit -> 회원별 주문 인덱스 검색 => 주문 0건 회원 제거 GOOD: Nested Loop Left Join -> GOLD 회원 -> Limit -> 회원별 주문 인덱스 검색 => 주문 0건 회원도 출력, x 컬럼 NULL
Plain Text
복사
기억할 것: LATERAL 서브쿼리가 0행을 반환할 때 CROSS JOIN LATERAL과 LEFT JOIN LATERAL ... ON TRUE의 결과가 다르다.
주의: SELECT SUM(...)처럼 GROUP BY 없는 집계는 입력이 0건이어도 집계 결과 1행을 반환할 수 있다. 따라서 LEFT/CROSS 차이가 나타나지 않는 집계 패턴도 있다.

REMINDER 07-04. 그룹별 최신 1건을 LATERAL + LIMIT으로 찾자

TIP · 상황: 회원별 가장 최근 주문 한 건의 주문번호와 금액을 조회한다.
BAD — MAX 일시로 다시 조인하면 동률에서 여러 행이 나올 수 있다
SELECT m.mbr_id, o.ord_no, o.ord_amt FROM ms_mbr_big AS m LEFT JOIN ( SELECT mbr_id, MAX(ord_dtm) AS last_dtm FROM tr_ord_big GROUP BY mbr_id ) AS last_o ON last_o.mbr_id = m.mbr_id LEFT JOIN tr_ord_big AS o ON o.mbr_id = last_o.mbr_id AND o.ord_dtm = last_o.last_dtm WHERE m.mbr_gd = 'GOLD';
SQL
복사
GOOD — 외부 회원별로 정렬된 최상위 한 건만 선택
SELECT m.mbr_id, x.ord_no, x.ord_amt FROM ms_mbr_big AS m LEFT JOIN LATERAL ( SELECT o.ord_no, o.ord_amt FROM tr_ord_big AS o WHERE o.mbr_id = m.mbr_id ORDER BY o.ord_dtm DESC, o.ord_no DESC LIMIT 1 ) AS x ON TRUE WHERE m.mbr_gd = 'GOLD';
SQL
복사
예상 실행계획
BAD (가능한 형태) HashAggregate (mbr_id, MAX(ord_dtm)) -> 전체 주문 스캔 -> 회원 Join -> 주문 재Join => 동일 최신 시각의 여러 주문이 재결합될 수 있음 GOOD (외부 회원이 적고 정렬 지원 인덱스 존재 시) Nested Loop Left Join -> GOLD 회원 스캔 -> Limit -> Index Scan on tr_ord_big Index Cond: (mbr_id = m.mbr_id) Order: ord_dtm DESC, ord_no DESC (인덱스 설계 전제) => 회원별 최상위 1건만 반환 가능
Plain Text
복사
핵심: 외부 키에 따라 상위 한 건만 선택하는 요구사항에는 LATERAL + ORDER BY + LIMIT 1이 직관적이다.
주의: 이 예제의 BAD와 GOOD은 동일한 최신 일시에서 주문이 둘 이상이면 원래부터 결과가 다르다. 업무 요건이 정확히 '1건'인지 먼저 정의해야 한다. 인덱스가 정렬을 지원하지 않으면 LATERAL 안에서도 Sort가 발생할 수 있다. DISTINCT ON 및 윈도 함수와의 종합 비교는 SECTION 09에서 다룬다.

REMINDER 07-05. 같은 주문을 여러 번 집계하는 LATERAL을 합치자

TIP · 상황: 회원별 특정 월 전체 주문 수와 픽업 완료 주문 수를 함께 조회한다.
BAD — 같은 기간·같은 테이블을 두 번 조회
SELECT m.mbr_id, a.all_cnt, b.pkup_cnt FROM ms_mbr_big AS m LEFT JOIN LATERAL ( SELECT COUNT(*) AS all_cnt FROM tr_ord_big AS o WHERE o.mbr_id = m.mbr_id AND o.ord_dtm >= TIMESTAMP '2023-03-01' AND o.ord_dtm < TIMESTAMP '2023-04-01' ) AS a ON TRUE LEFT JOIN LATERAL ( SELECT COUNT(*) AS pkup_cnt FROM tr_ord_big AS o WHERE o.mbr_id = m.mbr_id AND o.ord_dtm >= TIMESTAMP '2023-03-01' AND o.ord_dtm < TIMESTAMP '2023-04-01' AND o.ord_st = 'PKUP' ) AS b ON TRUE WHERE m.mbr_id = 'M00019';
SQL
복사
GOOD — 한 번의 LATERAL 조회로 조건부 집계
SELECT m.mbr_id, x.all_cnt, x.pkup_cnt FROM ms_mbr_big AS m LEFT JOIN LATERAL ( SELECT COUNT(*) AS all_cnt, COUNT(*) FILTER (WHERE o.ord_st = 'PKUP') AS pkup_cnt FROM tr_ord_big AS o WHERE o.mbr_id = m.mbr_id AND o.ord_dtm >= TIMESTAMP '2023-03-01' AND o.ord_dtm < TIMESTAMP '2023-04-01' ) AS x ON TRUE WHERE m.mbr_id = 'M00019';
SQL
복사
예상 실행계획
BAD (가능한 형태) Nested Loop Left Join -> 회원 집합 -> Aggregate (전체 건수) -> 주문 검색 A -> Aggregate (픽업 건수) -> 주문 검색 B GOOD (가능한 형태) Nested Loop Left Join -> 회원 집합 -> Aggregate (COUNT, FILTER 집계) -> 주문 검색 1회 => 동일 회원·기간의 주문 재탐색 감소 가능
Plain Text
복사
핵심: LATERAL을 쓰더라도 같은 키·기간·테이블을 여러 번 읽는다면 통합 집계가 가능한지 확인하자.
주의: COUNT(*)는 매칭 주문 0건일 때에도 0을 반환하므로 두 SQL의 0건 처리 의미가 같다. 데이터 분포·인덱스에 따라 복수의 작은 선택적 검색이 유리한 예외도 있다.
실제 사례 연결: 204건 요약 0928_62는 LATERAL 인라인 뷰 두 개를 통합하고 조건부 집계를 활용한 사례로 기록되어 있다. 위 SQL은 교육용 재구성이다.

REMINDER 07-06. 페이징 결과를 먼저 확정하고 LATERAL을 수행할 수 있다

MUST · 상황: 회원 등급과 ID 순으로 11~15번째 회원의 2023년 3월 주문금액을 보여준다.
BAD 후보 — 전체 회원에 부가 집계를 적용한 후 페이징
SELECT m.mbr_gd, m.nick_nm, m.mbr_id, x.ord_amt FROM ms_mbr_big AS m LEFT JOIN LATERAL ( SELECT SUM(o.ord_amt) AS ord_amt FROM tr_ord_big AS o WHERE o.mbr_id = m.mbr_id AND o.ord_dtm >= TIMESTAMP '2023-03-01' AND o.ord_dtm < TIMESTAMP '2023-04-01' ) AS x ON TRUE ORDER BY m.mbr_gd, m.mbr_id LIMIT 5 OFFSET 10;
SQL
복사
GOOD — 회원 5건을 선별한 뒤 주문 집계
SELECT p.mbr_gd, p.nick_nm, p.mbr_id, x.ord_amt FROM ( SELECT m.mbr_gd, m.nick_nm, m.mbr_id FROM ms_mbr_big AS m ORDER BY m.mbr_gd, m.mbr_id LIMIT 5 OFFSET 10 ) AS p LEFT JOIN LATERAL ( SELECT SUM(o.ord_amt) AS ord_amt FROM tr_ord_big AS o WHERE o.mbr_id = p.mbr_id AND o.ord_dtm >= TIMESTAMP '2023-03-01' AND o.ord_dtm < TIMESTAMP '2023-04-01' ) AS x ON TRUE ORDER BY p.mbr_gd, p.mbr_id;
SQL
복사
예상 실행계획
BAD (조인이 LIMIT 이전에 실행되는 가능한 경우) Limit -> Sort -> Nested Loop Left Join -> 회원 집합 -> Aggregate -> 회원별 주문 스캔 => 페이지에 없는 회원까지 집계 가능 GOOD (가능한 형태) Nested Loop Left Join -> Limit -> Sort / Index Scan on ms_mbr_big (회원 5건 추출) -> Aggregate -> Index Scan using tr_ord_big_x04 Index Cond: (mbr_id = p.mbr_id AND ord_dtm 범위) => 회원 5건에 대해서만 부가 집계 가능
Plain Text
복사
핵심: 최종 정렬·페이징 기준이 회원 컬럼만으로 정해질 때는 회원 페이지를 먼저 선택하고, 필요한 행에 대해서만 LATERAL을 실행하는 방식이 유리할 수 있다.
주의: 위 집계는 회원마다 항상 1행을 반환해 회원 페이징 결과를 보존한다. 반대로 정렬 기준에 주문합계 ord_amt가 포함된다면 먼저 회원 페이징하는 SQL은 원래 결과와 달라져 사용할 수 없다. PostgreSQL이 BAD에서 이미 일부 최적화를 수행할 가능성도 있다.
교재 근거: StartUP Tuning PG PDF p.295, SQL-PAGE-JOIN1/2.

REMINDER 07-07. 선택된 매장에 대해서만 관련 테이블을 집계하자

TIP · 상황: 면적·운영유형 조건에 맞는 매장의 2023년 1분기 이벤트 응모 건수와 주문 건수를 조회한다.
BAD 후보 — 전체 매장의 응모·주문을 각각 미리 집계
SELECT s.shop_id, s.shop_nm, COALESCE(e.entry_cnt, 0) AS entry_cnt, COALESCE(o.ord_cnt, 0) AS ord_cnt FROM ms_shop_big AS s LEFT JOIN ( SELECT shop_id, COUNT(*) AS entry_cnt FROM tr_event_entry_big WHERE entry_dtm >= TIMESTAMP '2023-01-01' AND entry_dtm < TIMESTAMP '2023-04-01' GROUP BY shop_id ) AS e ON e.shop_id = s.shop_id LEFT JOIN ( SELECT shop_id, COUNT(*) AS ord_cnt FROM tr_ord_big WHERE ord_dtm >= TIMESTAMP '2023-01-01' AND ord_dtm < TIMESTAMP '2023-04-01' GROUP BY shop_id ) AS o ON o.shop_id = s.shop_id WHERE s.shop_oper_tp = 'DIST' AND s.table_qty > 25;
SQL
복사
GOOD 후보 — 매장 필터 후 LATERAL 집계
SELECT s.shop_id, s.shop_nm, e.entry_cnt, o.ord_cnt FROM ms_shop_big AS s LEFT JOIN LATERAL ( SELECT COUNT(*) AS entry_cnt FROM tr_event_entry_big AS a WHERE a.shop_id = s.shop_id AND a.entry_dtm >= TIMESTAMP '2023-01-01' AND a.entry_dtm < TIMESTAMP '2023-04-01' ) AS e ON TRUE LEFT JOIN LATERAL ( SELECT COUNT(*) AS ord_cnt FROM tr_ord_big AS b WHERE b.shop_id = s.shop_id AND b.ord_dtm >= TIMESTAMP '2023-01-01' AND b.ord_dtm < TIMESTAMP '2023-04-01' ) AS o ON TRUE WHERE s.shop_oper_tp = 'DIST' AND s.table_qty > 25;
SQL
복사
예상 실행계획
BAD (전체 선집계가 남는 경우) Hash Left Join -> 조건에 맞는 매장 -> HashAggregate (모든 매장의 1분기 응모) -> HashAggregate (모든 매장의 1분기 주문) GOOD (대상 매장 수가 적을 때 가능한 형태) Nested Loop Left Join -> 조건에 맞는 매장 -> Aggregate -> Index Scan on tr_event_entry_big Index Cond: (shop_id = s.shop_id AND entry_dtm 범위) -> Aggregate -> Index Scan on tr_ord_big Index Cond: (shop_id = s.shop_id AND ord_dtm 범위)
Plain Text
복사
핵심: 대상 매장이 극히 적으면 전체 매장의 월별 집계를 만들 필요가 없을 수 있다.
주의: COUNT(*) 집계는 입력 0건에서도 0을 반환한다. BAD는 COALESCE로 결과를 0으로 맞췄다. 반대로 대상 매장이 매우 많다면 전체 집계가 더 유리할 수 있다.
교재 근거: StartUP Tuning PG PDF p.244 BOOSTER QUIZ-3. 원본에서는 이벤트 인덱스 (shop_id, entry_dtm)를 만들고 LATERAL 적용 전후를 비교하도록 안내한다.

REMINDER 07-08. LATERAL 내부 인덱스는 외부 키를 선두로 검토하자

CHECK · 상황: GOLD 회원별 2023년 3월 주문금액 집계를 LATERAL로 실행한다.
BAD 후보 — 날짜만으로 검색하는 인덱스가 사용되는 경우
-- 날짜 단독 인덱스 x01을 통한 접근을 가정 SELECT m.mbr_id, x.ord_amt FROM ms_mbr_big AS m LEFT JOIN LATERAL ( SELECT SUM(o.ord_amt) AS ord_amt FROM tr_ord_big AS o WHERE o.mbr_id = m.mbr_id AND o.ord_dtm >= TIMESTAMP '2023-03-01' AND o.ord_dtm < TIMESTAMP '2023-04-01' ) AS x ON TRUE WHERE m.mbr_gd = 'GOLD';
SQL
복사
GOOD 후보 — 회원 키와 날짜 범위를 지원하는 인덱스
-- 기존 실습 DDL (예제 비교용) -- CREATE INDEX tr_ord_big_x04 -- ON tr_ord_big (mbr_id, ord_dtm); -- SQL은 동일하다. 실제 인덱스 선택이 핵심이다. SELECT m.mbr_id, x.ord_amt FROM ms_mbr_big AS m LEFT JOIN LATERAL ( SELECT SUM(o.ord_amt) AS ord_amt FROM tr_ord_big AS o WHERE o.mbr_id = m.mbr_id AND o.ord_dtm >= TIMESTAMP '2023-03-01' AND o.ord_dtm < TIMESTAMP '2023-04-01' ) AS x ON TRUE WHERE m.mbr_gd = 'GOLD';
SQL
복사
예상 실행계획
BAD 가능: Nested Loop Left Join -> GOLD 회원 -> Aggregate -> Index Scan using tr_ord_big_x01 Index Cond: (ord_dtm 범위) Filter: (mbr_id = m.mbr_id) → 해당 월 주문을 외부 회원마다 넓게 검색할 위험 GOOD 가능: Nested Loop Left Join -> GOLD 회원 -> Aggregate -> Index Scan using tr_ord_big_x04 Index Cond: (mbr_id = m.mbr_id AND ord_dtm 범위) → 회원별 좁은 검색 구간을 반복하는 형태
Plain Text
복사
핵심: LATERAL은 외부 키를 넘길 뿐, 그 키에 적합한 인덱스가 없으면 반복 접근 비용을 줄이지 못할 수 있다.
주의: 위 BAD와 GOOD의 SQL은 완전히 같다. 인덱스 x01/x04 중 어떤 계획을 선택할지는 실제 데이터·통계가 결정한다. 동일 SQL에 대한 두 가능성을 인위적으로 분리해 설명한 교육용 비교다.
교재 근거: PDF p.107~119의 복합 인덱스, p.241 LATERAL 조건 공급. 인덱스 tr_ord_big_x04(mbr_id, ord_dtm)는 원본 실습 정의다.

REMINDER 07-09. 외부 행이 많다면 LATERAL을 제거하고 한 번에 집계해보자

CHECK · 상황: 회원 전체의 2023년 상반기 주문금액을 조회한다.
BAD 후보 — 회원 전체를 순회하며 상관 집계
SELECT m.mbr_id, m.nick_nm, x.ord_amt FROM ms_mbr_big AS m LEFT JOIN LATERAL ( SELECT SUM(o.ord_amt) AS ord_amt FROM tr_ord_big AS o WHERE o.mbr_id = m.mbr_id AND o.ord_dtm >= TIMESTAMP '2023-01-01' AND o.ord_dtm < TIMESTAMP '2023-07-01' ) AS x ON TRUE;
SQL
복사
GOOD 후보 — LATERAL 제거, 회원별 선집계 1회
SELECT m.mbr_id, m.nick_nm, x.ord_amt FROM ms_mbr_big AS m LEFT JOIN ( SELECT o.mbr_id, SUM(o.ord_amt) AS ord_amt FROM tr_ord_big AS o WHERE o.ord_dtm >= TIMESTAMP '2023-01-01' AND o.ord_dtm < TIMESTAMP '2023-07-01' GROUP BY o.mbr_id ) AS x ON x.mbr_id = m.mbr_id;
SQL
복사
예상 실행계획
BAD (외부 회원이 많은 상황) Nested Loop Left Join -> Seq Scan on ms_mbr_big -> Aggregate -> Index Scan on tr_ord_big (mbr_id + 기간) → 회원 수에 비례하는 많은 반복 검색 가능 GOOD (가능한 대안) Hash Left Join -> Seq Scan on ms_mbr_big -> Hash -> HashAggregate (mbr_id) -> 기간 내 tr_ord_big 일괄 스캔 → 주문을 크게 한 번 읽는 방식으로 전환 가능
Plain Text
복사
핵심: 회원이 많으면 수만 회 작은 검색보다 한 번의 큰 스캔과 집계 후 JOIN이 더 경제적일 수 있다. LATERAL 제거 역시 정상적인 튜닝이다.
주의: 이 두 SQL은 주문이 없는 회원도 반환하고 SUM을 NULL로 표시한다. 정확한 속도는 실제 측정해야 한다. PostgreSQL이 상관 LATERAL을 변환할 가능성도 있으므로 SQL만 보고 반드시 Nested Loop라고 단정하지 않는다.
원본 반례: StartUP Tuning PG PDF p.243 SQL-맛튜닝-조건공급3에는 LATERAL 제거 쪽이 소폭 유리했던 측정 사례가 제시된다. 204건 분류의 0922_58, 0922_59도 LATERAL 제거로 기록되어 있으나, 그 두 사례의 상세 실행계획은 현재 요약에 없다.

REMINDER 07-10. 단순한 JOIN에는 LATERAL을 억지로 넣지 말자

CHECK · 상황: 특정 기간의 주문에 매장명을 연결한다. 집계나 외부 행별 LIMIT이 필요하지 않다.
BAD 후보 — LATERAL이지만 실질적으로 단순 매장 조인
SELECT o.ord_no, o.shop_id, s.shop_nm FROM tr_ord_big AS o JOIN LATERAL ( SELECT x.shop_id, x.shop_nm FROM ms_shop_big AS x WHERE x.shop_id = o.shop_id ) AS s ON TRUE WHERE o.ord_dtm >= TIMESTAMP '2023-03-01' AND o.ord_dtm < TIMESTAMP '2023-04-01';
SQL
복사
GOOD — 의미가 명확한 일반 JOIN
SELECT o.ord_no, o.shop_id, s.shop_nm FROM tr_ord_big AS o JOIN ms_shop_big AS s ON s.shop_id = o.shop_id WHERE o.ord_dtm >= TIMESTAMP '2023-03-01' AND o.ord_dtm < TIMESTAMP '2023-04-01';
SQL
복사
예상 실행계획
BAD / GOOD 모두 가능한 형태 Hash Join Hash Cond: (o.shop_id = s.shop_id) -> tr_ord_big의 기간 조건 검색 -> Hash -> ms_shop_big 스캔 또는 Nested Loop + 매장 PK Index Scan => PostgreSQL이 단순 LATERAL 서브쿼리를 일반 JOIN처럼 최적화 가능
Plain Text
복사
핵심: 외부 컬럼을 참조한다는 이유만으로 LATERAL이 특별한 성능 향상을 만드는 것은 아니다. 평범한 1:N 또는 N:1 연결이라면 일반 JOIN이 명확하다.
주의: 여기서는 LATERAL 사용 자체가 오류가 아니며, PostgreSQL은 두 SQL을 같은 계획으로 만들 수도 있다. 이 항목의 BAD는 불필요한 문법적 복잡성을 뜻한다.

LATERAL을 사용할지 제거할지 판단하는 기준

상황
먼저 검토할 작성 방식
반드시 확인할 점
외부 회원이 수 건
LATERAL + 회원별 인덱스 접근
반복 횟수와 단건 검색 비용
외부 회원이 수만 건
GROUP BY 선집계 + 일반 JOIN
일괄 스캔·집계 비용
그룹마다 최근 1건
LATERAL + ORDER BY + LIMIT 1
동률 처리·정렬 지원 인덱스
회원 페이지 5건의 집계
LIMIT 이후 LATERAL
페이징 순서가 집계 결과에 의존하지 않는가
같은 테이블의 통계 여러 개
하나의 LATERAL + FILTER 집계
두 번 이상 읽는지
단순 매장명 조회
일반 JOIN
불필요한 LATERAL 제거 가능
핵심 판단: LATERAL의 존재 자체보다 외부 입력 행 수 × 내부 1회 검색 비용을 생각해야 한다. 실제 PostgreSQL에서는 캐싱·계획 변환 등이 개입할 수 있으므로 이 식은 개념적 점검 기준이지 측정값 계산식이 아니다.

실행계획에서 확인할 신호

-- 실제 DB에 접속 가능한 경우, 먼저 계획만 확인한다. EXPLAIN (COSTS OFF) SELECT m.mbr_id, x.ord_amt FROM ms_mbr_big AS m LEFT JOIN LATERAL ( SELECT SUM(o.ord_amt) AS ord_amt FROM tr_ord_big AS o WHERE o.mbr_id = m.mbr_id AND o.ord_dtm >= TIMESTAMP '2023-03-01' AND o.ord_dtm < TIMESTAMP '2023-04-01' ) AS x ON TRUE WHERE m.mbr_id = 'M00019'; -- 실습 환경에서 실제 SELECT를 실행해 측정할 때만: EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT m.mbr_id, x.ord_amt FROM ms_mbr_big AS m LEFT JOIN LATERAL ( SELECT SUM(o.ord_amt) AS ord_amt FROM tr_ord_big AS o WHERE o.mbr_id = m.mbr_id AND o.ord_dtm >= TIMESTAMP '2023-03-01' AND o.ord_dtm < TIMESTAMP '2023-04-01' ) AS x ON TRUE WHERE m.mbr_id = 'M00019';
SQL
복사
•
Nested Loop / Nested Loop Left Join: 상관 실행의 단서다. 하지만 실제 최적화 결과가 일반 JOIN·다른 구조로 바뀔 수도 있다.
•
loops: 내부 Aggregate·Index Scan이 실제로 몇 회 실행됐는지 본다. 외부 회원 건수와 비교한다.
•
Index Cond와 Filter: 외부 회원 mbr_id 조건이 인덱스 탐색에 쓰였는지 본다.
•
actual rows / Buffers: 내부 조회 1회당 후보·반환 행 수, 누적 읽기 부담을 확인한다.
•
Sort / Limit: 최신 한 건 조회에서 정렬이 추가되는지, Limit이 내부에 배치됐는지 본다.
•
Materialize / Memoize: PostgreSQL이 반복 입력·결과 재사용을 시도하는지 확인한다. Memoize가 있다고 모든 반복 비용이 없어지는 것은 아니다.
EXPLAIN ANALYZE는 SQL을 실제로 실행한다. 이 자료에서 설명한 계획은 아직 실측하지 않았다.

개발 전 체크리스트

LATERAL 안에 올바른 외부 조인 키 조건을 넣었는가?
외부 행이 없거나 내부 결과가 0행일 때 JOIN 유형에 따른 결과를 확인했는가?
SUM과 COUNT 집계가 입력 0건일 때 1행을 반환할 수 있음을 알고 있는가?
외부 집합은 충분히 선택적인가?
내부 검색에 회원·매장 키를 선두로 사용할 인덱스가 존재하는가?
그룹별 1건 조회에 명확한 ORDER BY와 동률 처리 키가 있는가?
동일 테이블의 LATERAL 두 개를 조건부 집계 하나로 합칠 수 있는가?
페이징을 LATERAL 앞쪽으로 옮겨도 결과가 동일한가?
외부 행이 많아 반복 접근이 과도하면 일괄 집계 대안도 비교했는가?
LATERAL 제거 또는 변경 후 NULL·중복·반환 행 수·정렬 결과가 동일한가?

참고 자료 및 근거

•
StartUPTuning_PG_20250616.pdf p.240~243: 조건 공급이 어려운 인라인 뷰, LATERAL 적용, WITH·일반 JOIN 대안, LATERAL 제거 반례
•
같은 교재 p.244: BOOSTER QUIZ-3, 선택된 매장의 이벤트·주문 집계를 LATERAL로 연결하는 실습
•
같은 교재 p.294~295: 페이징 뒤 부가정보 조회와 SQL-PAGE-JOIN1/2 비교
•
StartUPTuning_PG_SQL_Script.sql: SQL-맛튜닝-조건공급2-1/2-2/2-3/2-4/3, SQL-PAGE-JOIN1/2, tr_ord_big_x04
•
: 0317_02(조건 공급 목적으로 적용), 0922_58/59(제거), 0928_62(두 LATERAL 집계 통합) 등
출처와 예제의 차이: 이 페이지의 BAD/GOOD SQL은 원본 실습의 핵심 패턴을 BIG 계열 테이블에 맞춰 재구성한 교육용 SQL이다. 모든 예상 실행계획은 가능성을 보여주는 구조이며, 원본의 실측 시간·Buffers를 이 예제의 실제 측정치로 표시하지 않았다. 특히 07-04의 그룹별 최신 주문, 07-05의 FILTER 통합, 07-10의 불필요한 LATERAL 예제는 개념 설명을 위해 새로 작성했다.
🖼️
추후 그림 제작 메모 — 현재 이미지 미삽입
•
그림 A · 조건 공급: 바깥 GOLD 회원 N건의 키가 LATERAL 내부 tr_ord_big 인덱스로 전달되는 과정을 화살표로 표시
•
그림 B · LEFT / CROSS 차이: 주문이 없는 회원 1명을 포함하거나 제외하는 결과 표 비교
•
그림 C · 최신 1건 탐색: 회원별 인덱스 리프 ORDER BY DESC LIMIT 1 탐색과 전체 MAX 집계 비교
•
그림 D · 페이징과 LATERAL: 전체 회원 집계 후 5건 추출 vs 회원 5건 선별 후 집계
•
그림 E · LATERAL 제거: 회원 N명에 대한 반복 인덱스 조회 vs 주문 전체 1회 GROUP BY 후 Hash Join
SECTION 07 한 줄 요약: LATERAL은 외부 조건을 내부에 전달하는 유용한 수단이지만, 선택적 입력에서는 적용을, 대량 반복에서는 제거를 함께 검토하자.