만능 열쇠로 오해받는 스칼라 서브쿼리의 양면성
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)이 전달될 때 오라클 내부에서는 다음 과정을 거칩니다.
- Hash Bucket 탐색 (Cache Lookup): 입력된 E.DEPT_ID 값에 내부 해시 함수를 적용하여 UGA 내 해시 테이블의 버킷(Bucket)을 탐색합니다.
- Cache Hit 시: 해시 버킷에 동일한 입력 값과 매핑된 출력 값(D.DEPT_NAME)이 이미 존재한다면, 서브쿼리의 테이블이나 인덱스를 전혀 읽지 않고 즉시 메모리의 출력 값을 반환합니다. (논리적 I/O Buffers = 0)
- 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)를 배정받았다고 가정합니다.
- 충돌 현상:
- 첫 번째 로우에서 10이 들어오면 Cache Miss가 발생하여 블록 I/O를 수행한 뒤 Bucket #5에 [10 : 인사팀]을 기록합니다.
- 두 번째 로우에서 276이 들어오면 해시 값은 Bucket #5를 가리키지만, 실제 입력 값(10 vs 276)이 다르므로 Hash Collision(Cache Miss)으로 판정됩니다.
- 오라클은 서브쿼리를 다시 실행해 블록 I/O를 일으키고, Bucket #5의 내용을 [276 : 재무팀]으로 덮어씁니다(Overwrite).
- 세 번째 로우에서 다시 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 정렬 후 스칼라 호출
