개요: 인라인 뷰(Inline View)가 풀리지 않을 때 벌어지는 문제

실무 SQL을 작성하다 보면 UNION ALL, GROUP BY, DISTINCT가 포함된 복잡한 뷰(View)나 인라인 뷰(Inline View)를 메인 테이블과 조인해야 하는 상황이 빈번하게 발생합니다.

 

일반적인 뷰라면 오라클 옵티마이저(CBO)의 쿼리 변환기(Query Transformer)가 뷰 머징(View Merging)을 수행하여 메인 쿼리와 하나로 합친 뒤 최적의 실행 계획을 수립합니다. 그러나 뷰 내부에 집계 연산이나 집합 연산자가 포함되어 뷰 머징이 불가능하거나 비용상 불리할 때, 뷰 내부 테이블을 처음부터 끝까지 다 읽고(Full Scan) 중간 집합을 만든 뒤 조인한다면 응답 속도는 기하급수적으로 느려집니다.

 

이때 오라클 옵티마이저가 선택하는 핵심 돌파구가 바로 JPPD (Join Predicate Pushdown, 조인 조건 푸시다운)입니다. 메인 쿼리에 있는 조인 조건(Join Predicate)을 뷰의 울타리 안쪽으로 강제로 밀어 넣어, 뷰 내부 테이블의 인덱스를 활용하고 부분 범위 처리를 가능하게 만드는 JPPD의 내부 메커니즘과 실무 튜닝 기법을 상세히 파헤쳐 봅니다.

 


내부 메커니즘: 옵티마이저는 어떻게 조인 조건을 뷰 안으로 밀어 넣는가?

오라클의 쿼리 변환 엔진(CBQT, Cost-Based Query Transformation)이 JPPD를 수행하는 내부 아키텍처와 실행 계획상의 변화를 단계별로 살펴봅니다.

1. View Merging 실패와 비효율적인 조인 (JPPD 미적용 시)

아래와 같이 부서별 급여 합계를 구하는 인라인 뷰와 부서 테이블을 조인하는 쿼리가 있다고 가정합니다.

SELECT D.DEPT_ID, D.DEPT_NAME, V.SUM_SAL
FROM   DEPARTMENTS D
JOIN   (SELECT DEPT_ID, SUM(SALARY) AS SUM_SAL
        FROM   EMPLOYEES
        GROUP BY DEPT_ID) V
  ON   D.DEPT_ID = V.DEPT_ID
WHERE  D.LOC_CODE = 'SEOUL';

만약 DEPARTMENTS에서 'SEOUL'에 위치한 부서가 단 1건뿐이더라도, 뷰 머징과 JPPD가 모두 실패하면 오라클은 다음과 같이 동작합니다.

  1. EMPLOYEES 테이블 전체(예: 1,000만 건)를 Full Scan하여 모든 부서의 급여 합계를 PGA Sort/Hash Area에서 집계(HASH GROUP BY)합니다.
  2. 생성된 거대한 뷰 중간 결과 집합(VIEW)을 DEPARTMENTS의 1건과 조인한 뒤 나머지 부서 데이터를 모두 버립니다.

2. JPPD 작동 원리와 VIEW PUSHED PREDICATE

옵티마이저가 JPPD 변환을 결정하면, 메인 쿼리의 조인 조건인 D.DEPT_ID = V.DEPT_ID를 인라인 뷰 안쪽의 WHERE 절로 침투시켜 상관 서브쿼리(Correlated Subquery)와 유사한 형태로 쿼리를 재작성합니다.

-- 옵티마이저가 내부적으로 재작성(Query Transformation)한 논리적 형태
SELECT D.DEPT_ID, D.DEPT_NAME, V.SUM_SAL
FROM   DEPARTMENTS D,
       LATERAL (SELECT DEPT_ID, SUM(SALARY) AS SUM_SAL
                FROM   EMPLOYEES E
                WHERE  E.DEPT_ID = D.DEPT_ID -- 외부의 조인 조건이 뷰 내부로 침투!
                GROUP BY DEPT_ID) V
WHERE  D.LOC_CODE = 'SEOUL';

이 변환이 완료되면 실행 계획에는 다음과 같은 고유 오퍼레이션이 등장합니다.

------------------------------------------------------------------------------------------
| Id  | Operation                      | Name        | Starts | E-Rows | A-Rows | Buffers |
------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT               |             |      1 |        |      1 |      12 |
|   1 |  NESTED LOOPS                  |             |      1 |      1 |      1 |      12 |
|*  2 |   TABLE ACCESS BY INDEX ROWID  | DEPARTMENTS |      1 |      1 |      1 |       2 |
|*  3 |    INDEX RANGE SCAN            | DEPT_LOC_IX |      1 |      1 |      1 |       1 |
|   4 |   VIEW PUSHED PREDICATE        |             |      1 |      1 |      1 |      10 |
|*  5 |    FILTER                      |             |      1 |        |      1 |      10 |
|   6 |     SORT GROUP BY              |             |      1 |      1 |      1 |      10 |
|   7 |      TABLE ACCESS BY INDEX ROWID| EMPLOYEES  |      1 |     15 |     12 |      10 |
|*  8 |       INDEX RANGE SCAN         | EMP_DEPT_IX |      1 |     15 |     12 |       2 |
------------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
   2 - filter("D"."LOC_CODE"='SEOUL')
   8 - access("E"."DEPT_ID"="D"."DEPT_ID")  <-- 메인 테이블의 컬럼이 인덱스 액세스 조건으로 작동!
  • 핵심 동작 순서:
    1. 선행(Driving) 테이블인 DEPARTMENTS에서 'SEOUL' 조건을 만족하는 부서 1건을 먼저 추출합니다.
    2. 추출된 D.DEPT_ID 값을 바인드 변수처럼 인라인 뷰 내부로 밀어 넣습니다 (VIEW PUSHED PREDICATE).
    3. 뷰 내부의 EMPLOYEES 테이블은 전체 스캔 대신 EMP_DEPT_IX 인덱스를 Range Scan하여 해당 부서의 사원 12건만 Buffer Cache에서 읽고 SORT GROUP BY를 마칩니다.
    4. 항상 Nested Loops 기반으로 동작: JPPD는 선행 테이블의 로우마다 뷰 내부로 값을 건네주어야 하므로, 물리적 조인 방식은 필연적으로 Nested Loops Join (USE_NL)과 결합됩니다.

3. JPPD가 작동 가능한 대표적 뷰 유형과 관련 히든 파라미터

오라클 내부에서 JPPD는 비용 기반(Cost-Based) 및 규칙 기반으로 제어되며, 다음 세 가지 히든 파라미터에 의해 지배받습니다.

  • _push_join_predicate (기본값 TRUE): JPPD 기능 전체를 활성화합니다.
  • _push_join_union_view (기본값 TRUE): UNION ALL 또는 UNION으로 구성된 뷰 내부의 각 브랜치(Branch)로 조인 조건을 밀어 넣습니다.
  • _optimizer_push_pred_cost_based (기본값 TRUE): JPPD 적용 전후의 Cost를 계산하여 유리할 때만 선택합니다.
뷰(View) 구성 형태 JPPD 가능 여부 실행 계획 표시 특징
UNION ALL / UNION 포함 뷰 가능 VIEW 오퍼레이션 하위의 각 UNION-ALL 브랜치마다 인덱스 액세스 발생
GROUP BY / DISTINCT 포함 뷰 가능 VIEW PUSHED PREDICATE 오퍼레이션 명시적 출력
Outer Join (+, LEFT JOIN) 대상 뷰 가능 Outer Join의 후행(Inner) 집합으로 위치한 뷰 내부로 조건 침투
Anti / Semi Join 대상 뷰 가능 NOT EXISTS, EXISTS 내의 복잡한 뷰 내부로 조건 침투

 


 

주요 원인 분석: 실무에서 JPPD 관련 성능 장애가 터지는 3가지 이유

JPPD는 양날의 검입니다. 작동해야 할 때 작동하지 않아서 문제 되기도 하지만, 작동하지 말아야 할 상황에서 작동하여 DB를 멈추게 하는 경우도 많습니다.

1. 문법적 제약 조건으로 인한 JPPD 불가 (Unpushable Views)

개발자가 인라인 뷰 내부에 아래와 같은 오퍼레이션을 포함시키면, 데이터 정합성이 깨지거나 구조적 한계로 인해 옵티마이저는 JPPD를 원천 포기합니다.

  • 뷰 내부에 ROWNUM 슈도 컬럼이 사용된 경우 (실체화 강제)
  • 뷰 내부에 분석 함수(Analytic Function: ROW_NUMBER() OVER(), RANK(), SUM() OVER())가 포함된 경우 (파티션 기준과 조인 키가 일치하지 않을 때)
  • 뷰 내부에 CONNECT BY 계층형 쿼리나 CUBE / ROLLUP이 포함된 경우

2. 뷰 내부에 조인 키 인덱스가 없는 경우

JPPD는 조인 조건을 뷰 안으로 밀어 넣어 인덱스 스캔(Index Scan)을 타게 만드는 것이 유일한 목적입니다. 만약 뷰 내부 테이블에 조인 컬럼 인덱스가 없거나 인덱스 컬럼이 가공(TO_CHAR(DEPT_ID))되어 있다면, 조건을 밀어 넣어 봐야 어차피 내부에서 Full Table Scan을 반복해야 하므로 옵티마이저는 JPPD를 기각합니다.

3. 잘못된 JPPD 작동 (High Cardinality Driving)

가장 치명적인 장애 시나리오입니다. 통계정보 왜곡으로 인해 옵티마이저가 선행 테이블(Driving)의 결과 건수를 1건으로 예측하고 JPPD(VIEW PUSHED PREDICATE)를 선택했으나, 실제 런타임에 선행 테이블에서 50만 건이 추출되는 경우입니다.

  • 뷰 내부의 UNION ALL이나 GROUP BY 쿼리가 50만 번 반복 실행(Starts = 500K)되면서 Buffer Cache의 논리적 I/O(Buffers)가 수천만 블록으로 치솟고 CPU가 100% 고갈됩니다.

트러블슈팅 및 해결 방안 (튜닝 가이드)

실무에서 JPPD가 풀리지 않아 느린 쿼리와, 반대로 JPPD가 잘못 작동해 느린 쿼리를 교정하는 단계별 튜닝 전략입니다.

1. PUSH_PRED 힌트를 통한 강제 JPPD 유도 (소량 Driving 시)

선행 테이블의 결과 건수가 소량이고 뷰 내부 테이블에 조인 키 인덱스가 존재함에도, 옵티마이저가 뷰 전체를 Full Scan + Hash Join으로 처리할 때는 NO_MERGE와 PUSH_PRED 힌트를 결합하여 강제로 조건을 밀어 넣습니다.

-- [튜닝 전/후 비교] UNION ALL 뷰 내부로 조인 조건을 강제 침투시키는 쿼리
SELECT /*+ LEADING(C V) USE_NL(V) NO_MERGE(V) PUSH_PRED(V) */
       C.CUST_ID, C.CUST_NAME, V.ORDER_DT, V.ORD_AMT
FROM   CUSTOMERS C
LEFT JOIN (
       SELECT /*+ QB_NAME(V_SUB) */ CUST_ID, ORDER_DT, ORD_AMT
       FROM   ORDERS_2025
       UNION ALL
       SELECT CUST_ID, ORDER_DT, ORD_AMT
       FROM   ORDERS_2026
) V ON C.CUST_ID = V.CUST_ID
WHERE  C.VIP_GRADE = 'DIAMOND'; -- 극소수(예: 10명) 고객만 조회

힌트 작성 필수 공식:

  1. LEADING(선행 뷰): 반드시 조건을 제공할 테이블이 먼저 읽히도록 순서를 고정합니다.
  2. USE_NL(뷰): JPPD는 Nested Loops 조인에서만 작동하므로 NL 조인을 지시합니다.
  3. NO_MERGE(뷰) & PUSH_PRED(뷰): 뷰 머징을 막은 상태에서 조인 조건 푸시다운을 명령합니다.

2. NO_PUSH_PRED 힌트를 통한 JPPD 해제 (대량 Driving 시)

선행 테이블의 데이터 건수가 수만 건 이상으로 많을 때 잘못된 JPPD(VIEW PUSHED PREDICATE)가 발생해 Starts 횟수가 폭발한다면, 즉시 NO_PUSH_PRED 힌트로 푸시다운을 막고 Hash Join (USE_HASH)으로 유도해야 합니다.

-- 대량 데이터 조회 시 JPPD를 차단하고 단 1회 Full Scan + Hash Join으로 유도
SELECT /*+ LEADING(C V) USE_HASH(V) NO_MERGE(V) NO_PUSH_PRED(V) */
       C.CUST_ID, C.CUST_NAME, V.SUM_AMT
FROM   CUSTOMERS C
JOIN   (
       SELECT CUST_ID, SUM(ORD_AMT) AS SUM_AMT
       FROM   ORDERS_2026
       GROUP BY CUST_ID
) V ON C.CUST_ID = V.CUST_ID
WHERE  C.REG_DATE >= TO_DATE('20200101', 'YYYYMMDD'); -- 대량(예: 30만 명) 조회

3. 문법적 제약(ROWNUM, 분석함수) 우회 리팩토링

인라인 뷰 안에 ROW_NUMBER() OVER(PARTITION BY CUST_ID ORDER BY ORDER_DT DESC)처럼 최신 1건을 뽑는 분석 함수가 들어 있을 때, 만약 PARTITION BY 절에 조인 키(CUST_ID)가 정확히 명시되어 있다면 오라클은 JPPD를 정상적으로 수행할 수 있습니다.

그러나 PARTITION BY가 누락되어 있거나 ROWNUM으로 작성되어 JPPD가 막혀 있다면, 아래와 같이 쿼리를 리팩토링하여 해결합니다.

  • 해결 방안 A: 분석 함수의 PARTITION BY 절에 메인 쿼리와의 조인 키 컬럼을 명시적으로 추가하여 옵티마이저에게 푸시다운 안전성을 보장해 줍니다.
  • 해결 방안 B (Oracle 12c 이상): 인라인 뷰를 CROSS APPLY 또는 OUTER APPLY (Lateral View) 문법으로 변환하고 내부에서 FETCH FIRST 1 ROW ONLY를 사용하면, JPPD 제약 없이 깔끔하게 인덱스 부분 범위 처리가 가능합니다.
-- Oracle 12c 이상: OUTER APPLY를 활용해 JPPD 제약을 완벽히 우회하는 기법
SELECT C.CUST_ID, C.CUST_NAME, V.ORDER_ID, V.ORD_AMT
FROM   CUSTOMERS C
OUTER APPLY (
       SELECT O.ORDER_ID, O.ORD_AMT
       FROM   ORDERS O
       WHERE  O.CUST_ID = C.CUST_ID -- 조인 조건을 뷰 내부에 직접 기술
       ORDER BY O.ORDER_DT DESC
       FETCH FIRST 1 ROW ONLY       -- ROWNUM 제약 없이 최신 1건만 인덱스 스캔!
) V
WHERE  C.VIP_GRADE = 'DIAMOND';

 


요약 

  • JPPD(Join Predicate Pushdown)의 정의: 뷰 머징(View Merging)이 불가능한 GROUP BY, UNION ALL, Outer Join 뷰 내부로 메인 쿼리의 조인 조건을 밀어 넣어 인덱스 액세스를 가능케 하는 쿼리 변환 기법입니다.
  • 실행 계획과 동작 특징: 항상 NESTED LOOPS 조인과 짝을 이루며, 실행 계획의 VIEW 또는 VIEW PUSHED PREDICATE 하단 오퍼레이션에서 메인 테이블의 조인 키가 인덱스 access 조건으로 사용되는지 확인해야 합니다.
  • 실무 튜닝 핵심 기준:
    • 선행(Driving) 집합이 소량이고 뷰 내부 테이블에 조인 키 인덱스가 있을 때 $\rightarrow$ /*+ NO_MERGE(V) USE_NL(V) PUSH_PRED(V) */
    • 선행(Driving) 집합이 대량이어서 반복 실행(Starts) 부하가 극심할 때 $\rightarrow$ /*+ NO_MERGE(V) USE_HASH(V) NO_PUSH_PRED(V) */
 
 

 

개요: 개발자의 JOIN과 옵티마이저의 JOIN은 다르다

관계형 데이터베이스(RDBMS)에서 데이터를 결합하는 JOIN은 크게 두 가지 관점으로 나뉩니다.

  1. 논리적 조인 (Logical Join): SQL 작성자가 비즈니스 요건에 맞춰 데이터를 어떻게 결합할지 정의하는 문법적 명세입니다. (INNER JOIN, LEFT/RIGHT/FULL OUTER JOIN, CROSS JOIN, SEMI/ANTI JOIN)
  2. 물리적 조인 (Physical Join): 오라클 CBO(Cost-Based Optimizer)가 논리적 조인 쿼리를 파싱한 뒤, 실제 디스크와 메모리(SGA/PGA)에서 데이터를 가장 적은 비용(Cost)으로 결합하기 위해 선택하는 내부 실행 알고리즘입니다. (Nested Loops Join, Sort Merge Join, Hash Join)

단순히 LEFT JOIN을 썼다고 해서 항상 같은 방식으로 동작하지 않습니다. 데이터 건수(Cardinality), 인덱스 유무, 메모리 가용량에 따라 옵티마이저는 내부적으로 완전히 다른 물리적 엔진을 가동합니다. 실무 튜닝의 핵심은 논리적 조인의 결과 집합을 정확히 이해하고, 이를 처리하는 물리적 조인 알고리즘의 아키텍처 차이를 통제하는 데 있습니다.

 


1. 논리적 조인(Logical Join) 별 특징과 내부 변환

SQL 레벨에서 작성하는 조인 방식과 옵티마이저가 이를 해석하는 메커니즘입니다.

논리적 조인 유형 결과 집합 특징 옵티마이저 내부 처리 및 주요 특징
INNER JOIN 양쪽 집합의 조인 조건이 모두 일치하는 행만 반환 (교집합) 선행(Driving) 테이블과 후행(Driven) 테이블의 순서를 옵티마이저가 비용 기반으로 자유롭게 변경 가능
OUTER JOIN (LEFT / RIGHT) 기준 집합은 모두 출력하고, 참조 집합에 데이터가 없으면 NULL로 채움 조인 순서가 고정되기 쉬움. 기준 집합이 주로 Driving 테이블이 되며, 조건절 위치(ON vs WHERE)에 따라 결과가 완전히 달라짐
FULL OUTER JOIN 양쪽 집합의 모든 행을 보존하며 매칭되지 않는 영역은 서로 NULL 처리 (합집합) Oracle 11g 이상에서는 내부적으로 HASH JOIN FULL OUTER(Native Full Outer Join) 오퍼레이션으로 단일 패스 처리
CROSS JOIN 조인 조건 없이 양쪽 집합의 모든 행을 $M \times N$ 결합 (카테시안 곱) 실행 계획상 **MERGE JOIN CARTESIAN**으로 나타나며, 후행 집합을 PGA에 BUFFER SORT하여 반복 매핑
SEMI / ANTI JOIN EXISTS/IN (Semi) 또는 NOT EXISTS/NOT IN (Anti) 서브쿼리가 변환된 조인 메인 쿼리의 행이 서브쿼리에 존재하는지만 확인하고 즉시 다음 행으로 이동하여 중복(1:M 증폭)을 원천 방지

실무 주의사항 (Outer Join 필터링 오류):

LEFT OUTER JOIN 사용 시 후행 테이블의 일반 필터 조건을 WHERE 절에 기술하면 옵티마이저가 이를 일반 INNER JOIN으로 강제 변환(Outer-to-Inner Conversion)해 버립니다. 아우터 조인의 기준 집합을 유지하려면 후행 테이블 필터 조건은 반드시 ON 절 내부에 명시해야 합니다.

2. 물리적 조인(Physical Join) 3대 알고리즘 내부 메커니즘

오라클 엔진이 실제로 데이터를 결합하는 3가지 물리적 메커니즘과 메모리(SGA/PGA) 작동 원리입니다.

① Nested Loops Join (NL 조인)

프로그래밍의 이중 FOR 루프와 완전히 동일한 구조로 동작하며, OLTP 환경의 핵심 조인 방식입니다.

------------------------------------------------------------------------------------
| Id  | Operation                    | Name        | Starts | E-Rows | A-Rows | Buffers |
------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT             |             |      1 |        |     15 |      48 |
|   1 |  NESTED LOOPS                |             |      1 |     15 |     15 |      48 |
|   2 |   NESTED LOOPS               |             |      1 |     15 |     15 |      33 |
|   3 |    TABLE ACCESS FULL         | DEPARTMENTS |      1 |      3 |      3 |       7 |
|*  4 |    INDEX RANGE SCAN          | EMP_DEPT_IX |      3 |      5 |     15 |       6 |
|   5 |   TABLE ACCESS BY INDEX ROWID| EMPLOYEES   |     15 |      5 |     15 |      15 |
------------------------------------------------------------------------------------
  • 동작 순서:
    1. 선행(Outer/Driving) 테이블에서 조건을 만족하는 행을 추출합니다.
    2. 추출된 각 행의 조인 키 값을 가지고 후행(Inner/Driven) 테이블의 인덱스를 탐색합니다. (선행 결과 건수만큼 Starts 반복 수행)
    3. 인덱스에서 얻은 ROWID로 후행 테이블 블록을 Random Access (Single Block I/O)하여 최종 데이터를 가져옵니다.
  • 메모리 및 리소스: 모든 블록 탐색이 SGA의 Buffer Cache 상에서 이루어집니다. 동일 블록을 반복 액세스할 경우 cache buffers chains 래치(Latch) 경합이나 Buffer Pinning 여부가 성능을 좌우합니다.
  • 특징: 소량 데이터 처리 및 부분 범위 처리(First Rows)에 압도적으로 유리하지만, 선행 집합의 건수가 많아지면 랜덤 I/O 폭발로 성능이 급격히 저하됩니다.

② Sort Merge Join (소트 머지 조인)

양쪽 테이블을 각각 조인 컬럼 기준으로 정렬(Sort)한 뒤, 정렬된 두 집합을 위에서부터 차례대로 스캔하며 병합(Merge)하는 방식입니다.

------------------------------------------------------------------------------------
| Id  | Operation                    | Name        | Rows  | Bytes |TempSpc| Cost  |
------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT             |             | 50000 |  3417K|       |  1420 |
|   1 |  MERGE JOIN                  |             | 50000 |  3417K|       |  1420 |
|   2 |   SORT JOIN                  |             | 10000 |   390K|  1120K|   410 |
|   3 |    TABLE ACCESS FULL         | ORDERS      | 10000 |   390K|       |   180 |
|*  4 |   SORT JOIN                  |             | 50000 |  1464K|  4200K|  1010 |
|   5 |    TABLE ACCESS FULL         | ORDER_ITEMS | 50000 |  1464K|       |   320 |
------------------------------------------------------------------------------------
  • 동작 순서:
    1. 선행 집합을 읽어들여 조인 키 기준으로 정렬합니다 (SORT JOIN). 단, 선행 집합에 이미 정렬된 인덱스가 있다면 이 정렬 단계는 생략될 수 있습니다.
    2. 후행 집합도 동일하게 읽어들여 조인 키 기준으로 정렬합니다 (SORT JOIN).
    3. 정렬된 두 집합(PGA 내 위치)을 스캔하면서 조인 조건을 비교해 결합합니다.
  • 메모리 및 리소스: 정렬 작업은 공유 메모리(SGA)가 아닌 프로세스 전용 메모리인 PGA의 Sort Area에서 수행됩니다. 정렬할 데이터 크기가 PGA 할당량을 초과하면 TEMP Tablespace(디스크)로 Spill이 발생하여 direct path write temp / read temp 대기가 발생합니다.
  • 특징: 조인 조건이 등치(=)가 아닌 비등치 조인(BETWEEN, <, >)일 때 Hash Join을 사용할 수 없으므로 대용량 비등치 조인의 유일한 대안으로 활약합니다.

③ Hash Join (해시 조인)

소트 부하 없이 해시 함수(Hash Function)를 사용하여 대용량 데이터를 고속으로 결합하는 배치 및 DW/OLAP 환경의 표준 엔진입니다.

-----------------------------------------------------------------------------------
| Id  | Operation          | Name        | Starts | E-Rows | A-Rows |    Used-Mem  |
-----------------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |             |      1 |        |  50000 |             |
|*  1 |  HASH JOIN         |             |      1 |  50000 |  50000 |  1542K (0)  |
|   2 |   TABLE ACCESS FULL| DEPARTMENTS |      1 |   1000 |   1000 |             |
|   3 |   TABLE ACCESS FULL| EMPLOYEES   |      1 |  50000 |  50000 |             |
-----------------------------------------------------------------------------------
  • 동작 순서:
    1. Build 단계: 두 집합 중 크기가 작은 집합(Build Input)을 읽어 PGA의 Hash Area에 해시 테이블(Hash Map)을 생성합니다.
    2. Probe 단계: 큰 집합(Probe Input)을 스캔하면서 조인 키에 해시 함수를 적용해, 방금 만든 PGA 해시 버킷을 탐색하며 즉각 매칭합니다.
  • 메모리 및 리소스: Build Input이 PGA의 Hash Area에 완전히 들어맞으면 Optimal(In-Memory) 모드로 초고속 처리되지만, 메모리가 부족하면 파티션을 나누어 TEMP Tablespace에 썼다 읽는 One-Pass 또는 Multi-Pass 동작으로 전락하며 성능이 급락합니다.
  • 특징: 오직 등치(=) 조인 조건에서만 작동합니다. 인덱스 유무와 무관하게 Full Table Scan(Multiblock I/O)과 결합되어 대용량 처리 시 최고의 처리량(Throughput)을 보장합니다.

3. 실무에서 발생하는 조인 성능 저하 주요 원인

프로덕션 환경에서 조인 쿼리가 장애나 슬로우 쿼리를 유발하는 대표적인 3가지 원인입니다.

  1. 대용량 집합에 대한 잘못된 NL 조인 선택: 통계정보가 오래되어 옵티마이저가 선행 테이블을 10건으로 예측했으나 실제로는 100만 건인 경우, 후행 테이블로 100만 번의 인덱스 랜덤 액세스가 발생하여 Buffer Cache 경합(db file sequential read 대기 급증)이 발생합니다.
  2. Hash Join 시 Build Input 선정 실패: 큰 테이블이 Build Input(선행)으로 선택되어 PGA Hash Area가 터지고 TEMP Tablespace I/O(direct path read/write temp)가 폭증하는 현상입니다.
  3. 고빈도 OLTP 쿼리에 무분별한 Hash Join 적용: 초당 수백 번 실행되는 온라인 트랜잭션 쿼리가 매번 해시 테이블을 PGA에 생성하고 소멸시키면, 서버 전체의 CPU 사용률과 PGA 메모리 할당 경합이 극심해집니다.

4. 트러블슈팅 및 튜닝 해결 방안

① 물리적 조인별 핵심 비교 매트릭스

비교 항목 Nested Loops Join Sort Merge Join Hash Join
주요 작업 메모리 SGA (Buffer Cache) PGA (Sort Area) PGA (Hash Area)
조인 조건 제약 모든 조건 가능 모든 조건 가능 (비등치 특화) 등치(=) 조건만 가능
인덱스 의존도 매우 높음 (Inner 조인 키 필수) 낮음 (정렬 생략 시에만 유효) 없음 (Full Scan 친화적)
최적의 워크로드 소량 데이터, OLTP, 부분 범위 처리 비등치 대용량 조인, 기 정렬 집합 대용량 배치, DW, 집계 리포팅
제어 힌트 USE_NL(A B) USE_MERGE(A B) USE_HASH(A B)

② 힌트(Hint)를 활용한 조인 순서 및 방식 강제 제어

옵티마이저가 잘못된 물리적 조인을 선택했을 때는 반드시 LEADING 힌트(조인 순서)와 조인 방식 힌트를 쌍으로 묶어서 교정해야 합니다.

-- 1. 소량 데이터 OLTP 최적화: A를 먼저 읽고 B를 NL 조인으로 유도
SELECT /*+ LEADING(A B) USE_NL(B) INDEX(B EMP_DEPT_IX) */
       A.DEPT_NAME, B.EMP_NAME, B.SALARY
FROM   DEPARTMENTS A
JOIN   EMPLOYEES B ON A.DEPT_ID = B.DEPT_ID
WHERE  A.LOC_CODE = 'SEOUL';

-- 2. 대용량 배치 최적화: 작은 집합(A)으로 해시 테이블을 Build(SWAP_JOIN_INPUTS)하고 큰 집합(B)으로 Probe
SELECT /*+ LEADING(A B) USE_HASH(B) SWAP_JOIN_INPUTS(A) */
       A.DEPT_NAME, COUNT(B.EMP_ID), SUM(B.SALARY)
FROM   DEPARTMENTS A
JOIN   LARGE_EMP_HISTORY B ON A.DEPT_ID = B.DEPT_ID
GROUP BY A.DEPT_NAME;

③ PGA 메모리 효율 및 실행 계획 진단 쿼리 (Allstats Last)

쿼리 튜닝 시 실제 조인 단계에서 발생한 블록 I/O(Buffers)와 PGA 메모리/TEMP 사용량(Used-Mem, TempSpc)을 실측하여 원인을 진단합니다.

-- 1. 테스트 세션에 통계 수집 레벨 활성화
ALTER SESSION SET STATISTICS_LEVEL = ALL;

-- 2. 대상 조인 쿼리 실행
SELECT /*+ GATHER_plan_statistics */ ... ;

-- 3. 실제 수행된 조인 오퍼레이션 및 메모리/I/O 실측치 확인
SELECT * 
FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(NULL, NULL, 'ALLSTATS LAST +PEEKED_BINDS'));

 


5. 요약

  • 논리적 조인과 물리적 조인의 분리: INNER/OUTER JOIN은 데이터 결합의 논리적 요건일 뿐이며, 실제 DB 성능은 옵티마이저가 선택하는 NL / Sort Merge / Hash Join 중 무엇이 작동하느냐에 달려 있습니다.
  • 워크로드별 표준 공식:
    • 소량 건수 & 인덱스 기반 온라인 트랜잭션 $\rightarrow$ Nested Loops Join
    • 대용량 데이터 & 등치(=) 조건 배치/집계 $\rightarrow$ Hash Join (작은 집합을 Build Input으로 배치)
    • 대용량 데이터 & 부등호(BETWEEN, >, <) 조인 조건 $\rightarrow$ Sort Merge Join
  • 튜닝의 기본 원칙: 조인 힌트(USE_NL, USE_HASH, USE_MERGE)를 쓸 때는 반드시 조인 순서를 결정하는 LEADING 또는 해시 방향을 지정하는 SWAP_JOIN_INPUTS / NO_SWAP_JOIN_INPUTS 힌트를 함께 명시해야 실행 계획이 흔들리지 않습니다.

CROSS JOIN과 카테시안 곱이란?

SQL 표준(ANSI SQL)에서 지원하는 CROSS JOIN은 조인 조건(Join Predicate) 없이 양쪽 집합의 모든 행을 1:1로 결합하는 연산입니다. 집합 이론 관점에서는 두 집합의 카테시안 곱(Cartesian Product)을 구하는 연산에 해당합니다.

-- ANSI CROSS JOIN 문법
SELECT A.EMP_ID, B.DEPT_ID
FROM EMPLOYEES A
CROSS JOIN DEPARTMENTS B;

-- 전통적인 Oracle 문법 (조인 조건 부재)
SELECT A.EMP_ID, B.DEPT_ID
FROM EMPLOYEES A, DEPARTMENTS B;

집합 A의 건수가 $M$개이고 집합 B의 건수가 $N$개일 때, 최종 결과 집합은 $M \times N$ 건이 됩니다.

실무 운영 환경에서 대다수의 개발자나 주니어 DBA는 CROSS JOIN을 "조인 조건을 누락해 발생한 치명적인 쿼리 실수"로 기억하지만, 통계 집계, 복제 테이블(Data Duplication), 달력 매트릭스 생성 등 고도화된 SQL 테크닉에서는 필수적인 도구로 활용됩니다.

 

 

오라클 내부 메커니즘: MERGE JOIN CARTESIAN

오라클 옵티마이저가 두 집합을 카테시안 곱 형태로 결합해야 한다고 판단하면, 실행 계획(Execution Plan)에는 일반적인 Hash Join이나 Nested Loop Join 대신 MERGE JOIN CARTESIAN 오퍼레이션이 등장합니다.

---------------------------------------------------------------------------------
| Id  | Operation              | Name        | Rows  | Bytes | Cost (%CPU)|
---------------------------------------------------------------------------------
|   0 | SELECT STATEMENT       |             | 10700 | 187K  |    28   (0)|
|   1 |  MERGE JOIN CARTESIAN  |             | 10700 | 187K  |    28   (0)|
|   2 |   TABLE ACCESS FULL    | DEPARTMENTS |    27 |   189 |     3   (0)|
|   3 |   BUFFER SORT          |             |   107 |  1177 |    25   (0)|
|   4 |    TABLE ACCESS FULL   | EMPLOYEES   |   107 |  1177 |     1   (0)|
---------------------------------------------------------------------------------

1. 왜 Sort Merge 형태를 취하는가?

Nested Loop Join으로 $M \times N$ 건을 처리하면, Outer 테이블의 1개 로우마다 Inner 테이블을 매번 디스크 또는 Buffer Cache에서 반복 스캔해야 하므로 I/O 오버헤드가 극단적으로 치솟습니다.

오라클은 이를 방지하기 위해 다음 단계로 처리합니다.

  1. Outer 테이블(위 플랜의 Id 2) 스캔: 먼저 선행 테이블을 읽어옵니다.
  2. Inner 테이블(위 플랜의 Id 4) 버퍼링: 후행 테이블을 1회 Full Scan하여 PGA 메모리(Sort Area) 내에 BUFFER SORT 형태로 적재합니다. (이때의 BUFFER SORT는 실제 정렬 연산이 아니라 재사용을 위한 메모리 캐싱 단계입니다.)
  3. PGA 레벨의 반복 조인: Outer 테이블의 각 행마다 메모리에 캐싱된 Inner 테이블 전체를 반복 매핑합니다.

내부 리소스 영향:

Inner 테이블의 데이터가 PGA의 sort_area_size 또는 pga_aggregate_target 한도를 초과할 경우, 데이터 블록이 임시 테이블스페이스(TEMP Tablespace)로 Spill(Direct Path Read/Write)되어 극심한 I/O 대기 이벤트(direct path read temp, direct path write temp)를 유발합니다.

실무 발생 원인: 의도치 않은 카테시안 곱은 왜 발생하는가?

의도된 모델링 기법을 제외하고, 프로덕션 DB에서 예기치 않은 MERGE JOIN CARTESIAN이 유발되어 DB가 멈칫하는 주원인은 크게 세 가지입니다.

1. 조인 조건의 물리적 누락

전통적인 Oracle 문법(쉼표 조인)을 사용할 때 Where 절에서 조인 조건 컬럼을 빼먹는 가장 단순한 형태의 휴먼 에러입니다.

2. 스타 변환(Star Transformation) 및 잘못된 통계정보

CBO(Cost-Based Optimizer)가 계산한 특정 테이블의 Cardinality(예상 건수)가 1건으로 평가될 때 발생합니다.

  • 옵티마이저는 비용 계산 시 *"테이블 A가 어차피 1건뿐이라면, 테이블 B와 조인 조건을 먼저 따지는 것보다 카테시안 곱으로 묶어둔 뒤 다른 큰 테이블과 Hash Join하는 비용이 더 싸다"*고 오판합니다.
  • 오브젝트 통계정보가 오래되었거나, Dynamic Sampling 수치가 왜곡되어 실제 수만 건의 테이블을 1건으로 인식하면 대참사가 일어납니다.

3. 복합 테이블 조인 시 연결 고리 누락

테이블 A, B, C를 조인하면서 A-B는 연결했지만 B-C 또는 A-C 연결 고리를 누락하여, 부분 집합 간 카테시안 곱이 발생하는 경우입니다.

 


 

트러블슈팅 및 튜닝 전략

1. 실행 계획 확인 및 V$SQL 런타임 통계 점검

운영 중 CPU 사용률이나 TEMP 사용량이 치솟았다면, MERGE JOIN CARTESIAN을 수행 중인 세션과 쿼리를 즉시 특정해야 합니다.

-- 실행 중인 카테시안 조인 쿼리 식별
SELECT S.SID, S.SERIAL#, S.SQL_ID, Q.SQL_TEXT, P.OPERATION, P.OPTIONS
FROM V$SESSION S
JOIN V$SQL_PLAN P ON S.SQL_ID = P.SQL_ID AND S.SQL_CHILD_NUMBER = P.CHILD_NUMBER
JOIN V$SQLTEXT Q ON S.SQL_ID = Q.SQL_ID
WHERE P.OPERATION = 'MERGE JOIN'
  AND P.OPTIONS = 'CARTESIAN'
  AND S.STATUS = 'ACTIVE';

2. 통계정보 재수집 (Object Statistics)

통계정보 왜곡으로 인한 CBO의 잘못된 경로 선택인 경우, 관련 테이블의 통계정보를 최신화하여 정확한 Cardinality를 유도합니다.

BEGIN
  DBMS_STATS.GATHER_TABLE_STATS(
    ownname          => 'HR',
    tabname          => 'DEPARTMENTS',
    estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE,
    method_opt       => 'FOR ALL COLUMNS SIZE AUTO',
    cascade          => TRUE
  );
END;
/

3. HINT를 통한 실행 계획 교정

옵티마이저가 강제로 카테시안 곱을 유도하지 못하도록 ANSI Join 표준을 사용하거나, LEADING, USE_NL, USE_HASH, NO_MERGE 힌트를 적절히 부여하여 조인 순서와 방식을 제어합니다.

-- 의도치 않은 Cartesian Join을 방지하고 특정 테이블들을 해시 조인으로 유도
SELECT /*+ LEADING(A B) USE_HASH(B) */
       A.COL1, B.COL2
FROM   TABLE_A A, TABLE_B B
WHERE  A.ID = B.ID(+);

 


 

긍정적인 활용: 실무 SQL 테크닉

CROSS JOIN은 무조건 피해야 할 악(惡)이 아닙니다. 대량 배치 처리나 집계 쿼리에서 로우 복제(Row Duplication) 패턴으로 우아하게 활용할 수 있습니다.

더미 집합을 활용한 행 복제 (Pivot/Unpivot 대체)

1건의 매출 데이터를 공급가액, 부가세, 합계액 3개의 로우로 확장하여 출력해야 하는 경우, Dual 테이블과의 CROSS JOIN을 통해 단 1회의 테이블 스캔으로 해결할 수 있습니다.

SELECT O.ORDER_ID,
       CASE C.NO
         WHEN 1 THEN '공급가'
         WHEN 2 THEN '부가세'
         WHEN 3 THEN '합계금액'
       END AS ITEM_NAME,
       CASE C.NO
         WHEN 1 THEN O.NET_AMT
         WHEN 2 THEN O.VAT_AMT
         WHEN 3 THEN O.TOTAL_AMT
       END AS AMOUNT
FROM   ORDERS O
CROSS JOIN (
       SELECT LEVEL AS NO FROM DUAL CONNECT BY LEVEL <= 3
) C
WHERE  O.ORDER_DATE >= TRUNC(SYSDATE, 'MM');

 


 

마무리

 

카테시안 곱(CROSS JOIN)은 데이터베이스 I/O와 메모리를 단시간에 고갈시키는 위험 요소이기도 하지만,

원리를 정확히 알고 CBO의 비용 모델과 메모리 메커니즘을 통제할 수 있다면 성능 튜닝의 유용한 도구가 됩니다.

프로덕션 환경의 슬로우 쿼리 플랜에서 MERGE JOIN CARTESIAN이 관측된다면,

먼저 CBO의 Cardinality 계산 로직과 통계정보 유효성을 점검해 보시기 바랍니다.

 

 

+ Recent posts