포스트

Join Predicate Push Down(JPPD)

Join Predicate Push Down(JPPD)

JPPD (Join Predicate Push-Down) 는 옵티마이저가 선행 테이블의 join predicate 를 후행 VIEW 안쪽으로 밀어 넣어, VIEW 가 결과집합을 만드는 시점에 미리 좁히는 변환입니다. 본 글은 자동 JPPD 와 수동 LATERAL 재작성을 같은 쿼리에서 비교하며, 결과적으로 VIEW 가 LATERAL view 처럼 동작하는 것을 보고자 하는 목적이 있습니다.

핵심 정리

  1. 목적: VIEW 안에서 join condition 을 적용할 수 있다면, VIEW 가 결과집합을 만들기 전에 조건을 적용하여 불필요한 데이터를 미리 걸러냅니다.
  2. 효과: VIEW 의 결과집합 크기가 줄어들어, 이후 join 연산 비용이 낮아집니다.
  3. 결과의 본질: JPPD 가 적용된 VIEW 는 선행 테이블의 row 마다 후행 VIEW 가 동적으로 evaluation 되며, LATERAL VIEW 처럼 동작 합니다.

6가지 JPPD 가능 패턴

패턴한 줄 함의
UNION ALL VIEW & UNION VIEW각 분기 SELECT 가 독립이라 predicate 를 안으로 push down 해도 결과 동일. 본 글의 시나리오
OUTER JOIN VIEWNULL 보존 의미 유지하며 inner 쪽 predicate 를 안으로 push down
랭킹 분석 함수 사용 VIEWpartition-by 컬럼이 push down predicate 와 같으면 가능
GROUP BY, DISTINCT 사용 VIEWgrouping key 가 push down predicate 컬럼을 포함해야 결과값 유지 — 가장 까다로움
NL SEMI/ANTI JOIN VIEWEXISTS / NOT EXISTS unnesting 후 outer 쪽 predicate push down
MULTI LEVEL VIEW중첩 inline view — 한 단계씩 재귀적으로 push down

JPPD 는 view 자체가 outer 쿼리에 흡수되어 사라져버리기 때문에 VIEW MERGING 이 먼저 일어나면 발생하지 않습니다. JPPD 를 강제로 일으키려면 NO_MERGE 힌트로 view merging 을 차단해야 합니다.


시나리오

departmentemployee 두 테이블을 두 가지 job_id 조건 (예: 'AD_ASST''AD_PRES') 으로 UNION 한 후행 VIEW 와 NESTED LOOPS JOIN 합니다.

인덱스는 다음과 같습니다.

테이블인덱스컬럼
departmentDEPT_LOCATION_IXlocation_id
employeeEMP_JOB_DEPTjob_id, department_id (결합)
employeeEMP_JOB_IXjob_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_idUNION 안쪽 두 분기에 자동으로 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 5SORT UNIQUEUNION (중복 제거) 때문에 발생하는 비용 — 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_DEPT RANGE 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 ALLSORT UNIQUE 의 정체

Id 5SORT UNIQUEUNION (중복 제거) 의 cost 입니다. 두 분기의 job_id 가 항상 다르다면 (예: 'AD_ASST''AD_PRES') 결과 row 도 중복되지 않으므로 UNION ALL 로 바꾸어도 의미상 동일. 이 경우 SORT UNIQUE 가 사라져 cost 가 절감됩니다.

VIEW MERGING 이 먼저 일어나면 JPPD 도 사라진다

옵티마이저의 view 처리는 두 단계로 진행됩니다.

  1. VIEW MERGING — view 자체를 outer 쿼리에 inline 으로 흡수 (view 가 사라짐)
  2. 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 가 함께 필요함.


정리

세 줄로 압축하면:

  1. JPPD = 자동 LATERAL 변환 — 옵티마이저가 join predicate 를 후행 view 안쪽으로 밀어 넣어 view 를 매 outer row 별로 좁혀진 형태로 evaluation 함.
  2. 6가지 view 패턴 (UNION ALL/UNION, OUTER JOIN, 분석함수, GROUP BY/DISTINCT, NL SEMI/ANTI, MULTI LEVEL) 일 때만 JPPD 가능. 사용자가 직접 같은 효과를 내고 싶으면 LATERAL VIEW 로 명시적 재작성할 것.
  3. VIEW MERGING 이 먼저 일어나면 JPPD 도 사라짐 — 단순 view 에서 JPPD 를 강제로 일으키려면 NO_MERGE 힌트 필수.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.