개요: 인라인 뷰(Inline View)는 어떻게 실행되는가?

오라클 SQL을 작성할 때 가독성을 높이고 복잡한 로직을 모듈화하기 위해 인라인 뷰(Inline View)나 가상 뷰(View)를 사용하는 것은 매우 일반적입니다.

-- 전형적인 인라인 뷰 활용 쿼리
SELECT E.EMP_ID, E.EMP_NAME, V.MAX_SAL
FROM   EMPLOYEES E,
       (SELECT DEPT_ID, MAX(SALARY) AS MAX_SAL
        FROM   EMPLOYEES
        GROUP BY DEPT_ID) V
WHERE  E.DEPT_ID = V.DEPT_ID
  AND  E.SALARY = V.MAX_SAL;

 

개발자는 쿼리를 작성할 때 "먼저 안쪽 인라인 뷰(V)를 실행해서 부서별 최고 연봉 집합을 만든 뒤, 바깥쪽 메인 쿼리와 조인하겠지"라고 생각하기 쉽습니다. 하지만 오라클 CBO(Cost-Based Optimizer)는 개발자의 생각대로 쿼리를 순차 실행하지 않는 경우가 훨씬 많습니다.

 

옵티마이저는 비용을 최소화하기 위해 인라인 뷰의 울타리를 허물고 메인 쿼리와 하나로 합치는 뷰 머징(View Merging, 뷰 병합) 최적화 기술을 적극적으로 수행합니다. View Merging의 내부 원리와 단순/복합 뷰 머징의 차이, 그리고 이를 제어하는 MERGE / NO_MERGE 힌트 튜닝 가이드를 파헤쳐 봅니다.

 


내부 메커니즘: View Merging의 두 가지 얼굴 (단순 vs 복합)

오라클 쿼리 변환 엔진(CBQT)이 뷰를 만났을 때 수행하는 병합 방식은 뷰 내부의 연산자 포함 여부에 따라 크게 두 가지로 나뉩니다.

1. 단순 뷰 머징 (Simple View Merging)

뷰 내부에 GROUP BY, DISTINCT, 집계 함수, 분석 함수, ROWNUM 등이 포함되지 않은 단순한 SELECT-FROM-WHERE 구조일 때 적용됩니다.

  • 동작 원리: 인라인 뷰의 테이블과 메인 쿼리의 테이블이 마치 처음부터 하나의 SQL이었던 것처럼 완전히 합쳐집니다.
  • 이점: 옵티마이저는 조인 순서(Driving Order)와 물리적 조인 알고리즘(NL, Hash, Sort Merge)을 제약 없이 자유롭게 선택할 수 있어 최적의 실행 계획을 수립할 수 있습니다.
-- [원본 쿼리]
SELECT * 
FROM (SELECT EMP_ID, DEPT_ID FROM EMPLOYEES WHERE SALARY > 5000) V,
     DEPARTMENTS D
WHERE V.DEPT_ID = D.DEPT_ID;

-- [단순 뷰 머징 후 변환된 형태]
SELECT E.EMP_ID, E.DEPT_ID, D.DEPT_NAME
FROM   EMPLOYEES E, DEPARTMENTS D
WHERE  E.DEPT_ID = D.DEPT_ID
  AND  E.SALARY > 5000;

2. 복합 뷰 머징 (Complex View Merging)

뷰 내부에 GROUP BY, DISTINCT 등의 집계 연산이나 집합 연산이 포함되어 있어 그대로는 합칠 수 없는 경우에 적용됩니다.

  • 동작 원리: 집계 연산 때문에 데이터의 건수나 고유성이 달라지므로, 오라클은 쿼리 변환을 통해 집계 연산을 메인 쿼리 바깥으로 끌어내거나 서브쿼리 형태로 재구조화합니다. (내부적으로 VW_COL_RBR_ 또는 VW_SQ_ 형태의 임시 뷰 이름을 생성합니다.)
  • 핵심 제약: 복합 뷰 머징이 성공하면 대량의 데이터를 미리 집계해 두는 것이 아니라 조인과 집계를 동시에 처리할 수 있어 I/O를 아낄 수 있지만, 잘못 적용되면 불필요한 연산이 늘어나 성능이 급격히 저하될 수 있습니다.

 


주요 원인 분석: 실무에서 View Merging이 독이 되는 경우

View Merging은 대다수의 경우에 득이 되지만, 실무 프로덕션 환경에서는 이 기능 때문에 치명적인 성능 저하가 발생하기도 합니다.

1. 머징으로 인한 조인 순서 왜곡 (Unwanted Merging)

대용량 이력 테이블과 소규모 마스터 테이블이 포함된 인라인 뷰가 메인 쿼리와 무분별하게 머징되면서, 옵티마이저가 대용량 이력 테이블을 메인 드라이빙(Driving) 테이블로 잘못 선택하여 인덱스를 타지 못하고 Full Table Scan을 유발하는 경우입니다.

2. 집계 뷰 머징 실패로 인한 중복 연산 (Complex Merging Overhead)

GROUP BY가 포함된 뷰를 메인 쿼리와 조인할 때, 복합 뷰 머징이 과도하게 일어나거나 반대로 아예 머징이 막혀 뷰 내부에서 전체 데이터를 다 집계한 뒤 조인하는 바람에 TEMP Tablespace Spill이 발생하는 경우입니다.

 


 

트러블슈팅 및 튜닝 가이드: MERGE와 NO_MERGE 힌트

실행 계획에서 인라인 뷰가 머징되었는지 여부는 VIEW 오퍼레이션의 존재 유무로 판단할 수 있습니다. 실행 계획에 VIEW 오퍼레이션이 사라졌다면 뷰 머징이 일어난 것이고, 여전히 VIEW 오퍼레이션이 남아 있다면 머징되지 않고 독립적으로 수행된 것입니다.

1. NO_MERGE 힌트: 뷰 머징 차단 및 실체화(Materialization) 유도

옵티마이저가 뷰를 무리하게 머징하여 조인 순서가 꼬이거나 불필요한 풀 스캔이 유발될 때는 NO_MERGE 힌트를 사용하여 인라인 뷰의 독립성을 보장해야 합니다.

-- 대량 뷰 머징을 차단하고 뷰 내부 집계를 먼저 수행(Materialize)하도록 제어
SELECT /*+ LEADING(D V) USE_HASH(V) NO_MERGE(V) */
       D.DEPT_NAME, V.AVG_SAL
FROM   DEPARTMENTS D,
       (SELECT /*+ QB_NAME(V) */ DEPT_ID, AVG(SALARY) AS AVG_SAL
        FROM   EMPLOYEES
        WHERE  HIRE_DATE >= TO_DATE('20200101', 'YYYYMMDD')
        GROUP BY DEPT_ID) V
WHERE  D.DEPT_ID = V.DEPT_ID;
  • 효과: NO_MERGE(V) 힌트가 들어가면 오라클은 인라인 뷰 V를 먼저 실행하여 결과 집합을 메모리(PGA)에 임시로 담아두거나(Materialization), 독립된 서브쿼리 집합으로 고정시킨 뒤 메인 쿼리와 조인합니다.

2. MERGE 힌트: 강제 뷰 머징 유도

만약 통계정보 왜곡이나 복잡한 쿼리 구조로 인해 오라클이 뷰 머징을 포기하고 비효율적인 FILTER 또는 독립 뷰 스캔을 수행할 때, 이를 강제로 합쳐서 최적의 조인 경로를 찾게 만들려면 MERGE 힌트를 사용합니다.

-- 단순 뷰인데 머징이 안 될 때 강제로 병합 유도
SELECT /*+ MERGE(V) */
       E.EMP_ID, E.EMP_NAME, D.DEPT_NAME
FROM   DEPARTMENTS D,
       (SELECT EMP_ID, EMP_NAME, DEPT_ID 
        FROM EMPLOYEES 
        WHERE RETIRE_YN = 'N') V
WHERE  D.DEPT_ID = V.DEPT_ID;

주의사항: 뷰 힌트를 사용할 때는 반드시 인라인 뷰 내부에 /*+ QB_NAME(뷰별칭) */ 쿼리 블록 이름을 부여하고 바깥쪽 힌트에서 이를 정확히 지칭해야 힌트 오작동을 방지할 수 있습니다.

 


 

요약 

  • View Merging의 정의: 인라인 뷰의 울타리를 허물고 메인 쿼리와 하나로 합쳐 최적의 조인 순서와 경로를 찾게 해주는 오라클 쿼리 변환 핵심 기술입니다.
  • 단순 vs 복합 뷰 머징: 그룹바이/집계 연산 유무에 따라 단순 머징(완전 통합)과 복합 머징(쿼리 구조 재조정)으로 나뉩니다.
  • 실무 튜닝 가이드:
    • 옵티마이저의 무리한 병합으로 조인 순서가 깨질 때  /*+ NO_MERGE(뷰이름) */
    • 병합이 누락되어 독립 뷰 스캔 및 I/O가 늘어날 때  /*+ MERGE(뷰이름) */

만능 열쇠로 오해받는 스칼라 서브쿼리의 양면성

SQL을 작성하는 개발자들이 LEFT OUTER JOIN을 대신해 가장 즐겨 사용하는 문법이 바로 SELECT 절에 위치하는 스칼라 서브쿼리(Scalar Subquery)입니다. 메인 쿼리의 결과 집합 건수를 왜곡시키지 않으면서 코드와 명칭(Code Lookup)을 손쉽게 매핑할 수 있고, 오라클이 내부적으로 제공하는 스칼라 서브쿼리 캐싱(Scalar Subquery Caching) 기능 덕분에 일반적인 NL 아우터 조인보다 빠른 응답 속도를 보여주는 경우가 많기 때문입니다.

 
-- 전형적인 스칼라 서브쿼리 활용 패턴
SELECT E.EMP_ID,
       E.EMP_NAME,
       (SELECT D.DEPT_NAME 
        FROM   DEPARTMENTS D 
        WHERE  D.DEPT_ID = E.DEPT_ID) AS DEPT_NAME
FROM   EMPLOYEES E;

 

그러나 이 강력한 캐싱 메커니즘은 입력 값의 종류(NDV, Number of Distinct Values)와 데이터 정렬 상태를 고려하지 않고

무분별하게 사용될 경우, 오히려 일반 조인보다 훨씬 심각한 Buffer Cache 경합(cache buffers chains latch)과 CPU 점유율 급증을 초래하는 시한폭탄으로 돌변합니다.

 

오라클 엔진이 스칼라 서브쿼리를 어떻게 메모리에 캐싱하는지, 그리고 왜 특정 조건에서 Hash Collision(해시 충돌)로 인해 성능이 급락하는지 내부 아키텍처를 파헤쳐 봅니다.

 


내부 메커니즘: Query Execution Cache와 해시 테이블 동작 원리

스칼라 서브쿼리는 원칙적으로 메인 쿼리의 결과 로우 1건마다 서브쿼리를 1회씩 반복 수행하는 Iterative Execution(반복 실행) 모델로 동작합니다. 오라클은 이 반복적인 블록 I/O 오버헤드를 줄이기 위해 PGA(엄밀히는 UGA 내의 Query Execution Cache) 영역에 소규모 인메모리 해시 테이블(In-Memory Hash Table)을 생성합니다.

1. 스칼라 서브쿼리 캐싱의 3단계 처리 흐름

메인 쿼리에서 서브쿼리로 입력 값(예: E.DEPT_ID)이 전달될 때 오라클 내부에서는 다음 과정을 거칩니다.

  1. Hash Bucket 탐색 (Cache Lookup): 입력된 E.DEPT_ID 값에 내부 해시 함수를 적용하여 UGA 내 해시 테이블의 버킷(Bucket)을 탐색합니다.
  2. Cache Hit 시: 해시 버킷에 동일한 입력 값과 매핑된 출력 값(D.DEPT_NAME)이 이미 존재한다면, 서브쿼리의 테이블이나 인덱스를 전혀 읽지 않고 즉시 메모리의 출력 값을 반환합니다. (논리적 I/O Buffers = 0)
  3. Cache Miss 시: 해시 테이블에 해당 입력 값이 없다면, 비로소 서브쿼리 블록을 실행하여 Buffer Cache에서 인덱스 및 테이블 블록을 읽고 결과 값을 가져옵니다. 이후 다음 번 호출을 위해 [입력 값 - 출력 값] 쌍을 해시 버킷에 저장합니다.

2. 내부 메모리 제한과 히든 파라미터 (_query_execution_cache_max_size)

이 캐시 공간은 무한정 늘어날 수 없으며, 단일 SQL의 실행(Execution) 수명 주기 동안만 유지되고 커서가 닫히거나 패치(Fetch)가 완료되면 즉시 해제됩니다.

  • 캐시 크기를 결정하는 핵심 히든 파라미터는 _query_execution_cache_max_size이며, 기본값은 통상 65,536 Bytes (64KB) 수준입니다. (오라클 버전별로 해시 테이블 슬롯 개수는 약 256개 ~ 1,024개 내외로 제한됩니다.)
  • 입출력 데이터의 컬럼 길이에 따라 다르지만, 대략 수백 개에서 천여 개 남짓의 고유한 입력 값만 해시 테이블에 온전히 담을 수 있습니다.

3. Hash Collision (해시 충돌) 발생 메커니즘

입력 값의 고유 개수(NDV)가 해시 테이블의 버킷 수를 초과하는 순간 Hash Collision(해시 충돌)이 발생합니다. 이때 오라클의 스칼라 서브쿼리 해시 테이블은 일반적인 Hash Join처럼 버킷 아래에 연결 리스트(Linked List Chain)를 길게 달아매지 않고, 단일 슬롯 덮어쓰기 또는 제한적 교체 구조로 동작합니다.

  • 시나리오: 입력 값 A(DEPT_ID = 10)와 입력 값 B(DEPT_ID = 276)가 해시 함수를 거쳤을 때 동일한 해시 버킷 번호(예: Bucket #5)를 배정받았다고 가정합니다.
  • 충돌 현상:
    1. 첫 번째 로우에서 10이 들어오면 Cache Miss가 발생하여 블록 I/O를 수행한 뒤 Bucket #5에 [10 : 인사팀]을 기록합니다.
    2. 두 번째 로우에서 276이 들어오면 해시 값은 Bucket #5를 가리키지만, 실제 입력 값(10 vs 276)이 다르므로 Hash Collision(Cache Miss)으로 판정됩니다.
    3. 오라클은 서브쿼리를 다시 실행해 블록 I/O를 일으키고, Bucket #5의 내용을 [276 : 재무팀]으로 덮어씁니다(Overwrite).
    4. 세 번째 로우에서 다시 10이 들어오면, 이미 276에 의해 캐시가 밀려났으므로 또다시 Cache Miss가 발생하여 서브쿼리를 재수행합니다.

핵심 요약: 입력 데이터가 정렬되지 않은 상태에서 고유 값의 종류(NDV)가 많으면, 캐시에 넣고 덮어쓰는 CPU 연산만 소모할 뿐 실제 캐시 히트율은 0%에 수렴하여 순수 NL 조인보다도 느려집니다.

 

주요 원인 분석: 실무에서 스칼라 서브쿼리가 독이 되는 3가지 순간

프로덕션 환경에서 스칼라 서브쿼리가 슬로우 쿼리의 주범으로 지목되는 원인은 매우 명확합니다.

1. 입력 값의 높은 카디널리티 (High NDV)

공통 코드나 부서 코드처럼 종류가 수십 개 이하인 컬럼이 아니라, 고객번호(CUST_ID), 주문번호(ORDER_ID), 상품번호(ITEM_ID)처럼 고유 값이 수만~수백만 개인 컬럼을 스칼라 서브쿼리의 입력 값으로 넣는 경우입니다. 해시 테이블 크기를 아득히 초과하여 매 로우마다 Cache Miss와 해시 덮어쓰기 오버헤드가 발생합니다.

2. 대량 배치/DW 쿼리에서 UNNEST 차단으로 인한 NL 강제 고착화

메인 쿼리 결과가 100만 건인 대량 집계 쿼리에서는 참조 테이블과 Hash Outer Join으로 한 번에 풀어야 마땅합니다. 그러나 개발자가 이를 스칼라 서브쿼리로 작성해 두면(특히 Oracle 11g 이하 환경이나 복잡한 스칼라 구조), 옵티마이저가 이를 일반 조인으로 풀어내지 못하고 100만 번의 인덱스 랜덤 액세스(Single Block I/O)를 수행하게 만듭니다.

3. 다중 컬럼 반환을 위한 동일 서브쿼리 중복 호출

스칼라 서브쿼리는 문법상 단일 행, 단일 컬럼(1 Row, 1 Column)만 반환할 수 있습니다. 만약 부서 테이블에서 부서명, 부서위치, 매니저ID 3개 컬럼을 가져와야 할 때, 개발자들이 동일한 스칼라 서브쿼리를 SELECT 절에 3번 복사해서 붙여넣으면 똑같은 테이블/인덱스 탐색과 해시 캐시 구성을 3배로 중복 수행하게 됩니다.


 

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

1. 실행 계획(ALLSTATS LAST)을 통한 캐시 효율 진단

스칼라 서브쿼리의 캐싱이 제대로 작동하고 있는지 확인하려면, 반드시 메인 쿼리의 출력 로우 수(A-Rows)와 스칼라 서브쿼리 오퍼레이션의 실제 실행 횟수(Starts)를 비교해야 합니다.

-- 실행 통계 수집 후 커서 플랜 확인
SELECT /*+ GATHER_PLAN_STATISTICS */
       O.ORDER_ID,
       (SELECT C.CUST_NAME FROM CUSTOMERS C WHERE C.CUST_ID = O.CUST_ID) AS CUST_NAME
FROM   ORDERS O
WHERE  O.ORDER_DATE >= TRUNC(SYSDATE - 7);

SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(NULL, NULL, 'ALLSTATS LAST'));
---------------------------------------------------------------------------------------------
| Id  | Operation                   | Name        | Starts | E-Rows | A-Rows | Buffers |
---------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT            |             |      1 |        |   100K |     215K|
|   1 |   TABLE ACCESS BY INDEX ROWID| CUSTOMERS   |  85420 |      1 |  85420 |     170K|
|*  2 |   INDEX UNIQUE SCAN         | PK_CUST     |  85420 |      1 |  85420 |   43200 |
|*  3 |  TABLE ACCESS FULL          | ORDERS      |      1 |   100K |   100K |    1800 |
---------------------------------------------------------------------------------------------

진단 기준:

  • 정상적인 캐싱: 메인 쿼리(Id 3)의 A-Rows가 100K(10만 건)일 때, 서브쿼리(Id 1, 2)의 Starts가 30 ~ 500 수준이라면 캐시 히트율이 99% 이상인 최상의 상태입니다.
  • 캐싱 실패 (튜닝 대상): 위 플랜처럼 메인 쿼리가 100K인데 서브쿼리의 Starts가 85,420번 찍히고 Buffers가 21만 블록에 달한다면, 높은 NDV와 Hash Collision으로 인해 캐시가 무력화된 상태입니다.

2. 해결책 ①: Oracle 12c 이상의 UNNEST 힌트 활용 (Scalar Subquery Unnesting)

Oracle 12c부터는 옵티마이저가 SELECT 절의 스칼라 서브쿼리도 자동으로 LEFT OUTER JOIN으로 변환하는 기능이 도입되었습니다(_optimizer_unnest_scalar_sq = TRUE). 만약 옵티마이저가 비용 계산 문제로 이를 변환하지 않았다면, 스칼라 서브쿼리 내부에 /*+ UNNEST */ 힌트를 명시하여 강제로 Hash Outer Join을 유도할 수 있습니다.

-- 스칼라 서브쿼리를 강제로 풀어내어 Hash Outer Join으로 유도
SELECT /*+ LEADING(O) USE_HASH(C@SUBQ) */
       O.ORDER_ID,
       (SELECT /*+ QB_NAME(SUBQ) UNNEST */ C.CUST_NAME 
        FROM   CUSTOMERS C 
        WHERE  C.CUST_ID = O.CUST_ID) AS CUST_NAME
FROM   ORDERS O
WHERE  O.ORDER_DATE >= TRUNC(SYSDATE - 7);

단, 애플리케이션 SQL을 직접 수정할 수 있는 상황이라면 힌트에 의존하기보다 명시적인 LEFT OUTER JOIN 구문으로 리팩토링하는 것이 가장 직관적이고 안전합니다.

3. 해결책 ②: 입력 데이터 정렬(ORDER BY)을 통한 로컬 캐싱 극대화

스칼라 서브쿼리 내부에 복잡한 로직이 있어 아우터 조인으로 풀기 어렵고, 고유 값(NDV)이 해시 버킷 크기를 약간 웃도는 수준(예: 2,000 ~ 5,000개)이라면, 인라인 뷰에서 입력 키 기준으로 먼저 정렬(ORDER BY 또는 인덱스 스캔 유도)한 뒤 스칼라 서브쿼리를 태우는 기법이 유효합니다.

SELECT ORDER_ID,
       (SELECT C.CUST_NAME FROM CUSTOMERS C WHERE C.CUST_ID = X.CUST_ID) AS CUST_NAME
FROM (
       -- 인라인 뷰에서 CUST_ID 순으로 미리 정렬하여 전달 (NO_MERGE 필수)
       SELECT /*+ NO_MERGE */ O.ORDER_ID, O.CUST_ID
       FROM   ORDERS O
       WHERE  O.ORDER_DATE >= TRUNC(SYSDATE - 7)
       ORDER BY O.CUST_ID
) X;
  • 원리: 입력 값(CUST_ID)이 1, 1, 1, ..., 2, 2, 2, ..., 5000, 5000처럼 끼리끼리 뭉쳐서 들어오면, 나중에 다른 값에 의해 해시 버킷이 덮어쓰여지더라도 동일한 값은 이미 앞 단계에서 100% Cache Hit 처리된 이후이므로, 서브쿼리 실행 횟수(Starts)는 정확히 고유 값 개수(NDV)만큼만 수행되고 끝납니다.

4. 해결책 ③: 다중 컬럼 추출 시 문자열 결합(Concatenation) 또는 객체 반환

스칼라 서브쿼리 캐싱 효율이 매우 좋은 상황(낮은 NDV)에서 2~3개의 컬럼을 동시에 가져와야 한다면, 서브쿼리를 여러 개 쪼개지 말고 고정 길이 문자열(RPAD 또는 구분자)로 결합하여 1회만 호출한 뒤 바깥에서 잘라 쓰는(SUBSTR / REGEXP_SUBSTR) 고전 튜닝 기법을 적용합니다.

SELECT EMP_ID,
       EMP_NAME,
       TRIM(SUBSTR(DEPT_INFO, 1, 20))  AS DEPT_NAME,
       TRIM(SUBSTR(DEPT_INFO, 21, 10)) AS LOC_CODE
FROM (
       SELECT E.EMP_ID,
              E.EMP_NAME,
              -- 단 1회의 스칼라 서브쿼리 호출로 2개 컬럼 정보를 동시 캐싱
              (SELECT RPAD(D.DEPT_NAME, 20) || RPAD(D.LOC_CODE, 10)
               FROM   DEPARTMENTS D
               WHERE  D.DEPT_ID = E.DEPT_ID) AS DEPT_INFO
       FROM   EMPLOYEES E
);

 

 

요약

  • 스칼라 서브쿼리 캐싱의 본질: UGA 메모리 내 소규모 해시 테이블(_query_execution_cache_max_size, 기본 64KB)에 [입력값-출력값]을 저장하여 반복적인 블록 I/O를 생략하는 기법입니다.
  • Hash Collision 리스크: 입력 값의 종류(NDV)가 많고 정렬되어 있지 않으면 해시 버킷 충돌과 덮어쓰기(Overwrite)가 반복되어 캐시 효율이 완전히 깨지며, 대량 배치 시 심각한 랜덤 I/O 병목을 일으킵니다.
  • 실무 튜닝 공식:
    • 공통 코드/부서 코드 등 소수 고유 값(Low NDV) + 소량 OLTP 조회 $\rightarrow$ 스칼라 서브쿼리 유지
    • 고객번호/주문번호 등 다수 고유 값(High NDV) + 대량 조회 $\rightarrow$ LEFT OUTER JOIN 변환 (또는 /*+ UNNEST */ 힌트 적용)
    • 아우터 조인 변환이 불가능한 중간 규모 NDV $\rightarrow$ 인라인 뷰 ORDER BY 정렬 후 스칼라 호출

개요: EXISTS와 IN, 무엇이 더 빠를까?

SQL 튜닝 인터뷰나 실무 코드 리뷰에서 가장 단골로 등장하는 질문 중 하나는 "WHERE 절에서 IN과 EXISTS 중 어느 것이 성능상 유리한가?"입니다. 과거 규칙 기반 옵티마이저(RBO) 시절의 낡은 가이드에서는 "메인 쿼리가 작으면 EXISTS, 서브쿼리 결과가 작으면 IN을 써라"라고 공식처럼 정의하곤 했습니다.

 

하지만 현대의 비용 기반 옵티마이저(CBO, Cost-Based Optimizer) 환경에서 이 명제는 절반만 맞고 절반은 틀립니다.

오라클 옵티마이저는 쿼리 변환기(Query Transformer)를 통해 사용자가 IN으로 작성하든 EXISTS로 작성하든, 내부적으로 동일한 세미 조인(Semi Join) 또는 안티 조인(Anti Join) 실행 계획으로 통합 변환하여 최적의 경로를 찾아내기 때문입니다.

결국 실무 튜닝의 핵심은 문법적 모양새가 아니라, 옵티마이저가 서브쿼리를 어떻게 풀어내는지(Subquery Unnesting)와 그 과정에서 Semi / Anti Join이 메모리(Buffer Cache / PGA) 상에서 어떻게 동작하는지 그 내부 메커니즘을 통제하는 데 있습니다.

 


 

내부 메커니즘: 쿼리 변환(Query Transformation)과 Semi/Anti Join

오라클 CBO가 IN, EXISTS, NOT IN, NOT EXISTS가 포함된 SQL을 파싱할 때 내부적으로 일어나는 핵심 아키텍처는 다음과 같습니다.

1. Subquery Unnesting (서브쿼리 풀어내기) vs FILTER

옵티마이저는 서브쿼리를 만났을 때 크게 두 가지 경로 중 하나를 선택합니다.

  • Subquery Unnesting (UNNEST): 서브쿼리의 중첩 구조를 허물고 메인 쿼리와 동일한 레벨(Flat)로 끌어올려 일반 조인 형태(Semi/Anti Join)로 변환합니다. 조인 순서와 물리적 조인 기법(NL, Hash, Sort Merge)을 자유롭게 선택할 수 있어 대용량 처리에서 필수적입니다.
  • Subquery Filtering (NO_UNNEST): 서브쿼리를 풀지 않고 원형 그대로 둡니다. 실행 계획상 FILTER 오퍼레이션으로 나타나며, 기본적으로 Nested Loops와 유사하게 메인 쿼리의 각 행마다 서브쿼리를 반복 실행합니다.

2. Semi Join (세미 조인: EXISTS, IN)의 동작 원리

일반적인 Inner Join은 메인 테이블(1)과 서브 테이블(M)이 조인되면 최종 결과 건수가 M건으로 증폭됩니다. 반면, Semi Join(NL_SJ, HASH_SJ, MERGE_SJ)은 메인 쿼리의 행이 서브쿼리에 '단 1건이라도 존재하는지'만 확인하고 즉시 탐색을 멈추는(Short-Circuit Evaluation) 조인 기법입니다.

SELECT D.DEPT_ID, D.DEPT_NAME
FROM   DEPARTMENTS D
WHERE  EXISTS (SELECT /*+ UNNEST NL_SJ */ 1
               FROM   EMPLOYEES E
               WHERE  E.DEPT_ID = D.DEPT_ID
                 AND  E.SALARY >= 5000);
--------------------------------------------------------------------------------------
| Id  | Operation             | Name        | Starts | E-Rows | A-Rows | Buffers |
--------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT      |             |      1 |        |     11 |      28 |
|   1 |  NESTED LOOPS SEMI    |             |      1 |     11 |     11 |      28 |
|   2 |   TABLE ACCESS FULL   | DEPARTMENTS |      1 |     27 |     27 |       7 |
|*  3 |   INDEX RANGE SCAN    | EMP_DEPT_IX |     27 |     45 |     11 |      21 |
--------------------------------------------------------------------------------------
  1. 중복 제거 및 조기 종료(Early Exit): DEPARTMENTS의 특정 부서(DEPT_ID = 10)에 연봉 5000 이상인 사원이 1,000명 있더라도, NESTED LOOPS SEMI는 EMP_DEPT_IX 인덱스를 스캔하다가 조건에 맞는 첫 번째 로우를 찾는 순간 나머지 999건에 대한 스캔을 즉시 중단하고 다음 부서(DEPT_ID = 20)로 넘어갑니다. Buffer Cache의 논리적 블록 읽기(Buffers)를 획기적으로 줄여줍니다.
  2. 조인 순서 역전 (Right Semi Join): IN 절이나 EXISTS 절이 UNNEST 되면, 옵티마이저는 서브쿼리 집합을 먼저 읽고(SORT UNIQUE 또는 VW_NSO_1 뷰 생성) 메인 쿼리를 나중에 조인하거나, 반대로 서브쿼리를 Hash Build Input으로 삼는 HASH JOIN RIGHT SEMI를 구사할 수 있습니다.

3. Anti Join (안티 조인: NOT EXISTS, NOT IN)과 NULL의 치명적 차이

조건을 만족하지 않는 데이터를 걸러내는 Anti Join(NL_AJ, HASH_AJ, MERGE_AJ)에서는 NOT EXISTS와 NOT IN 사이에 아키텍처적으로 엄청난 차이가 존재합니다. 바로 NULL 값을 다루는 논리 구조(Three-Valued Logic)입니다.

  • NOT EXISTS: 조인 컬럼의 NULL 여부를 따지지 않고 매칭되는 로우가 없으면 무조건 참(True)을 반환합니다. 따라서 옵티마이저가 거리낌 없이 HASH JOIN ANTI나 NESTED LOOPS ANTI로 즉각 변환합니다.
  • NOT IN: 서브쿼리 결과 집합 내에 단 1건이라도 NULL이 포함되어 있으면 전체 평가식이 UNKNOWN(결과 0건)이 되어버리는 SQL 표준 제약을 가집니다.
    • 예: WHERE A.ID NOT IN (10, 20, NULL) $\rightarrow$ A.ID <> 10 AND A.ID <> 20 AND A.ID <> NULL(알수없음) $\rightarrow$ 항상 False (결과 0건)

이 때문에 과거 오라클 버전에서는 서브쿼리 컬럼에 NOT NULL 제약조건이 없으면 Anti Join으로 풀지 못하고 악명 높은 FILTER 플랜으로 처리했습니다. Oracle 11g 이후부터는 이를 개선하기 위해 HASH JOIN ANTI NA (Null-Aware Anti Join) 및 HASH JOIN ANTI SNA (Single Null-Aware) 알고리즘이 도입되었으나, 여전히 내부적으로 NULL 체크를 위한 추가적인 프로브(Probe) 연산이 발생하므로 순수 NOT EXISTS보다 비용이 높습니다.

 

주요 원인 분석: 실무에서 서브쿼리 성능이 급락하는 이유

프로덕션 환경에서 서브쿼리가 포함된 SQL이 갑작스러운 I/O 폭증이나 CPU 고갈을 일으키는 주원인은 다음과 같습니다.

1. Unnesting 실패로 인한 FILTER 오퍼레이션 고착화

서브쿼리 내부에 ROWNUM, 집계 함수, CONNECT BY, 또는 복잡한 OR 조건이 결합되어 있으면 옵티마이저가 UNNEST 변환을 포기하고 FILTER 오퍼레이션을 선택합니다.

  • 왜 문제인가?: 메인 쿼리의 결과 건수가 100만 건이고 고유 값(Distinct Value) 종류가 많다면, 서브쿼리를 100만 번 반복 실행하면서 Buffer Cache의 cache buffers chains 래치 경합과 db file sequential read 대기를 폭발시킵니다. (오라클이 내부적으로 Scalar Subquery Caching과 유사한 Filter 최적화를 수행하지만, 입력 값의 종류가 해시 테이블 크기를 넘어가면 캐시 미스가 속출합니다.)

2. 대용량 서브쿼리에 대한 잘못된 NL_SJ / NL_AJ 선택

메인 쿼리 건수가 수십만 건인데 옵티마이저가 통계정보 오류로 이를 수십 건으로 오판하여 NESTED LOOPS SEMI를 선택한 경우, 수십만 번의 인덱스 랜덤 액세스가 발생하여 온라인 트랜잭션이 마비됩니다.

3. NOT IN 절에 NULL 허용 컬럼 사용

서브쿼리의 비교 대상 컬럼에 NOT NULL 제약조건이 빠진 상태에서 NOT IN을 구사하여, 옵티마이저가 ANTI NA (Null-Aware) 연산을 수행하느라 PGA Hash Area 탐색 비용이 증가하거나, 다중 컬럼 비교 시 Unnesting이 완전히 깨지는 현상입니다.

 

 

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

1. 실행 계획을 통한 서브쿼리 변환 상태 진단

문제가 되는 SQL의 실행 계획을 추출하여 SEMI, ANTI 키워드가 정상적으로 나타나는지, 아니면 FILTER로 묶여 있는지 점검합니다.

-- 실행 계획 및 Predicate Information 점검
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR('대상_SQL_ID', NULL, 'ALLSTATS LAST +PREDICATE'));

점검 포인트:

Operation 항목에 FILTER가 떠 있고, 그 하위 스텝의 Starts 항목이 메인 쿼리 건수만큼 수만~수십만 번 찍혀 있다면 즉시 Unnesting 유도 또는 Hash Semi/Anti Join으로 튜닝해야 합니다.

2. 힌트(Hint)를 활용한 Semi / Anti Join 제어 전략

옵티마이저가 잘못된 판단을 내릴 때, 서브쿼리 블록 내부에 직접 변환 힌트와 물리적 조인 힌트를 주입하여 실행 계획을 교정합니다.

-- [사례 1] 대용량 테이블 간 EXISTS 조인: Unnesting 후 Hash Semi Join 유도
SELECT /*+ LEADING(O) */ O.ORDER_ID, O.ORDER_DATE, O.CUST_ID
FROM   ORDERS O
WHERE  EXISTS (
       SELECT /*+ UNNEST HASH_SJ */ 1
       FROM   ORDER_ITEMS I
       WHERE  I.ORDER_ID = O.ORDER_ID
         AND  I.ITEM_STATUS = 'SHIPPED'
);

-- [사례 2] 서브쿼리 결과 집합이 극소량일 때: 서브쿼리를 먼저 드라이빙(Right Semi Join)
SELECT /*+ LEADING(I@SUBQ O) USE_NL(O) */ O.ORDER_ID, O.ORDER_DATE
FROM   ORDERS O
WHERE  O.ORDER_ID IN (
       SELECT /*+ QB_NAME(SUBQ) UNNEST */ I.ORDER_ID
       FROM   ORDER_ITEMS I
       WHERE  I.PROMO_CODE = 'VIP_SPECIAL_2026'
);

-- [사례 3] 메인 쿼리가 극소량이고 인덱스 효율이 좋을 때: 의도적으로 FILTER(NO_UNNEST) 유도
SELECT O.ORDER_ID, O.ORDER_DATE
FROM   ORDERS O
WHERE  O.ORDER_DATE = TRUNC(SYSDATE) -- 극소량(예: 5건)
  AND  EXISTS (
       SELECT /*+ NO_UNNEST */ 1
       FROM   HUGE_LOG_TABLE L
       WHERE  L.ORDER_ID = O.ORDER_ID
);

3. NOT IN을 NOT EXISTS로 표준화 (또는 NOT NULL 필터 명시)

안티 조인 요건에서는 정합성 오류와 실행 계획 저하를 동시에 막기 위해 NOT IN 대신 NOT EXISTS 사용을 개발 표준으로 강제하는 것이 가장 안전합니다. 불가피하게 NOT IN을 써야 한다면 서브쿼리에 IS NOT NULL 조건을 수동으로 추가해야 합니다.

-- [권장] NOT EXISTS를 활용한 깔끔한 Hash Anti Join 유도
SELECT D.DEPT_ID, D.DEPT_NAME
FROM   DEPARTMENTS D
WHERE  NOT EXISTS (
       SELECT /*+ UNNEST HASH_AJ */ 1
       FROM   EMPLOYEES E
       WHERE  E.DEPT_ID = D.DEPT_ID
);

-- [대안] NOT IN 사용 시 반드시 IS NOT NULL 조건 명시
SELECT D.DEPT_ID, D.DEPT_NAME
FROM   DEPARTMENTS D
WHERE  D.DEPT_ID NOT IN (
       SELECT /*+ UNNEST HASH_AJ */ E.DEPT_ID
       FROM   EMPLOYEES E
       WHERE  E.DEPT_ID IS NOT NULL -- Null-Aware 오버헤드 제거
);

핵심 요약 및 워크로드별 선택 기준

비교 구분 권장 구문 및 힌트 전략 내부 오퍼레이션 최적의 상황 (When to use)
소량 메인 + 인덱스 보유 서브 EXISTS + /*+ UNNEST NL_SJ */ (또는 NO_UNNEST) NESTED LOOPS SEMI / FILTER OLTP 환경에서 메인 쿼리 결과 건수가 적고 서브쿼리 조인 키 인덱스가 완벽할 때
대용량 메인 + 대용량 서브 EXISTS / IN + /*+ UNNEST HASH_SJ */ HASH JOIN SEMI (RIGHT SEMI) 배치/DW 환경에서 양쪽 테이블 모두 건수가 많아 Full Scan과 PGA 해시 결합이 유리할 때
대용량 메인 + 극소량 서브 IN + /*+ UNNEST */ + 서브쿼리 선행(LEADING) NESTED LOOPS (VW_NSO_1 드라이빙) 서브쿼리에서 걸러지는 건수가 극소량이어서, 서브쿼리를 먼저 읽고 메인 테이블을 인덱스로 찌를 때
제외 조건 (차집합 연산) NOT EXISTS + /*+ UNNEST HASH_AJ */ HASH JOIN ANTI / NL ANTI NULL 데이터로 인한 결과 증발(0건 반환) 버그를 예방하고 순수 Anti Join 성능을 확보할 때
  • 결론: 오라클 CBO 환경에서 IN과 EXISTS는 Subquery Unnesting을 통해 동일한 Semi Join으로 수렴합니다. 문법 자체의 우열을 따지기보다 실행 계획에서 FILTER로 풀렸는지 SEMI/ANTI JOIN으로 풀렸는지 확인하고, 데이터 분포도에 맞춰 NL_SJ/HASH_SJ 방향을 통제하는 것이 베테랑 DBA의 핵심 튜닝 포인트입니다.

 

+ Recent posts