Skip to content

08화 · 파고들기 ​

필요할 때 찾아보는 문서다. 처음 읽을 때는 1_사건.md만으로 충분하다.

찾는 것바로 가기
조인이 왜 행을 불리나 · 1:1 / 1:M / M:1:M§1 카디널리티
Q3를 고치는 여섯 가지: 정답과 Buffers§2 고치는 법
옵티마이저가 SQL을 고쳐 쓰는 순서 · 휴리스틱과 비용 기반§3 변환 먼저
Unnesting · View Merging · JPPD · GBP · 조인 제거 · OR-Expansion§4 도감
계획에서 변환 흔적 읽기 (+ALIAS · +OUTLINE · Note)§5 흔적
10053 트레이스: 켜기 · 꺼내기 · 찾을 줄§6 10053
NOT IN과 NULL§7 NOT IN
Q2의 스칼라는 왜 조인으로 안 바뀌었나§8 스칼라 Unnesting
100만 행을 읽어 3행: 요구를 그대로 두면 어디까지§9 요구
정합성 회귀 검증 SQL · MINUS의 맹점§10 회귀 검증
측정값 전체§11 측정
공식문서 좌표§12 좌표

1. 조인 카디널리티 — M:1:M은 곱하기다 ​

조인 결과의 행 수는 키마다 양쪽에 몇 줄씩 있는지를 곱해서 더한 값이다.

결과 행 수 = Σ (키 k 에 대해)  왼쪽의 k 행 수  ×  오른쪽의 k 행 수
관계키 하나에결과예
M:1왼쪽 여럿 × 오른쪽 하나왼쪽 행 수 그대로주문상세 ⋈ 상품마스터 (M_ITM의 PK) · 주문 ⋈ 고객 (Q2)
1:M왼쪽 하나 × 오른쪽 여럿오른쪽 행 수 그대로상품 ⋈ 주문상세
M:1:M가운데 하나를 사이에 두고 양쪽이 여럿양쪽 행 수의 곱주문상세 ⋈ 상품평가 (Q3)

M:1:M의 함정: 두 '많은 쪽'은 서로 아무 관계가 없다. 둘 다 가운데 있는 하나(상품)에 매달려 있을 뿐이다. 그 하나를 다리 삼아 조인하면 가능한 모든 짝이 생긴다.

실측 (실습/00 [C]) ​

행 수
T_ORD_DET (상품당 2,000줄 × 상품 100개)200,000
T_ITM_EVL (상품당 50줄 × 상품 100개)5,000
T_ORD_DET ⋈ T_ITM_EVL (ITM_ID)10,000,000 (= Σ 2,000 × 50)

그래서 SUM(금액)은 상품마다 평가 수만큼 곱해진다. 평가 수가 전부 50이면 금액도 전부 50배가 되고 순위는 바뀌지 않는다. Q3의 오류가 몇 달 동안 드러나지 않은 것도 이 때문이다.

곱하는 수가 다르면 순위가 바뀐다 (실습/00 [F]) ​

같은 모양으로 '12월에 달린 평가'만 붙이면 평가 수가 상품마다 3건, 4건, 5건으로 갈린다(각각 상품 20개, 70개, 10개).

순위조인 (곱해진 값)EXISTS (원장과 같음)
1ITM00041 2,197,346,000 (×5)ITM00071 444,332,400
2ITM00036 2,147,149,000 (×5)ITM00081 443,754,800
3ITM00071 1,777,329,600 (×4)ITM00056 441,123,900
4ITM00056 1,764,495,600 (×4)ITM00051 440,989,300
5ITM00051 1,763,957,200 (×4)ITM00066 439,486,400

ITM00041은 원장 6위, ITM00036은 원장 18위다. 원장 2위 ITM00081은 12월 평가가 3건이라 조인 쪽 TOP 5에서 빠진다.

📌 점검법 ​

조인마다 "상대 쪽이 이 키로 정말 한 줄인가"를 묻는다. PK나 UK로 확인하고, 실행계획의 A-Rows가 조인을 지나며 불어나는지도 확인한다. 그런데 Q3 rev00의 A-Rows는 옵티마이저 변환 때문에 불어난 것처럼 보이지 않았다(§3). 그래서 결과 대조(§10)를 실행계획 판독으로 대신할 수는 없다.


2. 고치는 법 여섯 — 정답과 Buffers 두 축 ​

원장은 T_ORD_DET만 상품별로 더한 것이다(평가 조인 없음). 대조는 TOP 5가 아니라 상품 100개 전체로 했다(실습/01 [C]).

#방법정답Buffers계획 해시행 수합계MINUS 양방향
0(rev00) 평가를 그대로 조인✗ 50배839~8411191226711001,513,627,885,000100 / 100
1SUM(DISTINCT 금액) + COUNT(DISTINCT 주문키)✗ 작고 순위가 바뀜839223186409010019,421,974,500100 / 100
2주문상세만 먼저 묶고 평가 원본에 조인✗ 같은 줄 50번8394662418765,0001,513,627,885,0000 / 0 ←
3EXISTS (rev01)○839223186409010030,272,557,7000 / 0
4IN 서브쿼리○839223186409010030,272,557,7000 / 0
5평가를 상품별로 묶어 한 줄로 만들고 조인○839373245994810030,272,557,7000 / 0
6평가 조인을 지운다○ (지금 데이터에서만)807203140516410030,272,557,7000 / 0
—원장10030,272,557,700

각 방법이 왜 그런가 ​

#무엇을 했나결과
1같은 값을 하나로 친다. 중복된 줄을 지우는 게 아니다ITM00071(원장 1위)의 주문상세 2,000줄 가운데 서로 다른 금액은 1,276개다. 1위가 ITM00006 286,527,900(원장 12위)이 되고, 원장 TOP 5 밖의 ITM00006과 ITM00021(원장 10위)이 들어온다. ITM00071은 5위, 279,878,000(원장의 0.63배)이다. 건수는 키로 세므로 2,000으로 맞는다. 그래서 더 위험하다
2합치는 순서만 바꿨다평가 쪽이 여전히 '많은 쪽'이라 상품 한 줄이 50줄이 된다. MINUS는 0 / 0으로 통과한다(§10)
3 · 4평가 쪽을 '있는가' 하나로 줄였다(세미 조인)주문상세 한 줄은 평가가 몇 건이든 한 번만 남는다. 옵티마이저가 둘 다 같은 실행계획으로 고쳐 쓴다(§4)
5평가 쪽을 상품당 한 줄로 만들었다(1:1 조인)정답이다. 평가 수(EVL_CNT 50)를 화면에 함께 보여 줄 수도 있다
6요구 조건('평가가 있는 상품만')을 지웠다지금은 상품 100개 모두에 평가가 있어서 같다. 평가 없는 상품이 생기면 갈린다 ↓

6번이 갈리는 날 (실습/01 [D] · 복사본) ​

복사본 T_ITM_EVL_C08에서 ITM00071의 평가 50건을 뺐다(4,950행, 상품 99개).

1위2위3위
6 · 조인 삭제ITM00071 444,332,400ITM00081ITM00056
3 · EXISTSITM00081 443,754,800ITM00056ITM00051

양방향 MINUS는 1 / 0이다. 02화의 BETWEEN(T_ORD_BIG에서는 통과, T_ORD에서는 137건 누락)과 같은 함정이다.

📌 이 표에서 가져갈 것 세 가지 ​

  1. Buffers로는 정답이 갈리지 않는다. 맞는 것 넷, 틀린 것 셋이 모두 807~841이다.
  2. 계획 해시가 같아도 답은 다를 수 있다. 1번(틀림)과 3번, 4번(맞음)의 계획 해시가 2231864090으로 같다. 계획 해시는 연산의 모양만 볼 뿐 SUM 안의 DISTINCT는 보지 않는다.
  3. "이 데이터에서 같다"가 "맞다"는 뜻은 아니다. 6번은 지금은 맞지만, 요구 조건이 실제로 무언가를 거르는 날 틀린다.

3. 변환이 먼저, 비용이 나중 ​

옵티마이저는 SQL을 받으면 먼저 고쳐 쓰고(쿼리 변환), 그렇게 고쳐 쓴 문장에 비용을 매긴다(비용 계산).

SQL 텍스트
   │  파싱
   ▼
쿼리 블록 트리 (SEL$1, SEL$2 …)
   │  ① 쿼리 변환 ─ 휴리스틱: 조건만 맞으면 한다
   │                 비용 기반(CBQT): 바꾼 모양과 안 바꾼 모양에 각각 값을 매겨 고른다
   ▼
고쳐 쓴 쿼리 블록 (SEL$5DA710D3, VW_GBC_7 …)
   │  ② 비용 계산 ─ 접근 경로 · 조인 순서 · 조인 방식
   ▼
실행계획

모든 변환이 지키는 규칙은 하나다. 결과를 바꾸지 않는다. 원래 SQL의 뜻이 틀렸으면 고쳐 쓴 SQL도 정확히 같은 방식으로 틀린다.

실측 — Q3 rev00은 이미 '먼저 집계'로 고쳐 쓰여 있었다 (실습/02 [B]) ​

계획해시 조인 A-RowsA-TimeBuffers결과 (1위)
기본 (변환 적용)VW_GBF_8(평가 상품별 건수) ⋈ VW_GBC_7(주문상세 상품별 합계)1000.01초83922,216,620,000
NO_PLACE_GROUP_BY원래 모양 그대로 조인 후 집계10,000,0000.33~0.35초839~84022,216,620,000

10053 트레이스의 Final query after transformations에 고쳐 쓴 SQL이 있다(발췌).

sql
SELECT "VW_GBC_7"."ITEM_1" "ITM_ID",
       "VW_GBC_7"."ITEM_3" * "VW_GBF_8"."ITEM_3" "AMT"          -- 상품별 합계 × 상품별 평가 건수
  FROM (SELECT "E"."ITM_ID" "ITEM_1", COUNT(*) "ITEM_3" FROM "T_ITM_EVL" "E" GROUP BY "E"."ITM_ID") "VW_GBF_8",
       (SELECT "D"."ITM_ID" "ITEM_1", SUM("D"."ORD_QTY"*"D"."UNT_PRC") "ITEM_3" FROM "T_ORD_DET" "D" GROUP BY "D"."ITM_ID") "VW_GBC_7"
 WHERE "VW_GBF_8"."ITEM_1" = "VW_GBC_7"."ITEM_1"

곱하기가 SQL로 적혀 있다. 1,000만 행을 만들지 않고도 원래 SQL의 뜻(평가 한 건마다 한 번씩 더하기)을 정확히 지킨다.

휴리스틱과 비용 기반 — 10053으로 가려낸다 (실습/90) ​

변환트레이스 문장성격
EXISTS → 세미 조인 (Q3 rev01)SU: Performing unnesting that does not require costing. · SU: Transforming EXISTS subquery to a join.휴리스틱: 비용을 매기지 않는다
Group-by Placement (Q3 rev00)GBP: Starting iteration 1 … 4와 각 상태의 Cost비용 기반 (CBQT): 네 가지 모양에 비용을 매겨 고른다

GBP가 비용을 매긴 네 가지 모양은 다음과 같다(state space = (2,3)에서 2는 평가 쪽, 3은 주문상세 쪽이다. 0은 옮기지 않음, F/C는 그쪽에 집계를 놓음).

iteration상태Cost
1(0, 0) 원래 모양760.914613최선 갱신
2(0, C) 주문상세 쪽만 먼저 집계243.190352최선 갱신
3(F, 0) 평가 쪽만 먼저 집계247.366202갱신 안 함
4(F, C) 양쪽 다243.113503최종 채택

이 버전의 변환 설정(SYSDBA로 X$KSPPI 조회): _optimizer_cost_based_transformation = LINEAR · _optimizer_group_by_placement = TRUE · _optimizer_unnest_scalar_sq = TRUE · _complex_view_merging = TRUE · _push_join_predicate = TRUE · _optimizer_join_elimination_enabled = TRUE · _optimizer_null_aware_antijoin = TRUE · _or_expand_nvl_predicate = TRUE · optimizer_features_enable = 23.1.0. 밑줄 파라미터는 읽기만 한다. 운영에서 함부로 돌릴 손잡이가 아니다.


4. 변환 도감 — 이름표와 막는 힌트와 실측 ​

변환무엇을 하나계획에 남는 이름표Outline막는 힌트08화 실측
서브쿼리 UnnestingEXISTS · IN을 조인(세미/안티)으로 펼친다HASH JOIN (RIGHT) SEMI · ANTI · 쿼리 블록 이름이 합쳐짐UNNEST(@"SEL$2")NO_UNNESTQ3 rev01 839 · 막으면 FILTER 11,001
스칼라 서브쿼리 UnnestingSELECT 목록의 스칼라를 외부조인으로VW_SSQ_ (보통 곧 합쳐짐) · HASH JOIN … OUTERUNNESTNO_UNNEST07화 고객명 + MAX() 779,832 → 5,856 · Q2는 안 됨(§8)
단순 View Merging조인·필터만 있는 인라인 뷰를 바깥과 합친다뷰 줄이 사라짐MERGE(@"SEL$2" >"SEL$1")NO_MERGE07화 고객명 스칼라 문장의 인라인 뷰
복합 View MergingGROUP BY가 있는 뷰를 바깥과 합쳐 조인 뒤에 집계VM_NWVW_MERGENO_MERGE아래 [JPPD 표] 931
JPPD (조인 조건 밀어 넣기)바깥의 조인 조건을 뷰 안으로 밀어 넣어 뷰를 행마다 연다VIEW PUSHED PREDICATEPUSH_PREDNO_PUSH_PRED아래 [JPPD 표] 930
Group-by Placement집계를 조인 앞(또는 뒤)으로 옮긴다VW_GBC_ · VW_GBF_PLACE_GROUP_BYNO_PLACE_GROUP_BYQ3 rev00 (§3)
Partial JoinDISTINCT 집계만 있으면 조인을 '하나 찾으면 멈춤'으로HASH JOIN (RIGHT) SEMIPARTIAL_JOINNO_PARTIAL_JOIN§2의 1번: 틀린 안이 맞는 안과 같은 계획이 된 이유
조인 제거행 수를 안 바꾸는 조인을 지운다 (PK + FK)테이블 줄이 사라짐ELIMINATE_JOINNO_ELIMINATE_JOIN아래 [조인 제거 표] 806 → 802
OR-ExpansionOR을 UNION ALL 갈래로VW_ORE_ · UNION-ALL · 갈래마다 FILTEROR_EXPANDNO_OR_EXPAND아래 [OR 표] 12 vs 1,205
Null-Aware 안티 조인NULL이 있어도 NOT IN의 뜻을 지키는 안티 조인HASH JOIN ANTI NA——§7: 0명 대 8명

서브쿼리 Unnesting을 막으면 — 07화의 메모판이 돌아온다 (실습/02 [A]) ​

EXISTS (SELECT /*+ NO_UNNEST */ …)로 펼치기를 막으면 FILTER가 주문상세 한 줄마다 서브쿼리를 연다. 다만 07화의 스칼라 서브쿼리 캐시와 같은 메모판이 붙는다.

PK_T_ITM_EVL StartsBuffers
Unnesting (기본)1 (INDEX FAST FULL SCAN)839
NO_UNNEST → FILTER5,097 (INDEX RANGE SCAN)11,001

입력 값은 ITM_ID 100종류이고 각각 2,000번씩 나온다. 그런데 Starts는 07화의 식(자리 얻은 값 × 1 + 못 얻은 값 × 2,000)으로는 정수로 맞아떨어지지 않는다.

들어오는 순서를 봐야 한다. T_ORD_DET는 주문번호 순으로 쌓여 있어서, 바로 앞 줄과 같은 상품이 연달아 나오는 경우가 있다. 전체 스캔 순서(ROWID 순)로 '바로 앞 줄과 상품이 다를 때'만 세면, 상품마다 2,000줄 가운데 1,666번(상품 33개) 또는 1,667번(상품 67개)이 '새로' 들어온다. FILTER는 바로 앞 값과 같으면 서브쿼리를 다시 열지 않는다. 이 횟수를 식에 넣으면 정확히 맞는다.

5,097 = 97 × 1  +  (1,666 + 1,667 + 1,667)
        └ 메모판에 오른 97 종류 ┘   └ 자리를 못 얻은 3 종류: 값이 바뀌어 들어올 때마다 다시 연다 ┘

100종류 가운데 3개가 메모판에 자리를 얻지 못했다. 캐싱 효과는 입력 NDV에 달려 있고(07화에서 본 것: NDV 2,000 → Starts 752,995), 입력이 들어오는 순서에도 달려 있다. 같은 데이터라도 적재 순서가 다르면 이 숫자는 달라진다.

(실측으로 확인한 것은 이 분해가 식에 정확히 맞는다는 데까지다. 어느 3종류가 자리를 못 얻었는지는 직접 보이지 않는다.)

JPPD 표 — 같은 SQL, 세 모양 (실습/02 [C]) ​

sql
SELECT COUNT(*), SUM(d.amt)
  FROM T_ORD o,
       (SELECT ORD_SEQ, SUM(ORD_QTY * UNT_PRC) AS amt FROM T_ORD_DET GROUP BY ORD_SEQ) d
 WHERE d.ORD_SEQ = o.ORD_SEQ AND o.CUS_ID = 'CUS_000123';
-- 결과 50 건 · 13,983,900
힌트옵티마이저가 한 것계획Buffers
없음복합 View Merging: 뷰를 풀고 조인 뒤에 집계VIEW VM_NWVW_1 · NL931
NO_MERGE(d)JPPD: o.ORD_SEQ를 뷰 안으로 밀어 넣어 주문마다 뷰를 연다VIEW PUSHED PREDICATE (Starts 50)930
NO_MERGE(d) NO_PUSH_PRED(d)뷰를 통째로 만든 뒤 해시 조인 (블룸 필터)HASH GROUP BY 100K 행1,635

두 변환은 서로 대신할 수 있다. 옵티마이저는 비용을 따져 하나를 골랐고, 이 데이터에서 둘의 차이는 1블록이다.

조인 제거 표 — 복사본 두 개 (실습/02 [D]) ​

복사본 M_ITM_C08(PK 있음)과 T_ORD_DET_C08의 조인이다. 상품 쪽 컬럼은 하나도 쓰지 않는다.

조건계획Buffers
FK 없음상품마다 PK_M_ITM_C08을 찾아간다(VW_GBC_6 · NL)806
FK 있음ELIMINATE_JOIN: 상품 테이블이 실행계획에서 사라진다802
FK 있음 + 상품명(ITM_NM) 사용조인이 남는다(해시 조인)804

조인을 지우려면 "상대 쪽에 반드시 있다(FK)"와 "딱 하나만 있다(PK)"가 둘 다 증명돼야 한다. 행 수가 바뀌지 않는다는 증명이다. Q3의 평가 조인은 행 수를 50배로 바꾸므로 지울 수 없다. 지울 수 있는지 묻는 것과 행을 불리는지 묻는 것은 같은 질문이다. 한빛유통 신 시스템에는 FK가 0개다(01화에서 확인했다). 그래서 원본에서는 이 변환이 일어날 수 없다.

OR 표 — '고객번호를 안 주면 전부, 주면 그 고객만' (실습/02 [F] · 구 시스템 ORDERS) ​

인덱스는 ORDERS_X_CUST(cust_id)이고, 바인드 값은 :c = 123이다(결과 10건, 4,595,320).

조건계획Buffers
cust_id = :c (비교 기준)INDEX RANGE SCAN12
cust_id = NVL(:c, cust_id)VW_ORE_ · UNION-ALL: :C IS NOT NULL 갈래는 인덱스, :C IS NULL 갈래는 전체 스캔(Starts 0)12~13
(:c IS NULL OR cust_id = :c)VW_ORE_ (LNNVL(:C IS NULL) 갈래)12
cust_id = DECODE(:c, NULL, cust_id, :c)VW_ORE_12
NVL(cust_id, -1) = NVL(:c, NVL(cust_id, -1))TABLE ACCESS FULL: 컬럼을 감쌌다1,205
(참고) cust_id = 123 OR order_date = DATE '2024-06-01'OR-Expansion이 아니라 BITMAP OR(인덱스 둘을 비트맵으로 합침)288~289

📌 'NVL/DECODE가 OR-Expansion을 막는다'는 반쪽짜리 이야기다 ​

바인드 쪽을 감싼 NVL이나 DECODE는 이 버전에서 그대로 펼쳐진다(_or_expand_nvl_predicate = TRUE). 막히는 것은 컬럼 쪽을 감쌌을 때다. 02화의 '컬럼 가공'과 같은 원리다. 그리고 펼친 실행계획을 보면 cust_id = NVL(:c, cust_id)의 전체 스캔 갈래에 filter("CUST_ID" IS NOT NULL)이 붙어 있다. 원래 조건에는 cust_id가 NULL인 행을 버린다는 뜻이 들어 있으므로(NULL = NULL은 참이 아니다) 변환도 그 뜻을 지킨다. 컬럼을 감싼 마지막 줄은 'NULL 고객도 포함'이라는 다른 뜻이다. ORDERS.CUST_ID에 NULL이 0건이라 지금은 답이 같을 뿐이다. 바인드 변수 값에 따라 실행계획이 어떻게 갈리는지는 11화에서 다룬다.


5. 계획에서 변환 흔적 읽기 — +ALIAS · +OUTLINE · Note ​

sql
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(FORMAT => 'ALLSTATS LAST +ALIAS +OUTLINE'));
어디를 보나무엇이 보이나Q3 rev00의 예
Name 열의 VW_ · VM_ 이름옵티마이저가 만든 뷰. 내가 쓰지 않은 뷰가 보이면 변환이 있었다는 뜻이다VW_GBF_8 · VW_GBC_7
Query Block Name / Object Alias각 줄이 어느 쿼리 블록에서 왔는지 보인다. 원래 이름은 SEL$1, SEL$2 …이고, 변환으로 합쳐지거나 새로 생긴 블록에는 해시 같은 이름이 붙는다"E"@"SEL$1"이 SEL$39F5B911 안에 있다
Outline Data이 실행계획을 다시 만들기 위한 지시문 전부. 곧 무엇을 고쳐 썼는지 적은 기록이다PLACE_GROUP_BY(@"SEL$1" ( "E"@"SEL$1" ) ( "D"@"SEL$1" ) 7)
Predicate Information변환이 붙인 조건OR-Expansion의 :C IS NULL · LNNVL(…) · "CUST_ID" IS NOT NULL
Note변환은 여기에 나오지 않는다. 실행 환경에 관한 사실만 나온다이 화에서 찍힌 것은 this is an adaptive plan 한 줄뿐이다(§9 커버링 인덱스 실험)

오퍼레이션 역추적: 이름표가 하나 보이면 거꾸로 따라간다. VW_GBC_7 → Outline에서 PLACE_GROUP_BY를 찾는다 → 어느 별칭("D"@"SEL$1")의 집계를 옮겼나 → 원래 SQL의 어느 테이블인가. HASH JOIN RIGHT SEMI → 원래 SQL에는 조인이 없었다 → UNNEST(@"SEL$2") → 서브쿼리 SEL$2가 펼쳐졌다.


6. 10053 트레이스 — 변환 로그 읽기 ​

옵티마이저가 하드 파싱하면서 "이 변환은 해 볼까 / 이건 안 돼" 하고 적어 둔 혼잣말이다. SYSDBA 없이 일반 계정으로 켤 수 있다(ALTER SESSION 권한).

켜고 끄기 ​

sql
-- 파일 이름에 붙는다
ALTER SESSION SET TRACEFILE_IDENTIFIER = 'CH08_9A';
ALTER SESSION SET EVENTS '10053 trace name context forever, level 1';
SELECT /* 처음 보는 문장이어야 한다 */ … ;
ALTER SESSION SET EVENTS '10053 trace name context off';
SELECT value FROM v$diag_info WHERE name = 'Default Trace File';

⚠️ 10053은 하드 파싱할 때만 기록된다. 같은 문장이 공유 풀에 있으면 트레이스가 비어 있다. 그래서 실습/90은 문장 주석에 실행 시각(&run_ts)을 넣어 매번 새 문장으로 만든다.

꺼내기 — 파일은 컨테이너 안에 있다 ​

bash
# Git Bash — MSYS_NO_PATHCONV=1 이 없으면 /opt/... 경로가 C:/Program Files/Git/opt/... 로 바뀐다
MSYS_NO_PATHCONV=1 docker exec ora-hanorder sh -c 'grep -n "^SU:" /opt/oracle/diag/rdbms/free/FREE/trace/FREE_ora_NNN_CH08_9A.trc'
MSYS_NO_PATHCONV=1 docker cp ora-hanorder:/opt/oracle/diag/rdbms/free/FREE/trace/FREE_ora_NNN_CH08_9A.trc .
powershell
# PowerShell — 경로 변환이 없다
docker exec ora-hanorder sh -c 'grep -n "^SU:" /opt/oracle/diag/rdbms/free/FREE/trace/FREE_ora_NNN_CH08_9A.trc'
docker cp "ora-hanorder:/opt/oracle/diag/rdbms/free/FREE/trace/FREE_ora_NNN_CH08_9A.trc" .
Select-String -Path .\FREE_ora_NNN_CH08_9A.trc -Pattern '^SU:'

NNN은 서버 프로세스 번호다. 실습/90이 찍어 주는 경로를 그대로 쓰면 된다.

약어 (트레이스 앞부분 Legend에서 발췌) ​

약어뜻약어뜻
CBQTcost-based query transformationSUsubquery unnesting
CVMcomplex view mergingJEjoin elimination
JPPDjoin predicate push-downORECBQT OR-Expansion
FPDfilter push-downDPdistinct placement
OBYEorder by eliminationCNTcount(col) to count(*)
SJCset join conversionSLPselect list pruning

GBP(Group-by Placement)는 Legend에 없지만 GBP:로 시작하는 줄로 나온다.

이 화에서 찾은 줄 (실습/90) ​

파일찾을 줄뜻
9A Q2 rev01SU: bypassed: Scalar subquery may return more than one row.스칼라를 조인으로 바꾸려면 '한 행'이 증명돼야 하는데 증명되지 않았다
9A Q2 rev01GBP: Bypassed: Unsupported argument to aggregate function.MAX() 안에 스칼라 서브쿼리가 있어서 집계를 옮기지 못한다
9B Q2 + MAX()SU: bypassed: Outer qurery has illegal group-by.(철자는 원문 그대로) 바깥 쿼리의 GROUP BY 때문에 안 된다
9C 07화 고객명 + MAX()SU: Unnesting scalar subquery query block SEL$3 (#2) · SU: Transforming subquery block into inline view.펼쳤다. 이때 생긴 뷰가 VW_SSQ_1이다
9D Q3 rev00GBP: Starting iteration 1 … 4 · Updated best state, Cost = … · Final query after transformations네 가지 모양에 비용을 매겼다(§3 표) · 고쳐 쓴 SQL 전문
9E Q3 rev01SU: Performing unnesting that does not require costing. · SU: Transforming EXISTS subquery to a join.비용을 매기지 않고 펼쳤다
모든 파일JE:[V2]: Cannot eliminate … - no constr. based join pred(s)조인 제거를 시도했지만 제약(FK)이 없어 못 했다

7. NOT IN과 NULL — Null-Aware 안티 조인 ​

sql
-- '아무의 관리자도 아닌 사원' (구 시스템 EMP · 14 명 · KING 의 MGR 이 NULL)
-- 0
SELECT COUNT(*) FROM EMP e WHERE e.EMPNO NOT IN (SELECT m.MGR FROM EMP m);
-- 8
SELECT COUNT(*) FROM EMP e WHERE NOT EXISTS (SELECT 1 FROM EMP m WHERE m.MGR = e.EMPNO);
-- 8
SELECT COUNT(*) FROM EMP e WHERE e.EMPNO NOT IN (SELECT m.MGR FROM EMP m WHERE m.MGR IS NOT NULL);
문장결과계획 (실습/02 [E])
NOT IN0HASH JOIN ANTI NA (3~7블록. 14행짜리 테이블이라 설치마다 몇 블록씩 다르다)
NOT EXISTS8HASH JOIN ANTI (7블록 안팎) · 서브쿼리 쪽에 filter("M"."MGR" IS NOT NULL)
NOT IN … WHERE MGR IS NOT NULL8—

왜 0인가. x NOT IN (a, b, NULL)은 x <> a AND x <> b AND x <> NULL과 같다. 마지막 항은 참도 거짓도 아닌 '모름'이고, AND에 '모름'이 하나라도 끼면 전체가 참이 될 수 없다.

옵티마이저는 NOT IN을 NOT EXISTS로 바꾸지 않는다. NULL이 있으면 뜻이 달라지기 때문이다. 대신 NULL을 고려하는 안티 조인(NA = Null-Aware)으로 NOT IN의 뜻, 곧 0명이라는 답을 지킨다.

한빛유통 신 시스템의 컬럼에는 NULL이 0건이다(USER_TAB_COLUMNS.NUM_NULLS). 그래서 이 함정은 구 시스템 EMP에서 원본을 건드리지 않고 재현했다. 신 시스템에서도 '지금 NULL이 없어서 같을' 뿐이다. 체크리스트 E 항목의 경계 목록에 NULL이 들어 있는 것도 그래서다.


8. 스칼라 서브쿼리 Unnesting — Q2의 두 자물쇠 ​

07화가 남긴 질문은 "오라클은 왜 M_RGN 스칼라를 조인으로 안 바꿨나"였다. 이 버전에는 스칼라 서브쿼리 Unnesting이 있다(_optimizer_unnest_scalar_sq = TRUE). 그런데 Q2에서는 일어나지 않았다(실습/03 [A][B] · 실습/90).

문장펼쳐졌나Buffers10053
Q2 rev01✗ (M_RGN Starts 8)5,868~5,869may return more than one row
Q2 rev01 + /*+ UNNEST */✗ (계획 해시 2292855265 그대로)5,868—
Q2 + 스칼라 안을 MAX()로✗ (계획 해시 1928341286)5,868~5,869Outer qurery has illegal group-by
07화 고객명 스칼라✗ (M_CUS Starts 752,995)779,832~779,833may return more than one row
07화 고객명 + MAX()○ HASH JOIN RIGHT OUTER · UNNEST · ELIM_GROUPBY · MERGE5,856Unnesting scalar subquery

자물쇠 1: '한 행'의 증명. M_RGN.RGN_ID는 PK(VALIDATED)인데도 이 버전의 트레이스에는 may return more than one row라고 적혀 있었다. 서브쿼리를 MAX()로 감싸 '반드시 한 행'을 문법으로 보장하자 07화의 문장은 통과했다. 자물쇠 2: 바깥의 GROUP BY. Q2는 스칼라가 GROUP BY 쿼리의 MAX() 안에 있다. MAX()로 자물쇠 1을 풀어도 illegal group-by로 막혔다.

⚠️ 이 표는 이 버전(23.26.2 · optimizer_features_enable 23.1.0)에서 잰 것이다. 'PK가 있어도 비집계 스칼라는 펼치지 않는다'를 모든 버전의 규칙으로 일반화하면 안 된다. 가져갈 것은 확인하는 방법(트레이스의 SU: 줄)이다.

왜 증명을 고집하나 — 바꿨는데 두 행이 나오면 (실습/03 [C2] · 복사본) ​

PK 없는 지역 코드 복사본 M_RGN_C08에 같은 8행을 한 번 더 넣었다(16행, RGN_ID 8종류).

문장등급 A등급 B등급 C
(원래 값)10,000 / 2,564,322,10090,000 / 22,948,657,800900,000 / 229,885,643,800
외부조인20,000 / 5,128,644,200180,000 / 45,897,315,6001,800,000 / 459,771,287,600
스칼라 (rev01)ORA-01427: single-row subquery returns more than one row
먼저 묶고 붙이기 (§9, 스칼라 유지)ORA-01427

스칼라는 '한 행'이 깨지면 멈춘다. 조인은 조용히 곱한다. 옵티마이저가 증명 없이 바꾸면 에러가 나야 할 자리에서 두 배짜리 답이 나온다. 뜻이 바뀌는 것이다.

반대쪽 경계 — 1쪽에 없는 키 (실습/03 [C1] · 복사본) ​

M_CUS_C08에서 마지막 고객 20명(A 1명, B 1명, C 18명)의 지역을 지역 코드에 없는 RGN09로 바꿨다.

등급스칼라외부조인내부조인
A10,000 / 2,564,322,10010,000 / 2,564,322,1009,500 / 2,439,050,900
B90,000 / 22,948,657,80090,000 / 22,948,657,80089,500 / 22,823,283,100
C900,000 / 229,885,643,800900,000 / 229,885,643,800891,000 / 227,598,402,100

양방향 MINUS는 스칼라와 외부조인 사이가 0 / 0, 스칼라와 내부조인 사이가 3 / 3이다. 내부조인만 주문 10,000건(= 20명 × 500건)을 지운다.

Q2를 손으로 바꿀까 — 판정 ​

안Buffers같은 답인가판정
스칼라 → 내부조인—✗ 고아 키가 있으면 주문이 사라진다기각
스칼라 → 외부조인5,869 → 5,862 (07화)PK가 있는 동안만 ○기각: T0로는 7블록. 게다가 '틀리면 멈춤'을 '틀리면 조용히 곱함'으로 바꾼다
rev01 유지5,869—채택

07화에서는 잴 게 없어서 Q2를 고치지 않았다. 08화에서는 바꾸면 잃는 것이 있어서 고치지 않는다.


9. 100만 행을 읽어 3행 — 요구를 그대로 두면 어디까지 ​

06화가 남긴 질문이다. 요구는 '전체 기간, 지금 이 순간의 등급별 매출'이다. 그러려면 주문 100만 건의 금액을 적어도 한 번씩은 읽어야 한다. 줄일 수 있는 것은 읽는 블록과 행마다 하는 일 둘뿐이다.

요구를 그대로 두고 (실습/03 [D][E] · 예열 뒤 10회 · CPU 1/100초) ​

문장Buffers / 회CPU (10회)같은 답판정
rev01 (스칼라 그대로)5,868114~118—현행
스칼라 → 외부조인5,86256~61PK가 있는 동안기각 (§8)
주문을 고객별로 먼저 묶고 붙인다 (스칼라 유지)5,86830~34○ MINUS 0 / 0 · 코드 중복 시 ORA-01427도 그대로보류안
(바닥) T_ORD_BIG을 한 번 읽어 더하기만5,82621~24—참고
(참고) 고객별로 묶기만5,82630~34—참고
커버링 인덱스 (CUS_ID, ORD_AMT) + rev013,689 (INDEX FAST FULL SCAN · 리프 3,624)—○기각: T_ORD_BIG 인덱스가 셋이 된다(적재 비용 → 14화)

'먼저 묶고 붙이기'의 실행계획을 보면 해시 조인에서 나오는 행이 1,000,000 → 2,000으로 준다. M_RGN의 Starts는 8 그대로다. 오라클이 Q3에서는 스스로 한 Group-by Placement를 Q2에서는 스칼라가 막았는데(GBP: Bypassed: Unsupported argument to aggregate function.), 그 변환을 손으로 한 것이다.

왜 리비전이 아닌가? T0로는 Buffers 5,869 → 5,868이다. 게다가 이 SQL이 지금 CPU를 태운다는 증거가 없다(07화에서 rev01을 적용한 뒤 운영 CPU 그래프는 내려갔다). 재검토 조건: 16화에서 시스템 전체를 잴 때 이 SQL이 CPU 상위에 오르면 rev02로 올린다.

요구를 바꿔야 하는 것 ​

100만 건을 안 읽으려면바뀌는 약속누가 정하나
미리 더해 둔 숫자를 읽는다 (마감 집계표 · 실체화 뷰)'지금 이 순간' → '마지막 마감 시점'화면 주인 (정산팀)
기간을 좁힌다'전체 기간' → '최근 N개월'화면 주인

둘 다 튜너가 혼자 정할 수 없다. 그 이야기를 어떻게 꺼내고 문서로 남기는지는 09화에서 다룬다. 같은 3행을 실행할 때마다 다시 계산하지 않는 방법(결과 재사용)은 요구를 바꾸지 않고도 쓸 수 있다. 대신 다른 대가가 따른다(15b화).


10. 정합성 회귀 검증 — 건수 · 합계 · MINUS 양방향 ​

sql
WITH a AS ( /* 새 리비전 - ORDER BY · FETCH 를 뗀다 */ ),
     b AS ( /* 원장 또는 직전 리비전 */ )
SELECT (SELECT COUNT(*) FROM a) a_rows, (SELECT COUNT(*) FROM b) b_rows,
       (SELECT SUM(amt) FROM a) a_sum,  (SELECT SUM(amt) FROM b) b_sum,
       (SELECT COUNT(*) FROM (SELECT * FROM a MINUS SELECT * FROM b)) a_minus_b,
       (SELECT COUNT(*) FROM (SELECT * FROM b MINUS SELECT * FROM a)) b_minus_a
  FROM dual;

MINUS의 맹점 — 중복을 못 본다 (실습/01 [C]의 2번) ​

행 수합계MINUS 양방향
주문상세만 먼저 묶고 평가 원본에 조인5,0001,513,627,885,0000 / 0
원장10030,272,557,700

MINUS는 집합을 비교한다. 같은 줄이 50번 있어도 한 줄로 친다. 그래서 건수와 합계를 함께 본다. 셋이 한꺼번에 속는 경우는 드물다.

무엇과 대조하나 ​

기준언제
직전 리비전성능만 고쳤을 때. "답이 안 바뀌었다"는 증명이 된다
원장 (업무 정의를 가장 단순하게 쓴 SQL)직전 리비전이 맞는지 모를 때. Q3처럼 처음부터 틀렸을 수 있다

양식 전체와 경계 목록 → 40_보고서와_표준/템플릿/정합성_회귀_체크리스트.md


11. 이 화의 측정값 전부 ​

모두 ora-hanorder에서 잰 값이다. 환경: Oracle AI Database 26ai Free 23.26.2.0.0 · optimizer_features_enable 23.1.0 · cpu_count 2 · 히스토그램 없음(FOR ALL COLUMNS SIZE 1). 데이터: T_ORD_DET 200,000 / T_ITM_EVL 5,000 / M_ITM 100 / T_ORD_BIG 1,000,000 / M_CUS 2,000 / M_RGN 8 · 데이터 지문 SUM(T_ORD_BIG.ORD_AMT) = 255,398,623,700 · SUM(T_ORD_DET.ORD_QTY*UNT_PRC) = 30,272,557,700. 스크립트마다 두 번 이상 돌려 같은 값(또는 아래 적은 폭 안의 값)이 나오는지 확인했다.

Q3 (실습/00 · 실습/01) ​

항목값
rev00 Buffers839~841 (T_ORD_DET 807 · PK_T_ITM_EVL 32) · 계획 해시 119122671 (01화 W01과 같음)
rev00 TOP 5ITM00071 22,216,620,000 · ITM00081 22,187,740,000 · ITM00056 22,056,195,000 · ITM00051 22,049,465,000 · ITM00066 21,974,320,000 · CNT 각 100,000
원장 TOP 5ITM00071 444,332,400 · ITM00081 443,754,800 · ITM00056 441,123,900 · ITM00051 440,989,300 · ITM00066 439,486,400 · CNT 각 2,000
배율상품 100개 모두 50
TOP 5 합계화면 110,484,340,000 · 원장 2,209,686,800 · 회사 전체 30,272,557,700 (3.6배)
rev00 전체 합계1,513,627,885,000 (= 50 × 30,272,557,700)
평가 분포상품 100개 × 50건 = 5,000 · 조인 행 10,000,000
그때 재현 (복사본, 상품당 평가 1건)100행 · MINUS 0 / 0 · 합계 30,272,557,700
12월 평가3건 20개 · 4건 70개 · 5건 10개 상품 · 조인 1위 ITM00041 2,197,346,000 (원장 6위) · 2위 ITM00036 2,147,149,000 (원장 18위)
고치는 법 여섯→ §2 표
SUM(DISTINCT)의 원인ITM00071의 2,000줄 중 서로 다른 금액 1,276개 · 1위 ITM00006 286,527,900 · 전체 합계 19,421,974,500
rev01 Buffers839 · 계획 해시 2231864090 · HASH JOIN RIGHT SEMI

변환 (실습/02 · 실습/90) ​

항목값
GBP 끄기 (NO_PLACE_GROUP_BY)해시 조인 A-Rows 10M · 0.33~0.35초 · 839~840 · 계획 해시 2914012978
GBP 비용 (10053)760.914613 / 243.190352 / 247.366202 / 243.113503
NO_UNNEST → FILTERPK_T_ITM_EVL Starts 5,097 · Buffers 11,001 · 상품별 '새로 들어온' 횟수 1,666 (33개) / 1,667 (67개)
JPPD 표기본(CVM) 931 · NO_MERGE(JPPD) 930 · 둘 다 막음 1,635 · 결과 50건 / 13,983,900
조인 제거 (복사본)FK 없음 806 · FK 있음 802 · FK + 상품명 804 · 복사본 T_ORD_DET_C08 820블록
NOT IN / NOT EXISTS0 / 8 (HASH JOIN ANTI NA · HASH JOIN ANTI, 둘 다 10블록 미만이고 설치마다 몇 블록씩 다르다)
OR-ExpansionNVL 12~13 · IS NULL OR 12 · DECODE 12 · 컬럼 NVL 1,205 · 비교 기준 12 · 두 컬럼 OR BITMAP OR 288~289 (284건 · 133,944,110)

Q2 (실습/03 · 실습/90) ​

항목값
rev015,868~5,869 · 계획 해시 2292855265 · M_RGN Starts 8 · 12블록
/*+ UNNEST */계획 해시 2292855265 그대로
Q2 + MAX()5,868~5,869 · 계획 해시 1928341286 · 스칼라 그대로
07화 고객명 / + MAX()779,832~779,833 (Starts 752,995) / 5,856 (계획 해시 3152752075)
코드 중복 복사본외부조인 A 20,000 / 5,128,644,200 · B 180,000 / 45,897,315,600 · C 1,800,000 / 459,771,287,600 · 스칼라 ORA-01427
고아 키 복사본내부조인 A 9,500 / 2,439,050,900 · B 89,500 / 22,823,283,100 · C 891,000 / 227,598,402,100 · MINUS 0/0 (외부) · 3/3 (내부)
CPU 10회rev01 114~118 · 외부조인 56~61 · 먼저 묶기 30~34 · 바닥 21~24 · 묶기만 30~34
커버링 인덱스리프 3,624 · BLEVEL 2 · Q2 3,689 (INDEX FAST FULL SCAN 3,647) · 만드는 데 1.81초
소급 대조Q1 rev00 vs rev02: 27,390 / 6,982,065,200 · 0 / 0
Q2 rev00 vs rev01: 3행 / 255,398,623,700 · 0 / 0

12. 원천 좌표 ​

전체 지도 → 00_자료준비/06_원서와_공식문서_지도.md PDF 페이지 = 책 페이지 − 2 · 성능고도화II_목차색인.md (공개 사이트에는 없음)

성능고도화 II권 Ch4 「쿼리 변환」 (주 원천) ​

주제절책 페이지
쿼리 변환이란 · 휴리스틱과 비용 기반01462
서브쿼리 Unnesting · 의미 · 이점 · 기본 예시02 (1)~(4)466~469
Unnesting된 쿼리의 조인 순서 · Nonunique 인덱스일 때02 (5)(6)472 · 473
필터 오퍼레이션과 세미조인의 캐싱 효과02 (7)477
Anti 조인02 (8)480
뷰 Merging · 단순 · 복합 · 비용기반 쿼리 변환의 필요성03 (1)~(4)490~495
조건절 Pushing · 조인 조건 Pushdown04 (1)(3)499 · 507
조인 제거06524
OR-Expansion · 같은 컬럼에 대한 OR-Expansion07 (1)(3)527 · 530
기타 쿼리 변환 (조인 컬럼에 IS NOT NULL 추가 등)12546

같은 화에 나온 스칼라 서브쿼리 캐싱은 II권 Ch2 「스칼라 서브쿼리」(책 p.284 · 285)에 있다. 06화와 07화에서 가리킨 좌표와 같다.

오라클 공식문서 ​

주제매뉴얼장 제목
쿼리 변환 전반 (View Merging · Predicate Pushing · Subquery Unnesting · Group-by Placement · OR Expansion · Join Elimination)tgsqlQuery Transformations
쿼리 블록 · 힌트 문법 (UNNEST · MERGE · PUSH_PRED · PLACE_GROUP_BY · OR_EXPAND · ELIMINATE_JOIN)sqlrfComments > Hints
DBMS_XPLAN.DISPLAY_CURSOR의 +ALIAS · +OUTLINEarplsDBMS_XPLAN

10053 트레이스의 각 줄은 이 교재에서 실측한 줄을 그대로 옮겨 적고 뜻을 풀었다(§6). 매뉴얼의 장 제목을 근거로 단 풀이가 아니다.

이 화에서 꼭 확인할 뷰 ​

sql
USER_CONSTRAINTS     -- constraint_type P/R · validated · rely  (조인 제거와 '1 쪽' 확인)
USER_TAB_COLUMNS     -- nullable · num_nulls                       (NOT IN 경계)
V$SQL                -- plan_hash_value                            (같은 계획 ≠ 같은 답)
V$DIAG_INFO          -- 'Default Trace File'                        (10053 파일 경로)

다음 화가 여기서 가져가는 것 ​

화가져가는 것
09Q2의 '100만 행 → 3행' 가운데 요구를 바꿔야 하는 반쪽(마감 기준 · 기간 한정). 요구를 어떻게 묻고 적는가. 그리고 「정합성 회귀 체크리스트」를 붙이는 첫 리포트
10조인 결과의 E-Rows는 어떻게 추정되나 (M:1:M의 곱을 옵티마이저는 어떻게 아나)
11OR-Expansion의 :C IS NULL 갈래: 바인드 값에 따라 실행계획이 갈리는 문제
14기각한 커버링 인덱스 (CUS_ID, ORD_AMT)가 다시 제안될 때의 적재 비용
15b같은 3행을 실행할 때마다 다시 계산하지 않는 법: 결과 재사용과 그 대가
16Q2 '먼저 묶고 붙이기' 보류안: 시스템 전체에서 이 SQL이 CPU 상위인가
18조각 6개 모두 원장과 대조 (체크리스트 B 항목)