Nested Loops Join 기본 원리: Outer·Inner·반복 탐색
Driving Row마다 Inner Row Source가 반복 시작되는 NL Join 구조를 Starts와 A-Rows로 계산합니다.
핵심 요약
Nested Loops Join은 Outer Row Source가 한 행을 생산할 때마다 그 행의 Join Key로 Inner Row Source를 다시 시작하는 조인 방식입니다.
Outer Row 1
→ Inner Probe
→ Match 반환
Outer Row 2
→ Inner Probe
→ Match 반환
...
개념적인 총비용입니다.
NL 총비용
≈ Outer Row Source 생성 비용
+ Outer 실제 행 수
× Inner 1회 탐색 비용
실제 결과 Row 수는 다음 값에도 영향을 받습니다.
NL 결과 Row
≈ Outer Row 수
× Outer 한 행당 Inner 평균 Match 수
따라서 NL Join은 다음 조건에서 경쟁력이 높습니다.
- Predicate 적용 후 Outer Row Source가 작음
- Inner Join Key를 Index·Partition Key 등으로 빠르게 탐색 가능
- Inner 한 번의 Probe가 적은 Row·Block만 처리
- 첫 행·첫 페이지 응답이 중요
- Client가 전체 결과를 끝까지 Fetch하지 않을 수 있음
반대로 작은 1회 비용도 수십만·수백만 번 반복되면 전체 Buffers와 Elapsed가 커집니다.
이 이론의 범위
이 이론은 SQLP의
SQL 고급활용 및 튜닝 → 조인 순서와 조인 방식 → NL 조인범위에서 Outer·Inner 구조, Starts·A-Rows 해석, BATCHED ROWID Access와 반복 비용 진단을 다룹니다.
학습 목표
이 이론을 학습한 뒤에는 다음을 설명할 수 있어야 합니다.
- Outer·Inner Row Source의 역할을 구분한다.
- NL Join을 중첩 반복문 구조로 설명한다.
- 원본 Table 크기보다 Filter 후 Outer Cardinality가 중요한 이유를 설명한다.
- Inner
Starts와 OuterA-Rows의 관계를 설명한다. - Inner
A-Rows/Starts로 1회 평균 Match 수를 계산한다. - Oracle 11g 이후 NL Plan에 두 개의
NESTED LOOPS가 나타날 수 있는 이유를 설명한다. TABLE ACCESS BY INDEX ROWID BATCHED의 목적을 설명한다.- Covering Index가 Inner Table Access를 제거할 수 있는 이유를 설명한다.
- First Row와 Full Fetch 성능 목표를 구분한다.
- Multi-Table NL에서 앞 단계의 행 증가가 뒤 단계 반복에 전파되는 과정을 설명한다.
- Adaptive Plan에서 실제 왼쪽 Row 수에 따라 NL·Hash 후보가 선택될 수 있음을 설명한다.
ALLSTATS LAST에서 Starts·E-Rows·A-Rows·Buffers를 맞춰 검증한다.
1. NL Join의 기본 구조
두 Row Source의 역할입니다.
| 구분 | 역할 |
|---|---|
| Outer Row Source | 먼저 행을 생산해 조인을 이끄는 입력 |
| Inner Row Source | Outer의 Join Key를 받아 반복 탐색되는 입력 |
중첩 반복문으로 표현하면 다음과 같습니다.
FOR outer_row IN outer_row_source LOOP
FOR inner_row IN inner_row_source(outer_row.join_key) LOOP
두 Row를 결합해 반환
END LOOP
END LOOP
Outer는 반드시 단일 Table이 아닙니다.
- Index Scan 후 Table Access 결과
- Full Table Scan의 Filter 결과
- View·Inline View
- Aggregate 결과
- 이전 Join의 중간 결과
- Partition Iterator 결과
원본 Table
≠ NL의 실제 Outer 크기
Predicate 적용 후 Row Source
= NL 반복 횟수의 출발점
2. 업무 예제
ORDERS
한 행 = 주문 한 건
CUSTOMERS
한 행 = 고객 한 명
CUSTOMER_ID Primary Key
최근 PAID 주문과 고객명을 조회합니다.
SELECT /*+ GATHER_PLAN_STATISTICS */
o.order_id,
o.order_date,
c.customer_name
FROM orders o
JOIN customers c
ON c.customer_id = o.customer_id
WHERE o.order_date >= :from_date
AND o.status = 'PAID';
ORDERS가 Outer이고 CUSTOMERS가 Inner인 논리 흐름입니다.
1. 조건을 만족하는 ORDERS Row 한 건을 읽음
2. CUSTOMER_ID를 얻음
3. CUSTOMERS_PK를 Probe
4. 고객 Row를 주문 Row와 결합
5. 다음 주문에 대해 반복
Outer가 3행이고 고객 Key가 유일하면 다음과 같은 방향입니다.
Outer A-Rows 3
CUSTOMERS_PK Starts 3
Inner 평균 Match 1
최종 Join A-Rows 3
3. 실행계획의 Outer와 Inner
3.1 고전적인 기본 형태
NESTED LOOPS
TABLE ACCESS FULL DEPARTMENTS ← Outer
TABLE ACCESS BY INDEX ROWID EMPLOYEES
INDEX RANGE SCAN EMP_DEPARTMENT_IX ← Inner 탐색
논리적으로는 DEPARTMENTS Row마다 EMP_DEPARTMENT_IX를 Probe하고, 얻은 ROWID로 EMPLOYEES Table Row를 읽습니다.
3.2 최신 Plan에서 두 개의 NESTED LOOPS
Oracle 11g 이후에는 Index에서 ROWID를 얻는 단계와 Table Row를 읽는 단계를 분리해 다음처럼 표시될 수 있습니다.
NESTED LOOPS
NESTED LOOPS
Outer Row Source
INDEX RANGE SCAN INNER_INDEX
TABLE ACCESS BY INDEX ROWID INNER_TABLE
해석합니다.
Inner NESTED LOOPS
→ Outer Row와 Inner Index Entry·ROWID를 결합
Outer NESTED LOOPS
→ 생성된 ROWID로 Inner Table Row를 읽음
따라서 단순히 NESTED LOOPS의 두 번째 자식만 보고 Inner Table 전체를 판단하지 않습니다. Index ROWID 생성과 Table Access를 한 묶음의 논리적 Inner Access로 읽습니다.
4. TABLE ACCESS BY INDEX ROWID BATCHED
TABLE ACCESS BY INDEX ROWID BATCHED
INDEX RANGE SCAN
BATCHED Access는 Index에서 ROWID를 몇 개씩 모은 뒤 Table Block 순서에 가깝게 Row를 방문하려는 방식입니다.
목적입니다.
- 같은 Table Block을 반복 방문하는 횟수 감소
- Clustering이 좋지 않은 Range Scan의 Block 접근 개선
- Index 순서 그대로 한 건씩 Table을 방문하는 비용 완화
주의합니다.
BATCHED
→ Random Access 제거 X
→ ROWID 기반 Table Access를 Batch로 개선 O
후보 ROWID가 매우 많거나 Table Filter 탈락이 크면 BATCHED여도 전체 비용은 클 수 있습니다.
5. Covering Index와 Table Access
Index가 Query에 필요한 모든 Column을 포함하면 Inner Table Access가 생략될 수 있습니다.
SELECT o.order_id,
c.customer_id
FROM orders o
JOIN customers c
ON c.customer_id = o.customer_id;
CUSTOMERS_PK에 필요한 Column이 모두 있다면 다음처럼 Index만으로 끝날 수 있습니다.
NESTED LOOPS
ORDERS Row Source
INDEX UNIQUE SCAN CUSTOMERS_PK
반대로 customer_name처럼 Index에 없는 Column이 필요하면 ROWID Table Access가 추가됩니다.
Index Probe
+ Table Access 여부
= Inner 1회 비용
Covering을 위해 Column을 무조건 추가하면 Index 폭·DML·공간 비용이 증가하므로 전체 Workload로 판단합니다.
6. NL Join이 유리한 조건
6.1 Filter 후 Outer가 작음
ORDERS 원본 1,000,000,000행
→ 날짜·상태 조건 후 5행
→ Outer 5행
대형 Table도 선택적인 Access Path가 있으면 작은 Outer가 될 수 있습니다.
6.2 Inner Access가 효율적임
대표적으로 다음 조건입니다.
- PK·UK Index Unique Scan
- 선택적인 Index Range Scan
- Partition Key를 통한 작은 Partition Access
- Covering Index
- Key당 Match Row가 적음
- Inner Row·Index Block이 Cache에 잘 유지됨
6.3 부분범위 처리
NL Join은 첫 Outer Row와 Inner Match를 얻으면 즉시 결과를 반환할 수 있습니다.
첫 20행
→ 전체 Build·Sort 없이 빠를 수 있음
전체 100만행
→ 반복 Probe 총비용이 더 중요
FIRST_ROWS_n 목표는 첫 n행 Cost를 선호하도록 할 수 있지만 실제 결과 순서와 Fetch 범위는 SQL·Client 계약으로 결정됩니다.
7. NL Join이 불리해지는 원인
| 원인 | 반복 비용 |
|---|---|
| Outer A-Rows 과다 | Inner Starts 증가 |
| Inner Index 없음 | Full Scan·넓은 Scan 반복 |
| 낮은 선택도의 Inner Range Scan | Leaf Entry·ROWID 증가 |
| Key당 Match Row 다수 | Inner A-Rows·최종 결과 증가 |
| Table Filter 대량 탈락 | RowID Table Access 후 폐기 반복 |
| 높은 Clustering Factor | Table Block 방문 분산 |
| 전체 결과 Full Fetch | First Row 장점보다 총 반복 비용 우세 |
| Outer Cardinality 과소 추정 | NL Cost를 실제보다 작게 계산 |
| Bind별 Outer 규모 차이 | 한 Plan이 모든 Bind에 부적합 가능 |
핵심 진단식입니다.
Inner Total Buffers
≈ Inner Starts × Inner 평균 Buffers per Start
8. Starts·E-Rows·A-Rows
V$SQL_PLAN_STATISTICS에서 다음을 구분합니다.
LAST_STARTS
→ 마지막 실행에서 Row Source가 시작된 횟수
LAST_OUTPUT_ROWS
→ 마지막 실행에서 Row Source가 생산한 누적 Row 수
LAST_CR_BUFFER_GETS
→ 마지막 실행의 Consistent Buffer Get
DBMS_XPLAN.DISPLAY_CURSOR(...,'ALLSTATS LAST')에서는 일반적으로 다음 Column으로 보입니다.
Starts
E-Rows
A-Rows
Buffers
8.1 단위 맞추기
반복 Row Source에서는 E-Rows와 A-Rows를 그대로 비교하면 단위가 다를 수 있습니다.
Expected Total
≈ E-Rows × Starts
Actual Total
= A-Rows
또는
Expected per Start
= E-Rows
Actual per Start
≈ A-Rows / Starts
예시입니다.
Starts = 100
E-Rows = 5
A-Rows = 300
예상 총 Row = 5×100 = 500
실제 총 Row = 300
예상 Start당 5행
실제 Start당 3행
9. 실행통계 예제
| Id | Operation | Starts | E-Rows | A-Rows | Buffers |
|---:|----------------------------------------|-------:|-------:|-------:|--------:|
| 1 | NESTED LOOPS | 1 | 10 | 300 | 1,240 |
| 2 | TABLE ACCESS FULL DEPARTMENTS | 1 | 2 | 100 | 40 |
| 3 | TABLE ACCESS BY INDEX ROWID BATCHED E | 100 | 5 | 300 | 1,200 |
| 4 | INDEX RANGE SCAN EMP_DEPT_IX | 100 | 5 | 300 | 300 |
해석합니다.
Outer Actual Row = 100
Inner Starts = 100
Inner Total Output = 300
Inner Actual per Start = 3
Optimizer는 Outer를 2행으로 예상했으므로 Inner 반복 횟수도 심하게 과소 평가했을 가능성이 있습니다.
비용 집중 위치입니다.
Index Buffers 300
Table Buffers 1,200
→ Index에서 ROWID를 찾는 비용보다
Table Row 방문 비용이 더 큼
주의합니다.
- 상위 Operation의 Buffers·A-Time은 하위 작업을 포함할 수 있음
- 모든 Line Buffers를 단순 합산하지 않음
- Statement 총량과 비용이 집중된 Branch를 구분함
10. Inner Filter와 Access Predicate
다음 Plan을 가정합니다.
TABLE ACCESS BY INDEX ROWID ORDERS
filter(status='PAID')
INDEX RANGE SCAN ORDERS_CUSTOMER_IX
access(customer_id=:outer_customer_id)
Access Predicate
→ Index 탐색 범위를 정함
Table Filter
→ ROWID로 Table Row를 읽은 뒤 조건 평가
고객별 주문이 1,000건인데 PAID가 1건이면 다음 비용이 반복될 수 있습니다.
Index Entry 1,000건
→ Table Row 1,000건 접근
→ PAID 1건 반환
가능한 대안입니다.
(customer_id,status)복합 Index- 업무 Predicate 재작성
- Outer·Join Order 변경
- Hash Join 대안
- Statistics·Histogram 개선
11. Multi-Table Nested Loops
NESTED LOOPS
NESTED LOOPS
Row Source A
Row Source B
Row Source C
처리 흐름입니다.
A Row 1
→ B Probe
→ A+B 중간 Row 1
→ C Probe
A Row 1
→ B에서 두 번째 Match
→ A+B 중간 Row 2
→ C Probe
앞 단계에서 중간 결과가 커지면 다음 단계 Inner Starts도 증가합니다.
A 100행
× B 평균 Match 20행
= A+B 2,000행
→ C Probe 최대 약 2,000회
따라서 Plan 아래쪽 최초 행 증가·Cardinality 오차를 먼저 찾습니다.
12. Adaptive NL·Hash 후보
Oracle은 Adaptive Plan에서 NL과 Hash 같은 대안 Subplan을 준비할 수 있습니다.
STATISTICS COLLECTOR
→ 왼쪽 Row Source 실제 Row 수 관찰
Threshold 이하
→ NL 후보 사용
Threshold 초과
→ Hash 후보 사용
DBMS_XPLAN의 ADAPTIVE Format과 Plan Note에서 선택되지 않은 Operation이 -로 표시될 수 있습니다.
Adaptive Join은 반복되는 통계 오류를 영구 수정하지 않습니다. 같은 SQL에서 Cardinality 오차가 지속되면 Statistics·Predicate·Bind Skew를 점검합니다.
13. First Row와 Full Fetch 비교
공정한 비교 조건입니다.
Plan A
첫 20행 0.05초
전체 100만행 30초
Plan B
첫 20행 0.5초
전체 100만행 8초
업무 목표에 따라 우수 Plan이 달라집니다.
측정 시 통일합니다.
- 같은 Bind 값과 Data Type
- 같은 Client Fetch Size
- 같은 최종 Fetch Row 수
- 같은 Cache·Statistics·Optimizer 환경
- 첫 행 또는 End-of-Fetch 목표
- 같은 결과 행·정렬 의미
ORDER BY가 없으면 NL Plan의 생산 순서가 결과 순서를 보장하지 않습니다.
14. 실제 검증 절차
SELECT /*+ GATHER_PLAN_STATISTICS */
...
FROM ...;
SELECT *
FROM TABLE(
DBMS_XPLAN.DISPLAY_CURSOR(
:sql_id,
:child_no,
'ALLSTATS LAST +PREDICATE +ALIAS +NOTE'
)
);
확인 순서입니다.
1. SQL_ID·Child Number·Bind를 고정한다.
2. NL 아래 논리적 Outer와 Inner Access 묶음을 식별한다.
3. Outer E-Rows·A-Rows를 비교한다.
4. Inner Starts와 Outer A-Rows의 관계를 본다.
5. Inner A-Rows/Starts로 평균 Match 수를 계산한다.
6. Index Buffers와 Table Buffers를 구분한다.
7. Access Predicate와 Table Filter를 구분한다.
8. BATCHED·Covering 여부를 확인한다.
9. First Row·Full Fetch를 동일 계약으로 측정한다.
10. Statistics 개선·Index 개선·Hash Join 대안을 검증한다.
자주 혼동하는 판단
| 혼동 | 정확한 기준 |
|---|---|
| 작은 원본 Table이 항상 Outer | Filter 후 Row Source와 Inner 비용이 기준 |
| 두 번째 자식 하나가 항상 Inner 전체 | 최신 Plan에서는 Index ROWID 생성과 Table Access가 분리될 수 있음 |
| BATCHED면 Random Access가 사라짐 | ROWID를 Batch로 묶어 Block 방문을 개선하는 방식 |
| Inner Index가 있으면 NL은 항상 빠름 | Starts·Leaf Range·ROWID·Match 수를 함께 확인 |
| Inner Starts는 항상 Outer A-Rows와 정확히 같음 | 기본 관계지만 Filter·Batching·Caching·Plan 구조를 확인 |
| E-Rows와 A-Rows는 그대로 비교 | 반복 Row Source는 Total 또는 Per-Start 단위를 맞춤 |
| 첫 행이 빠르면 전체도 빠름 | First Row와 Full Fetch 계약을 분리 |
| NL Plan이면 결과 순서가 보장됨 | 결과 순서는 ORDER BY만 보장 |
| BATCHED Table Buffers가 크면 Index만 Rebuild | Outer 규모·Filter·CF·Index Column·Join Method를 종합 |
| Adaptive Plan이면 Statistics 문제 해결 | Runtime 선택일 뿐 반복 오류의 근본 원인은 별도 개선 |
개념 확인 문제
문제를 누르면 바로 아래에서 정답과 해설을 확인할 수 있습니다.
01NL Join의 Outer와 Inner Row Source 역할을 설명하시오.
Outer·Inner
- Outer는 먼저 행을 생산해 조인을 이끄는 Row Source입니다.
- Inner는 Outer Row의 Join Key로 반복 탐색되는 Row Source입니다.
02NL Join 총비용을 Outer Row 수와 Inner 탐색 비용으로 표현하시오.
개념적 총비용
Outer 생성 비용 + Outer 실제 Row 수×Inner 1회 탐색 비용으로 이해합니다.- Key당 Match 수가 크면 최종 Join Row와 Inner Table Access도 함께 증가합니다.
03최신 Oracle NL Plan에서 두 개의 NESTED LOOPS가 나타날 수 있는 이유를 설명하시오.
두 개의 NESTED LOOPS
- 최신 Plan에서는 첫 NL이 Outer Row와 Inner Index Entry·ROWID를 결합할 수 있습니다.
- 두 번째 NL은 생성된 ROWID로 Inner Table Row를 읽습니다.
- Index Scan과 Table Access를 논리적 Inner Access 묶음으로 해석합니다.
04TABLE ACCESS BY INDEX ROWID BATCHED의 목적과 한계를 설명하시오.
BATCHED
- Index에서 여러 ROWID를 모은 뒤 Table Block 순서에 가깝게 방문해 같은 Block 반복 접근을 줄입니다.
- ROWID Random Access 자체를 제거하는 것은 아니며 후보 ROWID가 많거나 Table Filter 탈락이 크면 여전히 비쌉니다.
05Covering Index가 Inner 1회 비용을 줄이는 이유를 설명하시오.
Covering Index
- Query에 필요한 Inner Column이 모두 Index에 있으면 ROWID Table Access를 생략할 수 있습니다.
- Inner 1회 비용이 Index Probe만으로 줄어듭니다.
- Index 폭·DML 비용은 함께 평가합니다.
06Starts=2,000, E-Rows=1, A-Rows=80,000인 Inner의 예상 총 Row와 실제 Start당 Row를 계산하시오.
Starts 계산
- 예상 총 Row는
2,000×1=2,000행입니다. - 실제 Start당 Row는
80,000/2,000=40행입니다. - 실제 반복 결과는 예상보다 Start당 40배 큽니다.
07Access Predicate와 Table Filter가 Inner 반복 비용에 미치는 차이를 설명하시오.
Access·Filter
- Access Predicate는 Index Range를 줄여 처음부터 읽을 Entry를 제한합니다.
- Table Filter는 ROWID로 Table Row를 읽은 뒤 탈락시키므로 반복 Table Access 비용이 이미 발생합니다.
- 반복 Filter 탈락이 크면 복합 Index나 다른 Join 전략을 검토합니다.
08Multi-Table NL에서 앞 단계 중간 결과 증가가 뒤 단계에 미치는 영향을 설명하시오.
다단계 영향
- 앞 NL 결과가 다음 NL의 Outer가 됩니다.
- 앞 단계 A-Rows가 증가하면 다음 Inner Starts도 증가합니다.
- 초기 Cardinality 오차와 1:N Match 증가가 뒤 단계로 곱셈 전파될 수 있습니다.
09Adaptive Plan에서 Statistics Collector가 NL·Hash 선택에 미치는 역할을 설명하시오.
Adaptive Join
- Statistics Collector가 실행 초기의 왼쪽 Row 수를 관찰합니다.
- Optimizer Threshold 이하이면 NL, 초과하면 Hash 후보를 선택할 수 있습니다.
- 이는 Runtime 선택이며 Object Statistics 오류를 영구 수정하지 않습니다.
10NL Plan을 First Row와 Full Fetch 관점에서 공정하게 검증하는 절차를 설명하시오.
공정 검증 - 같은 SQL 결과·Bind·Data Type·Fetch Size를 사용합니다. - 첫 n행 또는 End-of-Fetch 중 목표를 고정합니다. - ALLSTATS LAST에서 Outer E/A, Inner Starts·A-Rows·Buffers를 확인합니다. - Index·Table Access와 BATCHED·Covering 여부를 구분합니다. - 대안 Plan을 동일 Fetch 계약에서 반복 측정합니다.