포스트

FILTER PUSH DOWN 을 위해 ROWNUM, RANK 제거

인라인 뷰 안의 RANK/ROWNUM 같은 결과집합 의존 함수가 있으면 옵티마이저는 외부 술어를 뷰 안으로 푸시 다운 하지 않습니다. 불필요한 RANK 를 제거하니 EMP_JOB_IX 인덱스 RANGE SCAN 으로 처리되어 풀스캔과 WINDOW SORT 가 사라집니다.

FILTER PUSH DOWN 을 위해 ROWNUM, RANK 제거

인라인 뷰 안에 RANK/ROWNUM 같은 결과집합 의존 함수가 있으면, 옵티마이저는 외부 술어를 뷰 안으로 푸시 다운 하지 않습니다.

핵심 정리

  1. SELECT ... FROM (SELECT ... FROM employee) a, department b WHERE a.department_id = b.department_id AND a.job_id = 'MK_REP' 같은 쿼리에서 옵티마이저는 a.job_id 술어를 뷰 안으로 밀어 넣을 수 있습니다 (FILTER PUSH DOWN).
  2. View Merging 을 방지해야 하는 케이스가 있습니다 (NO_MERGE 힌트). 조인이 풀려 머지되면 뷰 안으로 술어를 푸시할 기회 자체가 사라지기도 하기 때문에, 의도적으로 뷰 경계를 유지해야 할 때 사용합니다.
  3. 뷰 내부에 불필요한 ROWNUM/RANK 가 있으면 반드시 제거합니다.
  4. ROWNUM, RANK 는 결과집합에 의존적이므로 옵티마이저가 손대지 않습니다 — 행을 미리 걸러 버리면 결과집합이 달라질 수 있기 때문입니다. FILTER PUSH DOWN 도 시도하지 않습니다.
  5. FILTER PUSH DOWN 효과를 보기 위해 RANK 를 제거한 쿼리는 원본 쿼리와 결과가 다를 수 있습니다. RANK 가 응용에서 실제로 필요했다면 함부로 제거하면 안 됩니다.

5번이 가장 중요합니다. RANK 를 제거할 수 있는지는 “결과집합에 RANK 결과가 정말 필요한가” 를 먼저 점검해야 합니다. 본 글의 시나리오는 RANK 결과가 사용되지 않는 경우입니다.


시나리오

같은 두 테이블, 같은 조건, 같은 조인. 단 하나만 다릅니다 — 인라인 뷰 안에 RANK 함수가 있느냐.

  • 테이블/인덱스: employee (EMP_JOB_IX (job_id)), department (DEPT_ID_PK (department_id))
  • 외부 술어: a.job_id = 'MK_REP'
  • 인라인 뷰는 NO_MERGE 힌트로 머지를 막아 둠 (시연 목적 — 푸시 다운 동작을 명확히 보기 위함)

1) 원본 — RANK 함수가 뷰 안에 존재

뷰 안에 RANK () OVER (ORDER BY salary) 가 들어가 있어 옵티마이저는 외부 술어 a.job_id = 'MK_REP' 를 뷰 안으로 밀어 넣지 못합니다. RANK 의 결과는 입력 행 집합 전체에 의존하므로, 행을 미리 걸러내면 RANK 값 자체가 (걸러진 부분집합 안에서의 순위로) 달라지기 때문입니다. 결과적으로 EMPLOYEE 를 풀스캔 하고 모든 행에 대해 RANK 를 계산한 뒤, 뷰 외부에서 JOB_ID 로 필터링합니다.

1
2
3
4
5
6
7
8
9
SELECT a.employee_id, a.first_name, a.last_name, a.email,
       b.department_name, a.salary_rank
  FROM (SELECT /*+ NO_MERGE */
               employee_id, first_name, last_name, job_id, email, department_id,
               RANK () OVER (ORDER BY salary) salary_rank
          FROM employee) a,
       department b
 WHERE a.department_id = b.department_id
   AND a.job_id = 'MK_REP';
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
---------------------------------------------------------------------------
| Id | Operation                      | Name        | Rows | Bytes | Cost |
---------------------------------------------------------------------------
|  0 | SELECT STATEMENT               |             |  106 | 11236 |    7 |
|  1 |  MERGE JOIN                    |             |  106 | 11236 |    7 |
|  2 |   TABLE ACCESS BY INDEX ROWID  | DEPARTMENT  |   27 |   540 |    2 |
|  3 |    INDEX FULL SCAN             | DEPT_ID_PK  |   27 |       |    1 |
|* 4 |   SORT JOIN                    |             |  107 |  9202 |    5 |
|* 5 |    VIEW                        |             |  107 |  9202 |    4 |
|  6 |     WINDOW SORT                |             |  107 |  5565 |    4 |
|  7 |      TABLE ACCESS FULL         | EMPLOYEE    |  107 |  5564 |    3 |
---------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------
4 - access("A"."DEPARTMENT_ID"="B"."DEPARTMENT_ID")
    filter("A"."DEPARTMENT_ID"="B"."DEPARTMENT_ID")
5 - filter("A"."JOB_ID"='MK_REP')

Id 7TABLE ACCESS FULL EMPLOYEEId 6WINDOW SORT모든 행에 대해 RANK 를 계산하기 위한 비용입니다. JOB_ID = ‘MK_REP’ 인 한 줌의 행만 필요한데, 옵티마이저는 그 술어를 뷰 안으로 못 밀어 넣어 Id 5 의 VIEW 단에서 뒤늦게 필터링합니다.


2) 수정 — RANK 제거

뷰에서 RANK 컬럼이 실제로 사용되지 않거나 다른 방법으로 대체 가능하다면 RANK 를 제거합니다. 결과집합 의존 함수가 사라졌으므로 옵티마이저는 외부 술어 a.job_id = 'MK_REP' 를 뷰 안으로 자유롭게 푸시할 수 있고, EMP_JOB_IX 인덱스 RANGE SCAN 으로 한 줌의 행만 즉시 추려냅니다.

1
2
3
4
5
6
7
SELECT a.employee_id, a.first_name, a.last_name, a.email, b.department_name
  FROM (SELECT /*+ NO_MERGE */
               employee_id, first_name, last_name, job_id, email, department_id
          FROM employee) a,
       department b
 WHERE a.department_id = b.department_id
   AND a.job_id = 'MK_REP';
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
----------------------------------------------------------------------------
| Id | Operation                       | Name        | Rows | Bytes | Cost |
----------------------------------------------------------------------------
|  0 | SELECT STATEMENT                |             |    1 |    88 |    2 |
|  1 |  NESTED LOOPS                   |             |      |       |      |
|  2 |   NESTED LOOPS                  |             |    1 |    88 |    2 |
|  3 |    VIEW                         |             |    1 |    68 |    2 |
|* 4 |     INDEX RANGE SCAN            | EMP_JOB_IX  |    1 |       |    1 |
|* 5 |    INDEX UNIQUE SCAN            | DEPT_ID_PK  |    1 |       |    0 |
|  6 |   TABLE ACCESS BY INDEX ROWID   | DEPARTMENT  |    1 |    20 |    1 |
----------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------
4 - access("JOB_ID"='MK_REP')
5 - access("A"."DEPARTMENT_ID"="B"."DEPARTMENT_ID")

Id 4INDEX RANGE SCAN EMP_JOB_IX 만으로 EMPLOYEE 의 필요 행이 추려졌습니다 — 술어가 뷰 안으로 밀려 들어와 인덱스가 활용된 결과입니다. 풀스캔과 WINDOW SORT 가 통째로 사라졌습니다.


분석

왜 RANK/ROWNUM 이 FILTER PUSH DOWN 을 막는가

옵티마이저의 술어 푸시 다운(predicate pushdown) 은 결과 정합성이 보장될 때만 안전합니다. RANK () OVER (ORDER BY salary) 의 결과는 입력 행 집합 전체 를 본 뒤 결정됩니다 — 옵티마이저가 외부의 JOB_ID = 'MK_REP' 를 뷰 안으로 밀어 넣어 입력 행을 미리 걸러 버리면, RANK 값이 (걸러진 부분집합 안에서의 순위로) 달라집니다.

ROWNUM 도 같은 종류 — 결과 행이 만들어지는 순서·개수에 의존하므로 술어를 위로 빼거나 아래로 밀어 넣으면 의미가 변합니다.

옵티마이저는 “결과를 바꿀 가능성이 있는 변환” 은 시도하지 않습니다. 그래서 RANK / ROWNUM 이 뷰 안에 있는 한 술어 푸시 다운은 봉인 됩니다.

  • 원본 (RANK 존재): EMPLOYEE FULL SCANWINDOW SORT (전 행 RANK 계산) → VIEW 외부에서 JOB_ID 필터
  • 수정 (RANK 제거): 술어가 뷰 내부로 푸시 → EMP_JOB_IX RANGE SCAN → 추려진 결과만 DEPARTMENT 와 NESTED LOOPS

View Merging 과 NO_MERGE 의 관계

본 시나리오는 의도적으로 NO_MERGE 힌트를 넣어 인라인 뷰가 머지되지 않게 한 상태입니다. 머지가 일어나면 인라인 뷰가 사라지고 단일 SELECT 로 평탄화되므로 “푸시 다운” 자체가 의미를 잃습니다. 그래서 두 변환은 순서가 있습니다 — 옵티마이저는 보통 머지를 먼저 시도하고, 머지가 불가능하거나 불리할 때 푸시 다운을 검토합니다.

뷰 안에 RANK/ROWNUM 이 있으면 머지도 보통 막힙니다 (윈도우 함수는 머지하면서 의미가 바뀌기 때문). 그러면 푸시 다운까지 막혀 이중으로 변환 봉인 상태가 됩니다.

RANK/ROWNUM 은 단순한 분석 함수가 아니라, 옵티마이저에게 “이 뷰는 입력 의존이니 건드리지 마” 라는 신호입니다. 비즈니스에 꼭 필요한 게 아니면 빼는 것이 성능 최적화의 첫 걸음입니다.

결과 동등성 — 빼도 되는지 먼저 확인

수정 쿼리는 SELECT 절에서 a.salary_rank 컬럼이 함께 빠진 점에 주목해야 합니다. 원본과 수정은 결과집합이 다릅니다 — 컬럼 수가 다르고, RANK 가 응용에서 실제로 쓰였다면 수정 쿼리는 잘못된 답입니다.

  • 응용에서 RANK 결과를 사용하지 않는데 잘못 들어가 있던 경우: 안전하게 제거 OK
  • RANK 결과를 꼭 써야 하는 경우: 인덱스 활용을 위해 RANK 를 빼는 건 옵션이 아닙니다. 다른 접근(예: JOB_ID = ‘MK_REP’ 인 부분집합 안에서만 RANK 를 계산하도록 쿼리 구조 변경) 을 검토해야 합니다.

비교 요약

관점원본 (RANK 포함)수정 (RANK 제거)
EMPLOYEE 접근TABLE ACCESS FULLINDEX RANGE SCAN EMP_JOB_IX
WINDOW SORT발생 (107 행 전부)없음
술어 적용 위치뷰 외부 (Id 5 filter)뷰 내부 (Id 4 access)
조인 방식MERGE JOINNESTED LOOPS
추정 비용72
결과집합salary_rank 컬럼 포함salary_rank 컬럼 없음

정리

세 줄로 압축하면:

  1. 인라인 뷰 안의 RANK/ROWNUM 은 결과집합 의존 함수입니다. 옵티마이저는 결과를 바꿀 가능성이 있는 변환(머지·푸시 다운) 을 시도하지 않습니다.
  2. 응용에서 쓰이지 않는 RANK/ROWNUM 은 즉시 제거하세요. 한 줄 제거로 풀스캔과 WINDOW SORT 가 통째로 사라지는 케이스가 흔합니다.
  3. 단, 결과 동등성을 먼저 확인하세요. RANK 결과가 비즈니스에 필요하면 빼는 것은 답이 아닙니다 — 쿼리 구조 자체를 다시 짜야 합니다.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.