Join Predicate Push Down(JPPD)
JPPD (Join Predicate Push-Down) 는 옵티마이저가 선행 테이블의 join predicate 를 후행 VIEW 안쪽으로 밀어 넣어, VIEW 가 결과집합을 만드는 시점에 미리 좁히는 변환입니다. 본 글은 자동 JPPD 와 수동 LATERAL 재작성을 같은 쿼리에서 비교하며, 결과적으로 VIEW 가 LATERAL view 처럼 동작하는 것을 보고자 하는 목적이 있습니다.
핵심 정리
- 목적: VIEW 안에서 join condition 을 적용할 수 있다면, VIEW 가 결과집합을 만들기 전에 조건을 적용하여 불필요한 데이터를 미리 걸러냅니다.
- 효과: VIEW 의 결과집합 크기가 줄어들어, 이후 join 연산 비용이 낮아집니다.
- 결과의 본질: JPPD 가 적용된 VIEW 는 선행 테이블의 row 마다 후행 VIEW 가 동적으로 evaluation 되며, LATERAL VIEW 처럼 동작 합니다.
6가지 JPPD 가능 패턴
| 패턴 | 한 줄 함의 |
|---|---|
| UNION ALL VIEW & UNION VIEW | 각 분기 SELECT 가 독립이라 predicate 를 안으로 push down 해도 결과 동일. 본 글의 시나리오 |
| OUTER JOIN VIEW | NULL 보존 의미 유지하며 inner 쪽 predicate 를 안으로 push down |
| 랭킹 분석 함수 사용 VIEW | partition-by 컬럼이 push down predicate 와 같으면 가능 |
| GROUP BY, DISTINCT 사용 VIEW | grouping key 가 push down predicate 컬럼을 포함해야 결과값 유지 — 가장 까다로움 |
| NL SEMI/ANTI JOIN VIEW | EXISTS / NOT EXISTS unnesting 후 outer 쪽 predicate push down |
| MULTI LEVEL VIEW | 중첩 inline view — 한 단계씩 재귀적으로 push down |
JPPD 는 view 자체가 outer 쿼리에 흡수되어 사라져버리기 때문에 VIEW MERGING 이 먼저 일어나면 발생하지 않습니다. JPPD 를 강제로 일으키려면
NO_MERGE힌트로 view merging 을 차단해야 합니다.
시나리오
department 와 employee 두 테이블을 두 가지 job_id 조건 (예: 'AD_ASST' 와 'AD_PRES') 으로 UNION 한 후행 VIEW 와 NESTED LOOPS JOIN 합니다.
인덱스는 다음과 같습니다.
| 테이블 | 인덱스 | 컬럼 |
|---|---|---|
department | DEPT_LOCATION_IX | location_id |
employee | EMP_JOB_DEPT | job_id, department_id (결합) |
employee | EMP_JOB_IX | job_id (단일) |
employee 에 결합 인덱스 (job_id, department_id) 가 있다는 점이 본 시나리오의 포인트입니다. JPPD 가 발생하면서 department_id 가 join predicate 로 push down 되면 이 결합 인덱스가 두 컬럼 모두를 access predicate 로 사용 하게 됩니다.
1) 원본 — 옵티마이저가 자동으로 JPPD 적용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
SELECT /*+ QB_NAME(OUTER) LEADING(d) USE_NL(e) */
d.department_id, d.department_name, e.employee_id, e.job_id, e.email_phone_num
FROM department d,
(SELECT /*+ QB_NAME(INNER1) */
employee_id, department_id, job_id, email AS email_phone_num
FROM employee
WHERE job_id = :v_job1 -- 'AD_ASST' 대입
UNION
SELECT /*+ QB_NAME(INNER2) */
employee_id, department_id, job_id, phone_number AS email_phone_num
FROM employee
WHERE job_id = :v_job2 ) e -- 'AD_PRES' 대입
WHERE d.department_id = e.department_id
AND d.location_id = 1700;
옵티마이저는 d.department_id = e.department_id 를 UNION 안쪽 두 분기에 자동으로 push down 합니다. view 안쪽에 department_id 조건을 안 적었어도 옵티마이저가 알아서 추가하는 셈입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-----------------------------------------------------------------------------------
| Id | Operation | Name | Rows | Bytes | Cost |
-----------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | | | 128 |
| 1 | NESTED LOOPS | | 9 | 558 | 128 |
| 2 | TABLE ACCESS BY INDEX ROWID | DEPARTMENT | 21 | 420 | 2 |
|* 3 | INDEX RANGE SCAN | DEPT_LOCATION_IX | 21 | | 1 |
| 4 | VIEW | | 1 | 42 | 6 |
| 5 | SORT UNIQUE | | 2 | 52 | 6 |
| 6 | UNION ALL PUSHED PREDICATE | | | | | ← JPPD 가 발생하였고, PUSHED PREDICATE 혹은 PREDICATE PUSHED 등의 힌트가 보인다.
| 7 | TABLE ACCESS BY INDEX ROWID | EMPLOYEE | 1 | 24 | 2 |
|* 8 | INDEX RANGE SCAN | EMP_JOB_DEPT | 1 | | 1 | ← Join 조건이 파고들면서 (job_id, department_id) 인덱스가 사용되었다.
| 9 | TABLE ACCESS BY INDEX ROWID | EMPLOYEE | 1 | 28 | 2 |
|*10 | INDEX RANGE SCAN | EMP_JOB_DEPT | 1 | | 1 | ← Join 조건이 파고들면서 (job_id, department_id) 인덱스가 사용되었다.
-----------------------------------------------------------------------------------
Predicate Information:
----------------------
3 - access("D"."LOCATION_ID"=1700)
8 - access("JOB_ID"=:V_JOB1 AND "DEPARTMENT_ID"="D"."DEPARTMENT_ID") ← Join 조건이 파고들면서 (job_id, department_id) 인덱스가 사용되었다.
10 - access("JOB_ID"=:V_JOB2 AND "DEPARTMENT_ID"="D"."DEPARTMENT_ID") ← Join 조건이 파고들면서 (job_id, department_id) 인덱스가 사용되었다.
Id 5 의 SORT UNIQUE 는 UNION (중복 제거) 때문에 발생하는 비용 — UNION ALL 로 바꿀 수 있다면 사라집니다.
2) 수정 — LATERAL VIEW 로 명시적 재작성
같은 효과를 사용자가 직접 LATERAL VIEW 로 표현해 봅니다. 핵심은 inline view 안쪽 두 분기에 d.department_id 를 명시적으로 결합 조건으로 적는 것입니다. 즉, 옵티마이저가 자동으로 하던 push down 을 사용자가 손으로 한 셈입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
SELECT /*+ QB_NAME(OUTER) LEADING(d) USE_NL(e) */
d.department_id, d.department_name, e.employee_id, e.job_id, e.email_phone_num
FROM department d,
LATERAL (SELECT /*+ QB_NAME(INNER1) */
employee_id, department_id, job_id, email AS email_phone_num
FROM employee e1
WHERE job_id = :v_job1
AND e1.department_id = d.department_id -- 명시적 join condition
UNION
SELECT /*+ QB_NAME(INNER2) */
employee_id, department_id, job_id, phone_number AS email_phone_num
FROM employee e2
WHERE job_id = :v_job2
AND e2.department_id = d.department_id -- 명시적 join condition
) e
WHERE d.location_id = 1700;
LATERAL 키워드 (Oracle 12c+) 는 inline view 안쪽에서 외부 테이블의 컬럼 (d.department_id) 을 참조할 수 있게 합니다. 이는 옵티마이저가 JPPD 로 자동 수행하던 변환을 사용자가 명시적으로 표현한 것입니다. 결과적으로 옵티마이저는 같은 access path (NL + 결합 인덱스 RANGE SCAN) 를 선택합니다.
일반적으로 LATERAL 재작성본은 원본과 같은 NL +
EMP_JOB_DEPTRANGE SCAN access path 로 풀려 Cost 가 거의 동일합니다. 하지만, 본 쿼리를 표현한 목적은 성능 향상이 목적이라기보다 JPPD 의 본질을 파악하기 위해 동일한 의미의 다른 쿼리로 표현해 보았습니다.
분석
JPPD = 자동 LATERAL 변환
원본 쿼리의 inline view 는 표면적으로 WHERE job_id = :v_job1 (또는 :v_job2) 만 가집니다. 옵티마이저는 NL 외곽의 d.department_id = e.department_id 를 view 안쪽 분기로 자동 이동 시킵니다. 결과적으로 view 는 매 outer row 마다 WHERE job_id = :v AND department_id = :outer_dept 로 좁혀진 형태로 evaluation 됩니다. 이것이 LATERAL 의 정의입니다. JPPD 와 LATERAL 은 같은 동작의 암묵적 표현 vs 명시적 표현 이라고 볼 수 있습니다.
왜 결합 인덱스 EMP_JOB_DEPT 가 사용되는가
JPPD 가 적용되기 전에는 view 안쪽 access predicate 가 JOB_ID=:V_JOB 단일이라 단일 인덱스 EMP_JOB_IX 로 충분합니다. JPPD 가 DEPARTMENT_ID 를 push down 하는 순간 access predicate 가 JOB_ID=:V_JOB AND DEPARTMENT_ID=:outer_dept 두 컬럼이 되어, 결합 인덱스 EMP_JOB_DEPT 의 selectivity 가 더 좋아집니다. 옵티마이저는 cost 비교를 통해 결합 인덱스를 선택. JPPD 는 인덱스 선택 자체에 영향을 줍니다.
UNION vs UNION ALL — SORT UNIQUE 의 정체
Id 5 의 SORT UNIQUE 는 UNION (중복 제거) 의 cost 입니다. 두 분기의 job_id 가 항상 다르다면 (예: 'AD_ASST' ≠ 'AD_PRES') 결과 row 도 중복되지 않으므로 UNION ALL 로 바꾸어도 의미상 동일. 이 경우 SORT UNIQUE 가 사라져 cost 가 절감됩니다.
VIEW MERGING 이 먼저 일어나면 JPPD 도 사라진다
옵티마이저의 view 처리는 두 단계로 진행됩니다.
- VIEW MERGING — view 자체를 outer 쿼리에 inline 으로 흡수 (view 가 사라짐)
- JPPD — view 가 살아남았을 때 join predicate 를 view 안쪽으로 push down
UNION / UNION ALL / GROUP BY / DISTINCT 가 있는 view 는 일반적으로 view merging 대상이 아니므로 JPPD 가 자동으로 가능합니다. 하지만 단순 view 는 merging 이 먼저 일어나 JPPD 가 무의미해집니다. JPPD 를 강제로 일으키려면 NO_MERGE 힌트로 merging 을 차단 해야 합니다.
JPPD 와 LATERAL 은 같은 동작의 두 가지 표현입니다. 옵티마이저가 자동으로 못 풀 때는 사용자가 LATERAL 로 직접 표현하면 같은 결과를 얻을 수 있습니다.
단, view merging 이 먼저 일어나는 단순 view 는NO_MERGE가 함께 필요함.
정리
세 줄로 압축하면:
- JPPD = 자동 LATERAL 변환 — 옵티마이저가 join predicate 를 후행 view 안쪽으로 밀어 넣어 view 를 매 outer row 별로 좁혀진 형태로 evaluation 함.
- 6가지 view 패턴 (UNION ALL/UNION, OUTER JOIN, 분석함수, GROUP BY/DISTINCT, NL SEMI/ANTI, MULTI LEVEL) 일 때만 JPPD 가능. 사용자가 직접 같은 효과를 내고 싶으면
LATERAL VIEW로 명시적 재작성할 것. - VIEW MERGING 이 먼저 일어나면 JPPD 도 사라짐 — 단순 view 에서 JPPD 를 강제로 일으키려면
NO_MERGE힌트 필수.