테마
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 (원장과 같음) |
|---|---|---|
| 1 | ITM00041 2,197,346,000 (×5) | ITM00071 444,332,400 |
| 2 | ITM00036 2,147,149,000 (×5) | ITM00081 443,754,800 |
| 3 | ITM00071 1,777,329,600 (×4) | ITM00056 441,123,900 |
| 4 | ITM00056 1,764,495,600 (×4) | ITM00051 440,989,300 |
| 5 | ITM00051 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~841 | 119122671 | 100 | 1,513,627,885,000 | 100 / 100 |
| 1 | SUM(DISTINCT 금액) + COUNT(DISTINCT 주문키) | ✗ 작고 순위가 바뀜 | 839 | 2231864090 | 100 | 19,421,974,500 | 100 / 100 |
| 2 | 주문상세만 먼저 묶고 평가 원본에 조인 | ✗ 같은 줄 50번 | 839 | 466241876 | 5,000 | 1,513,627,885,000 | 0 / 0 ← |
| 3 | EXISTS (rev01) | ○ | 839 | 2231864090 | 100 | 30,272,557,700 | 0 / 0 |
| 4 | IN 서브쿼리 | ○ | 839 | 2231864090 | 100 | 30,272,557,700 | 0 / 0 |
| 5 | 평가를 상품별로 묶어 한 줄로 만들고 조인 | ○ | 839 | 3732459948 | 100 | 30,272,557,700 | 0 / 0 |
| 6 | 평가 조인을 지운다 | ○ (지금 데이터에서만) | 807 | 2031405164 | 100 | 30,272,557,700 | 0 / 0 |
| — | 원장 | 100 | 30,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,400 | ITM00081 | ITM00056 |
3 · EXISTS | ITM00081 443,754,800 | ITM00056 | ITM00051 |
양방향 MINUS는 1 / 0이다. 02화의 BETWEEN(T_ORD_BIG에서는 통과, T_ORD에서는 137건 누락)과 같은 함정이다.
📌 이 표에서 가져갈 것 세 가지
Buffers로는 정답이 갈리지 않는다. 맞는 것 넷, 틀린 것 셋이 모두 807~841이다.- 계획 해시가 같아도 답은 다를 수 있다. 1번(틀림)과 3번, 4번(맞음)의 계획 해시가 2231864090으로 같다. 계획 해시는 연산의 모양만 볼 뿐
SUM안의DISTINCT는 보지 않는다.- "이 데이터에서 같다"가 "맞다"는 뜻은 아니다. 6번은 지금은 맞지만, 요구 조건이 실제로 무언가를 거르는 날 틀린다.
3. 변환이 먼저, 비용이 나중
옵티마이저는 SQL을 받으면 먼저 고쳐 쓰고(쿼리 변환), 그렇게 고쳐 쓴 문장에 비용을 매긴다(비용 계산).
SQL 텍스트
│ 파싱
▼
쿼리 블록 트리 (SEL$1, SEL$2 …)
│ ① 쿼리 변환 ─ 휴리스틱: 조건만 맞으면 한다
│ 비용 기반(CBQT): 바꾼 모양과 안 바꾼 모양에 각각 값을 매겨 고른다
▼
고쳐 쓴 쿼리 블록 (SEL$5DA710D3, VW_GBC_7 …)
│ ② 비용 계산 ─ 접근 경로 · 조인 순서 · 조인 방식
▼
실행계획모든 변환이 지키는 규칙은 하나다. 결과를 바꾸지 않는다. 원래 SQL의 뜻이 틀렸으면 고쳐 쓴 SQL도 정확히 같은 방식으로 틀린다.
실측 — Q3 rev00은 이미 '먼저 집계'로 고쳐 쓰여 있었다 (실습/02 [B])
| 계획 | 해시 조인 A-Rows | A-Time | Buffers | 결과 (1위) | |
|---|---|---|---|---|---|
| 기본 (변환 적용) | VW_GBF_8(평가 상품별 건수) ⋈ VW_GBC_7(주문상세 상품별 합계) | 100 | 0.01초 | 839 | 22,216,620,000 |
NO_PLACE_GROUP_BY | 원래 모양 그대로 조인 후 집계 | 10,000,000 | 0.33~0.35초 | 839~840 | 22,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화 실측 |
|---|---|---|---|---|---|
| 서브쿼리 Unnesting | EXISTS · IN을 조인(세미/안티)으로 펼친다 | HASH JOIN (RIGHT) SEMI · ANTI · 쿼리 블록 이름이 합쳐짐 | UNNEST(@"SEL$2") | NO_UNNEST | Q3 rev01 839 · 막으면 FILTER 11,001 |
| 스칼라 서브쿼리 Unnesting | SELECT 목록의 스칼라를 외부조인으로 | VW_SSQ_ (보통 곧 합쳐짐) · HASH JOIN … OUTER | UNNEST | NO_UNNEST | 07화 고객명 + MAX() 779,832 → 5,856 · Q2는 안 됨(§8) |
| 단순 View Merging | 조인·필터만 있는 인라인 뷰를 바깥과 합친다 | 뷰 줄이 사라짐 | MERGE(@"SEL$2" >"SEL$1") | NO_MERGE | 07화 고객명 스칼라 문장의 인라인 뷰 |
| 복합 View Merging | GROUP BY가 있는 뷰를 바깥과 합쳐 조인 뒤에 집계 | VM_NWVW_ | MERGE | NO_MERGE | 아래 [JPPD 표] 931 |
| JPPD (조인 조건 밀어 넣기) | 바깥의 조인 조건을 뷰 안으로 밀어 넣어 뷰를 행마다 연다 | VIEW PUSHED PREDICATE | PUSH_PRED | NO_PUSH_PRED | 아래 [JPPD 표] 930 |
| Group-by Placement | 집계를 조인 앞(또는 뒤)으로 옮긴다 | VW_GBC_ · VW_GBF_ | PLACE_GROUP_BY | NO_PLACE_GROUP_BY | Q3 rev00 (§3) |
| Partial Join | DISTINCT 집계만 있으면 조인을 '하나 찾으면 멈춤'으로 | HASH JOIN (RIGHT) SEMI | PARTIAL_JOIN | NO_PARTIAL_JOIN | §2의 1번: 틀린 안이 맞는 안과 같은 계획이 된 이유 |
| 조인 제거 | 행 수를 안 바꾸는 조인을 지운다 (PK + FK) | 테이블 줄이 사라짐 | ELIMINATE_JOIN | NO_ELIMINATE_JOIN | 아래 [조인 제거 표] 806 → 802 |
| OR-Expansion | OR을 UNION ALL 갈래로 | VW_ORE_ · UNION-ALL · 갈래마다 FILTER | OR_EXPAND | NO_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 Starts | Buffers | |
|---|---|---|
| Unnesting (기본) | 1 (INDEX FAST FULL SCAN) | 839 |
NO_UNNEST → FILTER | 5,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 · NL | 931 |
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 SCAN | 12 |
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에서 발췌)
| 약어 | 뜻 | 약어 | 뜻 | |
|---|---|---|---|---|
CBQT | cost-based query transformation | SU | subquery unnesting | |
CVM | complex view merging | JE | join elimination | |
JPPD | join predicate push-down | ORE | CBQT OR-Expansion | |
FPD | filter push-down | DP | distinct placement | |
OBYE | order by elimination | CNT | count(col) to count(*) | |
SJC | set join conversion | SLP | select list pruning |
GBP(Group-by Placement)는 Legend에 없지만 GBP:로 시작하는 줄로 나온다.
이 화에서 찾은 줄 (실습/90)
| 파일 | 찾을 줄 | 뜻 |
|---|---|---|
9A Q2 rev01 | SU: bypassed: Scalar subquery may return more than one row. | 스칼라를 조인으로 바꾸려면 '한 행'이 증명돼야 하는데 증명되지 않았다 |
9A Q2 rev01 | GBP: 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 rev00 | GBP: Starting iteration 1 … 4 · Updated best state, Cost = … · Final query after transformations | 네 가지 모양에 비용을 매겼다(§3 표) · 고쳐 쓴 SQL 전문 |
9E Q3 rev01 | SU: 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 IN | 0 | HASH JOIN ANTI NA (3~7블록. 14행짜리 테이블이라 설치마다 몇 블록씩 다르다) |
NOT EXISTS | 8 | HASH JOIN ANTI (7블록 안팎) · 서브쿼리 쪽에 filter("M"."MGR" IS NOT NULL) |
NOT IN … WHERE MGR IS NOT NULL | 8 | — |
왜 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).
| 문장 | 펼쳐졌나 | Buffers | 10053 |
|---|---|---|---|
| Q2 rev01 | ✗ (M_RGN Starts 8) | 5,868~5,869 | may return more than one row |
Q2 rev01 + /*+ UNNEST */ | ✗ (계획 해시 2292855265 그대로) | 5,868 | — |
Q2 + 스칼라 안을 MAX()로 | ✗ (계획 해시 1928341286) | 5,868~5,869 | Outer qurery has illegal group-by |
| 07화 고객명 스칼라 | ✗ (M_CUS Starts 752,995) | 779,832~779,833 | may return more than one row |
07화 고객명 + MAX() | ○ HASH JOIN RIGHT OUTER · UNNEST · ELIM_GROUPBY · MERGE | 5,856 | Unnesting 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,100 | 90,000 / 22,948,657,800 | 900,000 / 229,885,643,800 |
| 외부조인 | 20,000 / 5,128,644,200 | 180,000 / 45,897,315,600 | 1,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로 바꿨다.
| 등급 | 스칼라 | 외부조인 | 내부조인 |
|---|---|---|---|
| A | 10,000 / 2,564,322,100 | 10,000 / 2,564,322,100 | 9,500 / 2,439,050,900 |
| B | 90,000 / 22,948,657,800 | 90,000 / 22,948,657,800 | 89,500 / 22,823,283,100 |
| C | 900,000 / 229,885,643,800 | 900,000 / 229,885,643,800 | 891,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,868 | 114~118 | — | 현행 |
| 스칼라 → 외부조인 | 5,862 | 56~61 | PK가 있는 동안 | 기각 (§8) |
| 주문을 고객별로 먼저 묶고 붙인다 (스칼라 유지) | 5,868 | 30~34 | ○ MINUS 0 / 0 · 코드 중복 시 ORA-01427도 그대로 | 보류안 |
(바닥) T_ORD_BIG을 한 번 읽어 더하기만 | 5,826 | 21~24 | — | 참고 |
| (참고) 고객별로 묶기만 | 5,826 | 30~34 | — | 참고 |
커버링 인덱스 (CUS_ID, ORD_AMT) + rev01 | 3,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,000 | 1,513,627,885,000 | 0 / 0 |
| 원장 | 100 | 30,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 Buffers | 839~841 (T_ORD_DET 807 · PK_T_ITM_EVL 32) · 계획 해시 119122671 (01화 W01과 같음) |
| rev00 TOP 5 | ITM00071 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 5 | ITM00071 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 Buffers | 839 · 계획 해시 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 → FILTER | PK_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 EXISTS | 0 / 8 (HASH JOIN ANTI NA · HASH JOIN ANTI, 둘 다 10블록 미만이고 설치마다 몇 블록씩 다르다) |
| OR-Expansion | NVL 12~13 · IS NULL OR 12 · DECODE 12 · 컬럼 NVL 1,205 · 비교 기준 12 · 두 컬럼 OR BITMAP OR 288~289 (284건 · 133,944,110) |
Q2 (실습/03 · 실습/90)
| 항목 | 값 |
|---|---|
| rev01 | 5,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_원서와_공식문서_지도.mdPDF 페이지 = 책 페이지 − 2 ·성능고도화II_목차색인.md(공개 사이트에는 없음)
성능고도화 II권 Ch4 「쿼리 변환」 (주 원천)
| 주제 | 절 | 책 페이지 |
|---|---|---|
| 쿼리 변환이란 · 휴리스틱과 비용 기반 | 01 | 462 |
| 서브쿼리 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 · 조인 조건 Pushdown | 04 (1)(3) | 499 · 507 |
| 조인 제거 | 06 | 524 |
| OR-Expansion · 같은 컬럼에 대한 OR-Expansion | 07 (1)(3) | 527 · 530 |
| 기타 쿼리 변환 (조인 컬럼에 IS NOT NULL 추가 등) | 12 | 546 |
같은 화에 나온 스칼라 서브쿼리 캐싱은 II권 Ch2 「스칼라 서브쿼리」(책 p.284 · 285)에 있다. 06화와 07화에서 가리킨 좌표와 같다.
오라클 공식문서
| 주제 | 매뉴얼 | 장 제목 |
|---|---|---|
| 쿼리 변환 전반 (View Merging · Predicate Pushing · Subquery Unnesting · Group-by Placement · OR Expansion · Join Elimination) | tgsql | Query Transformations |
쿼리 블록 · 힌트 문법 (UNNEST · MERGE · PUSH_PRED · PLACE_GROUP_BY · OR_EXPAND · ELIMINATE_JOIN) | sqlrf | Comments > Hints |
DBMS_XPLAN.DISPLAY_CURSOR의 +ALIAS · +OUTLINE | arpls | DBMS_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 파일 경로)다음 화가 여기서 가져가는 것
| 화 | 가져가는 것 |
|---|---|
| 09 | Q2의 '100만 행 → 3행' 가운데 요구를 바꿔야 하는 반쪽(마감 기준 · 기간 한정). 요구를 어떻게 묻고 적는가. 그리고 「정합성 회귀 체크리스트」를 붙이는 첫 리포트 |
| 10 | 조인 결과의 E-Rows는 어떻게 추정되나 (M:1:M의 곱을 옵티마이저는 어떻게 아나) |
| 11 | OR-Expansion의 :C IS NULL 갈래: 바인드 값에 따라 실행계획이 갈리는 문제 |
| 14 | 기각한 커버링 인덱스 (CUS_ID, ORD_AMT)가 다시 제안될 때의 적재 비용 |
| 15b | 같은 3행을 실행할 때마다 다시 계산하지 않는 법: 결과 재사용과 그 대가 |
| 16 | Q2 '먼저 묶고 붙이기' 보류안: 시스템 전체에서 이 SQL이 CPU 상위인가 |
| 18 | 조각 6개 모두 원장과 대조 (체크리스트 B 항목) |