Skip to content

09화 · 파고들기 ​

조회용 문서다. 처음 읽을 때는 1_사건.md만으로 충분하다.

찾는 것바로 가기
정렬은 왜 비싼가 · 정렬을 부르는 오퍼레이션§1 정렬
STOPKEY와 Top-N 정렬§2 Top-N
페이지 곡선: 네 가지 SQL을 한 표로§3 곡선
정렬의 work area 3등급 · 책상 × 페이지 표 · 절벽§4 등급
외부 병합 정렬(청크 → Run → Merge) · 10032 읽는 법§5 외부 정렬
"PGA부터 올리자"를 판정하는 법§6 PGA
인덱스가 정렬을 대신하는 조건(NOSORT) · rev01§7 rev01
OFFSET의 본질 · 늦은 테이블 방문§8 OFFSET
키 기반 페이징(seek method · 포스트잇) · rev02§9 포스트잇
총건수를 다루는 네 가지§10 총건수
ARRAYSIZE와 Fetch Call · 부분범위 처리§11 Fetch
정렬 키의 정답성(ora-dist 매출 목록)§12 정렬 키
「화면 요구 재협상서」§13 재협상서
측정값 전부§14 측정
원천 좌표§15 좌표

1. 정렬은 다 읽어야 첫 줄이 나온다 ​

정렬이 비싼 이유는 두 가지다.

무엇09화에서 본 것
① 멈출 수 없다마지막 행을 보기 전에는 "가장 최신"이 무엇인지 모른다. 첫 줄을 내주기 전에 입력을 전부 읽는다1페이지도 100페이지도 Buffers 5,826
② 책상이 필요하다줄 세울 행을 PGA의 work area에 올린다. 모자라면 TEMP로 내린다100페이지 206K · 마지막 페이지 74,638K

①은 일량을, ②는 장소를 정한다. 메모리(PGA)는 ②만 바꾼다. ①을 바꾸려면 정렬을 없애는 수밖에 없다. 인덱스가 이미 그 순서로 줄 서 있으면 정렬이 필요 없다(§7).

실행계획에서 정렬을 알아보는 법 ​

오퍼레이션무엇책상(work area)
SORT ORDER BYORDER BY 전체 정렬입력 전부
SORT ORDER BY STOPKEYTop-N 정렬(ROWNUM <= N이 안쪽에 있을 때)상위 N건만
WINDOW SORT PUSHED RANK분석함수 Top-N(FETCH FIRST / OFFSET 문법이 이것으로 바뀐다)상위 N건만
SORT AGGREGATECOUNT(*)·SUM 한 줄 집계정렬하지 않는다(이름만 SORT)
SORT GROUP BY / HASH GROUP BY그룹별 집계그룹 수만큼
SORT UNIQUE / HASH UNIQUEDISTINCT · UNION고유값 수만큼
SORT JOIN소트 머지 조인(06화)조인 입력 전부

실행계획에 OMem, 1Mem, Used-Mem 열이 나타나면 그 줄이 work area를 쓴다는 뜻이다. 열이 아예 없으면 책상이 필요 없는 계획이다. rev01이 그랬다.


2. Top-N 정렬과 STOPKEY ​

ROWNUM <= N이 정렬 안쪽 인라인뷰에 있으면 옵티마이저는 "위에서 N건만 필요하다"는 것을 안다. 이때 정렬은 Top-N 정렬이 된다.

입력 100만 건을 하나씩 넘기면서
  책상 위에는 "지금까지 본 것 중 최신 N건"만 둔다
  새 행이 책상 위 가장 오래된 것보다 오래되었으면 → 보자마자 버린다

실측 — ROWNUM의 위치 하나로 (T_ORD_BIG 100만 행) ​

SQL오퍼레이션Used-MemBuffers
1페이지 · ROWNUM <= 10 안쪽 (rev00 형태)SORT ORDER BY STOPKEY4K (0)5,826
1페이지 · rn BETWEEN 1 AND 10 바깥에만SORT ORDER BY48M (0)5,826
100페이지 · ROWNUM <= 1000 안쪽SORT ORDER BY STOPKEY206K (0)5,826
100페이지 · OFFSET 990 ROWS FETCH NEXT 10 ROWS ONLYWINDOW SORT PUSHED RANK206K (0)5,826

Buffers는 넷 다 같다. Top-N은 일량이 아니라 책상을 줄인다. 그래서 STOPKEY만으로는 "1페이지도 5,826"이라는 사실이 바뀌지 않는다.

강도현의 2019-05-20 커밋 "ROWNUM을 안쪽 인라인뷰로 옮김. 바깥에 두면 전체를 정렬함"은 옳았다. 48M → 4K.

Top-N의 책상은 페이지에 비례한다 (AUTO · 실측) ​

페이지N (= 페이지 × 10)Used-Mem등급시간
1104Koptimal37~38ms
1010024Koptimal37~40ms
1001,000206Koptimal40~41ms
1,00010,0001,999Koptimal52~56ms
10,000100,00012,016Koptimal136~155ms
100,000 (마지막)1,000,00074,638Koptimal532~568ms

Buffers는 여섯 줄 모두 5,826이다. 책상은 페이지와 함께 커진다. 정비례는 아니어서, 100페이지까지는 페이지당 약 2K, 마지막 페이지까지는 평균 약 0.75K씩 늘어난다.


3. 페이지 곡선 — 네 가지 SQL ​

이 화의 핵심 표다. 화면도 같고 결과 10건도 같다. Buffers만 다르다.

페이지rev00 FULL + Top-N 정렬rev01 인덱스 NOSORT(자율)rev01 + 인덱스 강제늦은 테이블 방문rev02 포스트잇
15,8261414—16
105,826104104——
1005,8261,0071,0071615
500—5,0205,020——
600—6,0246,024——
1,0005,8265,826 ← 정렬로 복귀10,0374618
10,0005,8265,826 ← 정렬로 복귀100,33834717
100,000 (마지막)5,826—1,003,3473,35617
곡선의 모양평평하고 높다기울었다(10p)기울었다(10p)완만하다(p/30)평평하고 낮다

(—는 재지 않은 칸이다. rev02의 2페이지와 3페이지는 15)

읽는 법 ​

SQL무엇을 세나페이지 p의 비용
rev00매번 100만 건 전부를 읽고 줄 세운다상수(테이블 전체)
rev01앞의 10(p−1) 행을 테이블까지 가서 읽고 버린다≈ 10p(CF 1,000,000 → 버리는 행마다 한 블록)
늦은 테이블 방문앞의 행을 인덱스에서만 세고, 테이블에는 10건만 간다≈ 리프 블록 수 10p / 299 + 10
rev02세지 않는다. 포스트잇 자리로 찾아간다상수(≈ 15)

옵티마이저는 몇 페이지에서 갈아타나 (rev01 자율 · EXPLAIN PLAN) ​

페이지Cost고른 계획
9349,376INDEX FULL SCAN DESCENDING
9359,378SORT ORDER BY STOPKEY (rev00 계획)

Buffers로 보면 인덱스 쪽은 약 581페이지(10.03 × 581 ≈ 5,826)에서 이미 FULL보다 비싸다. 그런데도 옵티마이저는 935페이지까지 인덱스를 고른다. Cost에는 정렬에 드는 CPU가 들어 있기 때문이다(FULL 1,612 + 정렬 → 9,378).

📌 이 컨테이너에서는 시간이 반대로 나온다. 1,000페이지에서 인덱스를 강제하면 8~10ms, FULL + 정렬은 50~57ms다. 전부 버퍼 캐시에 있어서 10,037번의 논리적 읽기가 싸게 끝나기 때문이다. 디스크에서 읽어야 하는 운영 환경에서는 10,037번의 single block read와 5,826블록의 multiblock read를 비교하게 된다(04화에서 본 내용이다). T0 규약대로 판정은 Buffers로 한다.


4. 정렬의 work area 3등급과 절벽 ​

06화 §6의 3등급(optimal / onepass / multipass)이 정렬에도 그대로 적용된다. 06화에서 본 바로 그 구분이다.

책상 × 페이지 (rev00 · WORKAREA_SIZE_POLICY=MANUAL + SORT_AREA_SIZE · 실측) ​

책상 \ 페이지1101001,00010,000
64KBoptimaloptimal184 PASSES · 7,162290 PASSES · 8,013290 PASSES · 8,013
256KBoptimaloptimaloptimal22 PASSES · 6,15222 PASSES · 6,152
1MBoptimaloptimaloptimal · 5,8261 PASS · 5,8312 PASSES · 5,901
4MBoptimaloptimaloptimaloptimal · 5,8261 PASS · 5,847
16MBoptimaloptimaloptimaloptimal · 5,826optimal · 5,826
AUTO (512M)optimaloptimaloptimal · 5,826optimaloptimal (마지막 페이지도 optimal · 74,638K)

(숫자는 Buffers다. optimal 칸은 전부 5,826 안팎이다)

시간 (보조 지표 — 흔들린다) ​

책상 · 페이지등급시간(여러 번 잰 범위)
AUTO · 100optimal0.04초
64KB · 100184 PASSES6~24초(대부분 6초 안팎)
64KB · 1,000290 PASSES19~80초
256KB · 1,00022 PASSES9~42초
1MB · 1,0001 PASS0.10~0.36초
1MB · 10,0002 PASSES1.5~6.2초
4MB · 10,0001 PASS0.6~1.7초

흔들림은 대부분 direct path write temp 대기에서 온다. 64KB 책상으로 100페이지를 한 번 볼 때 3,674번 기다렸고, 그 시간이 5.7~10.6초였다(direct path read temp는 1,1xx~1,2xx번, 4~11ms). 컨테이너가 쓰는 디스크가 바쁘면 이 값이 길어진다. 판정 기준은 패스 수와 Buffers 값이다.

절벽 — 64KB 책상에서 한 페이지씩 ​

페이지등급Buffers (CU)EST_OPTTEMP시간
1 · 10optimal5,826 (0)5K · 28K—37ms
20optimal5,82748K—58ms
25 ~ 551 PASS5,829 ~ 5,83436K ~ 162K1M46~86ms
60 · 612 PASSES5,839 · 5,841270K · 279K1M92 · 151ms
623 PASSES5,846387K1M167ms
635 PASSES5,859 (33)639K1M171~253ms
64184 PASSES7,161 (1,335)24,795K26M5.2~7.2초
100184 PASSES7,162 (1,336)24,831K29M6~24초

63페이지에서 64페이지로 넘어갈 때 오라클이 추정한 optimal 크기가 639K → 24,795K로, TEMP가 1M → 26M으로 한꺼번에 뛴다. 상위 N건만 추려 가던 정렬이, 들어오는 행 대부분을 TEMP로 내려보내는 정렬로 바뀐 것으로 읽힌다 (10032 트레이스로 보면 입력 1,000,000건 중 615,924건이 run으로 나갔다. §5 참고). 그 경계가 왜 63과 64 사이인지는 확인하지 못했다.

현업의 증언과 맞아떨어지는 모양이다. 1페이지는 괜찮고, 뒤로 갈수록 조금씩 느려지다가(25~63페이지에서 수십~수백 ms), 어느 페이지에서 갑자기 멈춘다.


5. 외부 병합 정렬 — 청크 → Run → Merge ​

책상에 다 올라가지 않는 정렬은 세 단계로 진행한다.

① 청크   : 책상만큼 읽어서 책상 위에서 줄 세운다
② Run    : 줄 세운 묶음을 TEMP 에 내려놓는다. 다음 청크를 읽는다. 반복
③ Merge  : 묶음들의 맨 앞 행만 책상에 올려 비교하며 하나로 합친다
           한 번에 합칠 수 있는 묶음 수 = 합치기 폭 (책상 넓이가 정한다)
           묶음 수 > 합치기 폭 → 합친 결과를 다시 묶음으로 내려놓고 또 합친다 (패스)

   optimal  : ①에서 끝. ②③이 없다
   onepass  : ②를 하고 ③을 한 번에
   multipass: ③을 여러 번

10032 트레이스로 본 실측 ​

64KB · 100페이지1MB · 1,000페이지
max intermediate merge width (합치기 폭)329
Initial runs (②에서 내려놓은 묶음)9204
Intermediate runs183—
Number of merges184 (= 184 PASSES)1
Input records1,000,0001,000,000
Output records615,92483,081
Run directory block reads (buffer cache)1,3365

마지막 줄이 Buffers에 붙은 군살의 정체다. DISPLAY_CURSOR의 Buffers는 64KB 책상의 100페이지에서 7,162 = 5,826 + 1,336, 1MB 책상의 1,000페이지에서 5,831 = 5,826 + 5였다. V$SQL_PLAN_STATISTICS_ALL로 나눠 보면 이 몫은 전부 current 모드 읽기(LAST_CU_BUFFER_GETS)다. TEMP에 내려놓은 묶음들의 목차를 버퍼 캐시에서 읽은 값이다. TEMP를 쓰는 동안에만 붙고, 책상을 넓히면 사라진다.

10032 뜨는 법 · 읽는 법 ​

sql
ALTER SESSION SET tracefile_identifier = 'CH09_S64K';
ALTER SESSION SET EVENTS '10032 trace name context forever, level 10';
-- 정렬이 있는 SQL 실행
ALTER SESSION SET EVENTS '10032 trace name context off';
SELECT value FROM v$diag_info WHERE name = 'Default Trace File';
bash
docker exec ora-hanorder bash -c "grep -E 'max intermediate merge width|Initial runs|Number of merges|Run directory block reads' /opt/oracle/diag/rdbms/free/FREE/trace/*CH09_S*.trc"

10032는 공식 매뉴얼에 실린 기능이 아니라 진단 이벤트다. 출력 형식은 버전마다 다를 수 있고, 운영에서 쓰려면 DBA의 승인을 받아야 한다. 스크립트: 실습/90_정렬_트레이스_10032.sql

세션 통계와 대기 이벤트 (64KB · 100페이지 한 번) ​

지표값
sorts (disk)+1
physical writes direct temporary tablespace3,674블록
physical reads direct temporary tablespace1,115~1,212블록
direct path write temp3,674번 · 5.7~10.6초
direct path read temp1,115~1,212번 · 4~11ms

6. "PGA부터 올리자"를 판정하는 법 ​

메모리가 바꾸는 것과 못 바꾸는 것 ​

메모리를 올리면
등급(패스 수)바뀐다. 184 PASSES → optimal
시간바뀐다. 6초 → 0.04초
Buffers5,826 아래로는 안 내려간다. TEMP 군살(최대 2,187)만 빠진다
멈추는 페이지뒤로 밀린다. 64KB는 64페이지에서 184 PASSES, 256KB는 100페이지까지 살고 1,000페이지에서 22 PASSES, 1MB는 10,000페이지에서 2 PASSES
필요한 메모리페이지에 비례한다. 마지막 페이지를 optimal로 끝내려면 사용자 한 명이 한 번 누를 때 74,638K가 필요하다

메모리는 정렬을 빨리 하게 할 뿐, 안 하게 하지는 못한다. 정렬 자체를 없애면(§7) 메모리와는 상관없어진다.

판정 순서 ​

1. 계획에 work area 를 쓰는 줄이 있는가 (OMem / Used-Mem 열)        없으면 → PGA 와 무관하다
2. 그 줄의 등급은?  V$SQL_WORKAREA.LAST_EXECUTION                     OPTIMAL 이면 → PGA 가 원인이 아니다
3. 필요한 크기(ESTIMATED_OPTIMAL_SIZE)가 무엇에 비례하는가            페이지·기간·건수에 비례하면 → 올려도 언젠가 또 모자란다
4. 정렬(또는 해시)을 없앨 수 있는가                                    있으면 → 그쪽이 먼저다

Free에서 재현하는 법 — 그리고 범위 경계 ​

운영에서는 여러 세션이 동시에 work area를 쓰면 AUTO 정책이 작업 하나에 주는 크기(global memory bound)를 줄인다. 이 컨테이너에서는 세션 하나만 쓰므로 그 값이 넉넉하다. 실측한 V$PGASTAT의 global memory bound는 104,857,600(100MB)이었다. 그래서 마지막 페이지(74,638K)까지 optimal로 끝났다.

Free 컨테이너 한 대로는 수십 세션의 동시 정렬을 만들 수 없다. 그래서 06화처럼 세션 하나의 책상을 직접 정해서 같은 현상을 재현한다.

sql
ALTER SESSION SET workarea_size_policy = MANUAL;
ALTER SESSION SET sort_area_size = 65536;          -- 64KB
-- ... 측정 ...
ALTER SESSION SET workarea_size_policy = AUTO;     -- 반드시 원복

서버 메모리 자체를 바꿔 가며 비교하는 대규모 EE 환경 대조는 이 교재의 범위 밖이고, 별권에서 다룬다.

📌 Sort Area가 PGA 안의 어느 칸(UGA · CGA)에 잡히는지, 그리고 수동 관리와 자동 관리 방식에서 크기를 정하는 공식은 원천 II권 Ch5 01절 (3)과 07절에 있다. 이 화에서는 측정할 수 있는 것(work area 크기 · 등급 · TEMP)만 다룬다.


7. rev01 — 인덱스가 정렬을 대신한다 ​

인덱스가 ORDER BY를 대신하는 세 조건 ​

조건02화 IX_ORD_BIG_DT(ORD_DT)09화 IX_ORD_BIG_DT_SEQ(ORD_DT, ORD_SEQ)
① 정렬 키 전부가 같은 순서로 인덱스 앞쪽에 있다✕ ORD_SEQ가 없다○
② 인덱스에 모든 행이 있다고 보장된다✕ ORD_DT가 NULL 허용이다. 단일 컬럼 인덱스는 NULL 행을 저장하지 않는다○ ORD_SEQ가 NOT NULL이다. 복합 인덱스는 키 중 하나라도 NULL이 아니면 저장된다
③ 방향이 맞는다(전부 ASC 또는 전부 DESC)—○ 둘 다 DESC라서 거꾸로 읽는다(INDEX FULL SCAN DESCENDING)

02화 2_파고들기 §1의 "NULL은 저장되지 않는다"가 여기서 그대로 쓰인다.

02화 인덱스로 한 단계씩 (실측) ​

SQL (100페이지)계획Buffers
rev00 + INDEX_DESC(IX_ORD_BIG_DT) 힌트힌트 무시 · SORT ORDER BY STOPKEY + FULL5,826
WHERE ORD_DT IS NOT NULL + ORDER BY ORD_DT DESC (②를 조건으로 보장하고 ①을 줄임)INDEX FULL SCAN DESCENDING · 정렬 없음1,007
위 + ORD_SEQ DESC + 힌트인덱스 전체 2,655 + 테이블 100만 번 + SORT ORDER BY STOPKEY1,002K

ORD_DT의 실제 NULL은 0건이다. 그래도 안 된다. 옵티마이저는 지금 데이터가 아니라 제약(약속)을 보고 계획을 세운다.

왜 ORD_SEQ가 필요한가 — 정렬 키는 유일해야 한다 ​

하루 주문은 913~914건이고 전부 같은 ORD_DT(자정)다. ORD_DT만으로 정렬하면 같은 날짜 안에서는 순서가 정해지지 않으므로, 페이지 경계에서 어떤 행이 어느 페이지에 나올지가 실행할 때마다 달라질 수 있다. 키 기반 페이징(§9)에서는 포스트잇이 어디를 가리키는지조차 정해지지 않는다. 강도현이 ORD_SEQ를 붙인 것은 옳았다.

rev01 실측 ​

sql
CREATE INDEX IX_ORD_BIG_DT_SEQ ON T_ORD_BIG(ORD_DT, ORD_SEQ);    -- 생성 약 1.9~2.1초
-- BLEVEL 2 · LEAF_BLOCKS 3,344 · CLUSTERING_FACTOR 1,000,000
rev00rev01
Q4 (100페이지) 계획SORT ORDER BY STOPKEY + TABLE ACCESS FULLINDEX FULL SCAN DESCENDING + TABLE ACCESS BY INDEX ROWID + COUNT STOPKEY
Q4 Buffers5,8261,007(인덱스 7 + 테이블 1,000)
work area206K (0)없음. PGA와 무관
Q4c 계획TABLE ACCESS FULLINDEX FAST FULL SCAN ← SQL은 안 고쳤다
Q4c Buffers5,8263,365
Q1(02화 rev02의 SQL)9191(변화 없음)
Q2(06화 rev01의 SQL)5,868 · plan_hash_value 22928552655,868 · 2292855265(변화 없음)

⚠️ INVISIBLE로 검증할 때 이 버전에서 본 것 ​

02화의 규칙("운영이면 INVISIBLE로 먼저")에 따라 ALTER INDEX ... INVISIBLE + ALTER SESSION SET optimizer_use_invisible_indexes = TRUE로 내 세션에서만 켜 봤다.

SQL그 상태의 계획Buffers
Q4c COUNT(*)INDEX FAST FULL SCAN IX_ORD_BIG_DT_SEQ (인덱스를 썼다)3,365
Q4 (100페이지)SORT ORDER BY STOPKEY + FULL (인덱스를 안 썼다)5,826
Q4 + INDEX_DESC(IX_ORD_BIG_DT_SEQ)INDEX FULL SCAN DESCENDING · 정렬 없음1,007
포스트잇형(WHERE ORD_DT <= … + ROWNUM <= 11)SORT ORDER BY STOPKEY + FULL (인덱스를 안 썼다)—

26ai Free 23.26.2에서는 INVISIBLE 인덱스를 세션에서 켜도, "인덱스로 정렬을 대신하는" 계획을 옵티마이저가 스스로 고르지 않았다. 원인은 확인하지 못했다(10053 트레이스로는 보지 않았다). 그래서 INVISIBLE 검증은 "안 바뀌어야 할 SQL이 바뀌는가"를 잡는 데 쓰고, 정렬 대체 계획 자체는 힌트로 따로 확인했다. 재현은 실습/02_인덱스로_정렬_대신.sql [D]에서 한다.


8. OFFSET의 본질 — 앞에서부터 센다 ​

ROWNUM <= 1000 … rn > 990이든 OFFSET 990 ROWS든, "990번째 다음"을 묻는 페이징은 990번째가 어디인지 세 봐야 안다. 그래서 페이지가 뒤로 갈수록 비용이 커진다.

p 페이지의 비용 ≈ (앞에서 세며 버리는 행 수) × (행 하나를 세는 비용) + 10건
               = 10(p−1) × c + 10건
세는 방법c(행 하나를 세는 비용)실측(1,000페이지)
테이블까지 가서 센다(rev01) · T_ORD_BIG CF 1,000,000≈ 1블록(버리는 행마다 다른 블록)10,037
테이블까지 가서 센다 · ora-dist CF10 CF 9,340(테이블 4,583블록)≈ 0.02블록(버리는 행들이 같은 블록에 모여 있다)225
인덱스에서만 센다(늦은 테이블 방문)≈ 1/299블록(리프 한 블록에 약 299개)46
세지 않는다(rev02)018

같은 OFFSET인데 CF 하나로 44배가 갈린다(10,037 vs 225). 04화의 커피 캐리어가 페이징에도 그대로 나오는 것이다.

늦은 테이블 방문 (late row lookup) ​

버릴 행은 테이블까지 갈 필요가 없다. 인덱스에서 ROWID만으로 세고, 마지막 10건의 ROWID로만 테이블에 간다. 02화에서 나온 질문 "테이블에 왜 가야 해?"를 그대로 적용한 것이다.

sql
SELECT /*+ LEADING(x) USE_NL(t) */ t.ORD_SEQ, t.CUS_ID, t.ORD_DT, t.ORD_AMT
  FROM (SELECT rid, rn
          FROM (SELECT ROWNUM rn, rid
                  FROM (SELECT ROWID rid FROM T_ORD_BIG ORDER BY ORD_DT DESC, ORD_SEQ DESC)
                 WHERE ROWNUM <= :page * 10)
         WHERE rn > (:page - 1) * 10) x,
       T_ORD_BIG t
 WHERE t.ROWID = x.rid
 ORDER BY x.rn;
페이지1001,00010,000100,000
Buffers16463473,356

⚠️ LEADING(x) USE_NL(t)를 빼면 적응형 계획이 10,000페이지에서 HASH JOIN + T_ORD_BIG FULL을 골라 6,164가 나왔다(실측). 이 SQL은 조인 방법을 고정해야 의도대로 돈다. 맨 위 SORT ORDER BY는 마지막 10건을 rn 순으로 다시 세우는 정렬이다(2KB). 원천 II권 Ch5 06절 "Sort Area를 적게 사용하도록 SQL 작성"에 나오는 기법이다.

화면을 바꿀 수 없을 때(페이지 번호를 유지해야 할 때) 쓸 수 있는 차선책이다. 재협상서의 B안이 이것이다.


9. 키 기반 페이징 — 포스트잇 ​

"몇 번째부터" 대신 "어디부터"를 넘긴다(seek method, keyset pagination). 앞 페이지를 세지 않는다.

sql
-- 10건을 보여 주려고 11건을 가져온다
-- 11번째가 있으면 "다음 페이지 있음". 그 행의 (ORD_DT, ORD_SEQ) 가 포스트잇이다
SELECT ORD_SEQ, CUS_ID, ORD_DT, ORD_AMT
  FROM (SELECT ORD_SEQ, CUS_ID, ORD_DT, ORD_AMT
          FROM T_ORD_BIG
         WHERE ORD_DT <= :p_dt
           AND (ORD_DT < :p_dt OR ORD_SEQ <= :p_seq)
         ORDER BY ORD_DT DESC, ORD_SEQ DESC)
 WHERE ROWNUM <= 11;
|*  4 |     INDEX RANGE SCAN DESCENDING| IX_ORD_BIG_DT_SEQ |      1 |     12 |     11 |00:00:00.01 |       4 |
   4 - access("ORD_DT"<=TO_DATE(' 2024-12-30 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))
       filter(("ORD_SEQ"<=988784 OR "ORD_DT"<TO_DATE(' 2024-12-30 00:00:00', 'syyyy-mm-dd hh24:mi:ss')))

ORD_DT <= :p_dt가 access(찾아가기) 조건이 되고, 같은 날짜 안의 경계는 인덱스 안에서 filter로 거른다. 테이블에는 11번만 간다.

왜 (A < x OR (A = x AND B <= y))를 이렇게 쓰나 ​

오라클은 (ORD_DT, ORD_SEQ) <= (:d, :s) 같은 행 값 크기 비교를 지원하지 않는다. 그래서 풀어 쓴다. 이때 ORD_DT <= :d를 따로 한 번 더 써 줘야 인덱스 선두 컬럼에 access 조건이 생긴다. OR만 있으면 범위의 시작점을 지목할 수 없다.

실측 — 몇 페이지째든 같다 ​

페이지1231001,00010,000100,000 (마지막)
Buffers16151515181717
가져온 행11111111111110 ← "다음 없음"

정합성 회귀 검증 — 08화 「정합성 회귀 체크리스트」 ​

08화에서 만든 체크리스트다. 양식은 정합성_회귀_체크리스트.md에 있다. T1 7호가 이 표를 처음 붙인 리포트다.

#항목대상 / 방법결과판정
A기준rev00(같은 화면 · 같은 ORDER BY). 원장은 T_ORD_BIG 전체를 같은 순서로 정렬한 것—정함
B건수 · 합계 · MINUS 양방향 · 중복 키rev02 vs rev00, 100페이지10 = 10 · 2,627,200 = 2,627,200 · 0 / 0 · 정렬 키 (ORD_DT, ORD_SEQ) 중복 0통과
C잘라 내기 전 전체포스트잇으로 100,000페이지를 끝까지 넘기며 전체 정렬 결과와 한 행씩 대조(PL/SQL)페이지 100,000 · 행 1,000,000 · 합계 255,398,623,700 · 어긋난 행 0통과
D조인마다 1 쪽Q4는 조인이 없다(B안인 늦은 테이블 방문은 ROWID 1:1 조인)—해당 없음(조인 없음)
E경계 데이터날짜가 바뀌는 92페이지(12월 30일 3건 + 29일 7건) · 마지막 페이지(10건, 다음 없음)92페이지 10 = 10 · 2,848,500 = 2,848,500 · 0 / 0 · 마지막 페이지 10건통과
F계획 해시와 무관하게 대조rev00과 rev01은 SQL이 같고 계획이 다르다. 계획이 아니라 결과로 대조(B · C)통과
G변환을 근거로 쓰지 않음——해당 없음(변환 없음)
H영향 분석새 인덱스 IX_ORD_BIG_DT_SEQ → T_ORD_BIG을 읽는 조각 전부Q4c 계획 변경 · 답 1,000,000 = 1,000,000 · Q1 91 · 27,390건 그대로 · Q2 5,868 · 해시 2292855265 그대로통과

📌 페이징에서는 C 항목이 특히 중요하다. 화면 한 장(10건)만 맞춰 보면 페이지 경계에서 빠지거나 겹치는 행을 놓친다. 키 기반 페이징은 정렬 키가 유일하지 않으면 바로 그 경계에서 틀린다. 전체 순회는 3~4초면 끝난다(실습/03_포스트잇.sql [D3]).

날짜로 가기 ​

sql
         WHERE ORD_DT < :d + 1        -- :d 일자의 마지막 주문부터

실행계획은 INDEX RANGE SCAN DESCENDING이고 조건은 access("ORD_DT"<…), Buffers는 15다(12월 20일로 가기 실측). 포스트잇 대신 날짜 하나로 시작점을 지목하는 방식이다.

조건과 한계 ​

정렬 키가 유일해야 한다ORD_DT만으로는 하루 913~914건이 같은 값이라 시작점이 정해지지 않는다(§7)
정렬 키와 같은 인덱스가 있어야 한다없으면 포스트잇이 있어도 정렬을 한다
임의 페이지로 점프할 수 없다999페이지의 포스트잇은 998페이지를 지나와야 생긴다. "날짜로 가기"로 대신한다
"이전"은 앱이 쓴 포스트잇을 보관해 두고 꺼낸다SQL을 바꿀 필요가 없다
정렬 기준을 바꾸는 기능이 생기면기준마다 인덱스와 포스트잇이 따로 필요하다. 재협상 조건이다
사이에 새 행이 들어와도포스트잇 자리를 기준으로 하므로 행이 밀려서 같은 행이 두 번 나오는 일이 없다(OFFSET은 밀린다. 원리상 그렇고, 이 화에서 재지는 않았다)

포스트잇 값을 SQL 문자열에 그대로 박으면 페이지마다 다른 SQL이 된다. 바인드 변수로 넘기는 방법과 커서 공유 문제는 11화에서 다룬다.


10. 총건수를 다루는 네 가지 ​

방법SQL계획Buffers화면 표시
① 매번 정확히 센다 (rev00)COUNT(*)TABLE ACCESS FULL5,826총 1,000,000건
① 〃 (rev01 인덱스 뒤)COUNT(*)INDEX FAST FULL SCAN3,365〃
② 상한까지만 센다COUNT(*) FROM (… WHERE ROWNUM <= 1001)COUNT STOPKEY13"1,000건 이상"
③ 통계의 행수SELECT num_rows FROM user_tables딕셔너리14(테이블 0)"약 100만 건(통계 수집 시점)"
④ 다음 페이지 유무만11번째 행(§9)—0(추가 쿼리 없음)"다음 ▶"

①의 비용은 표에 나오는 건수에 비례하고, ②~④는 상수다. 무엇을 고를지는 SQL이 아니라 그 숫자를 누가 무엇에 쓰는가에 달려 있다(§13).


11. ARRAYSIZE와 Fetch Call ​

클라이언트는 결과를 한 번에 받지 않는다. Fetch Call 한 번에 ARRAYSIZE건씩 여러 번 받으러 온다. SQL*Plus의 기본값은 15다.

실측 — 6월 27,390건 내려받기 (AUTOTRACE TRACEONLY STATISTICS) ​

ARRAYSIZEconsistent getsSQL*Net roundtrips시간
정렬 있음(FULL + 정렬)155,8271,8270.21~0.23초
〃1005,8262750.06~0.07초
〃10005,826290.04초
정렬 없음(FULL)157,6411,8270.21초
〃10005,854290.04초
화면 1페이지 10건 (rev01)1186—
〃15142—

읽는 법 ​

정렬 있음정렬 없음
첫 행이 나오는 때입력을 전부 읽은 뒤읽자마자
ARRAYSIZE가 바꾸는 것roundtrips만roundtrips와 consistent gets 둘 다
왜결과가 이미 work area에 다 있다Fetch Call이 끝날 때마다 읽던 블록을 놓고 다음 Call에서 다시 잡는다(약 1,800번)

부분범위 처리는 정렬처럼 전부를 기다려야 하는 오퍼레이션이 없을 때 필요한 만큼만 읽고 멈추는 성질이다. rev01과 rev02가 COUNT STOPKEY에서 멈출 수 있는 것도, ARRAYSIZE가 일량까지 바꾸는 것도 이 성질 때문이다(원천 I권 05장 "Fetch Call 최소화").

화면 프로그램의 Fetch 크기는 드라이버와 설정에 따라 다르다. 내려받기 기능이면 반드시 확인한다. PL/SQL에서 같은 일을 하는 BULK COLLECT LIMIT는 15화에서 다룬다.


12. 정렬 키의 정답성 — ora-dist 매출 목록 ​

정렬은 비싸기 전에 틀릴 수도 있다. 03화의 SLE_HMS 0 채움 누락(33,241건) 때문에 이번에는 페이징 화면의 순서가 틀어졌다. 03화에서 본 바로 그 데이터다.

실측 ​

SQL (최신순 1페이지)계획Buffers맨 윗줄
현업: ORDER BY SLE_DT DESC, SLE_HMS DESCFULL + SORT ORDER BY STOPKEY(40K)4,51120231231-0037 · 95503 (오전 9:55) ← 틀림
판매번호: ORDER BY SLE_NO DESCINDEX FULL SCAN DESCENDING CF10_PK · 정렬 없음720231231-0612 · 225914(그날 마지막)

SLE_NO DESC로 정렬하면 100페이지는 28, 1,000페이지는 225다(현업 정렬의 100페이지는 4,510, Top-N 802K).

정렬 키를 바꾸기 전에 잰 전제 ​

전제확인실측
판매번호 앞 8자리 = 판매일자SUBSTR(SLE_NO,1,8) = SLE_DT500,000 / 500,000
하루 안에서 판매번호 순서 = 시각 순서LAG로 센 역전 횟수0
판매번호는 유일하다PK CF10_PK○
(참고) 현업 정렬 키 (SLE_DT, SLE_HMS)는 유일한가같은 초에 겹치는 쌍13,585 ← 유일하지 않다

승인된 교정을 복사본에서 미리 확인 ​

복사본 CF10_PG(SLE_NO, SLE_DT, SLE_HMS)에 LPAD 교정길이 6 = 500,000
교정 후 현업 정렬 맨 윗줄20231231-0612 · 225914
50만 행 전체에서 (SLE_DT, SLE_HMS, SLE_NO) 순번 vs SLE_NO 순번이 다른 행0
원본 CF10손대지 않았다(길이 5는 여전히 33,241) · 복사본은 삭제

정렬 키의 세 조건: ① 원하는 순서를 실제로 표현하는가 ② 유일한가 ③ 그 순서의 인덱스가 있는가. SLE_NO는 셋 다 만족한다. 단 ①은 채번 규칙에 기댄 판단이다. "판매번호가 시간 순으로 매겨진다"를 대장에 전제로 적고, 채번 방식이 바뀌면 다시 잰다. 데이터 교정은 여전히 필요하다. 영업시간 집계는 SLE_HMS가 맞아야 맞는다(03화).


13. 화면 요구 재협상서 ​

이 교재의 새 표준 문서다. 양식은 화면요구_재협상서.md에 있다.

왜 필요한가 ​

Q4를 SQL만으로 고치면 rev01의 4,372가 바닥이다(100페이지 기준이고, 그마저 페이지가 깊어지면 다시 커진다). 15로 가는 길은 SQL 안에 없었다. 화면이 묻는 질문("몇 번째부터", "총 몇 건")을 바꿔야 했다.

그 질문은 튜너가 혼자 바꿀 수 없다. 화면을 쓰는 사람에게 그 기능을 무엇에 쓰는지 물어야 한다. 이 문서는 01화 T9의 중단 기준 ⓓ "설계를 바꿔야 하는 문제로 판명될 때(그건 튜닝이 아니라 재협상이다)"를 실행에 옮기는 문서다.

핵심 질문 (양식 요약) ​

#질문09화 1호의 답
1그 숫자(총건수 등)를 누가 · 무엇에 쓰나아무도 안 본다 · 정본은 월말 정산 리포트 → 없앤다
2그 페이지까지 왜 가나어제 마감분을 찾으려고(하루 = 91페이지 남짓) → "날짜로 가기"
3정렬 기준은 몇 개인가최신순 하나 → 그대로
4한 번의 조작이 읽는 양(전 → 후)11,652 → 약 15블록
5다음 재협상 조건정렬 기준이 추가될 때 · 검색 조건이 생길 때

14. 이 화의 측정값 전부 ​

모두 ora-hanorder와 ora-dist에서 잰 값이다. 환경: Oracle AI Database 26ai Free 23.26.2.0.0 · db_block_size 8192 · pga_aggregate_target 512M · workarea_size_policy AUTO · 히스토그램 없음(FOR ALL COLUMNS SIZE 1) · V$PGASTAT global memory bound 100MB. 데이터: T_ORD_BIG 1,000,000행 · 5,923블록 · ORD_DT NULL 허용(실제 0건) · ORD_SEQ NOT NULL · 하루 913~914건 · 인덱스는 02화 산출물 2개(+ 09화 1개).

Q4 리비전별 (100페이지 · 실습/00 · 02 · 03) ​

rev바뀐 것Q4 계획Q4Q4c한 번 클릭
rev00없음(강도현 원본)FULL + SORT ORDER BY STOPKEY 206K (0)5,8265,82611,652
rev01인덱스 (ORD_DT, ORD_SEQ) · SQL 그대로INDEX FULL SCAN DESCENDING · 정렬 없음1,0073,3654,372
rev02포스트잇 + Q4c 제거(재협상)INDEX RANGE SCAN DESCENDING · 정렬 없음15—약 15

FULL의 Buffers는 실행할 때마다 5,826 / 5,827로 1블록씩 흔들린다. 정상이다.

페이지 곡선 ​

→ §3 · 책상 × 페이지 → §4 · 10032 → §5

02화 인덱스가 정렬을 대신하지 못한 이유 (실습/00 [D]) ​

단계Buffers
INDEX_DESC(IX_ORD_BIG_DT) → 무시5,826
IS NOT NULL + ORDER BY ORD_DT DESC1,007
+ ORD_SEQ DESC + 힌트1,002K (인덱스 2,655 · SORT 67,584 (0))

STOPKEY (실습/00 [E]) ​

Used-Mem
ROWNUM 안쪽 1페이지4K
ROWNUM 바깥 1페이지48M (OMem 54M)
OFFSET … FETCH 100페이지206K (WINDOW SORT PUSHED RANK)

총건수 · ARRAYSIZE · ora-dist ​

→ §10 · §11 · §12


15. 원천 좌표 ​

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

성능고도화 II권 Ch5 — 소트 튜닝 (주 원천) ​

주제절책 페이지
소트 수행 과정(청크 → Run → Merge)01 (1)572
소트 오퍼레이션 측정01 (2)573
Sort Area(PGA · UGA · CGA)01 (3)576
소트 튜닝 요약01 (4)580
소트를 발생시키는 오퍼레이션(Aggregate · Order By · Group By · Unique · Join · Window)02581
데이터 모델 측면에서의 검토03592
소트가 발생하지 않도록 SQL 작성04598
인덱스를 이용한 소트 연산 대체(Sort Order By 대체 · 대체하지 못하는 경우)05602 · 605
Sort Area를 적게 사용하도록 SQL 작성 · Top-N 쿼리06 (1)(2)608 · 609
분석함수에서의 Top-N 쿼리06 (3)613
Sort Area 크기 조정(PGA 관리 방식 · 크기 결정 공식 · 적정 크기)07615 ~ 620

성능고도화 I권 05장 — 데이터베이스 Call 최소화 ​

주제책 페이지
Call 통계 · User Call vs Recursive Call346 · 349
데이터베이스 Call이 성능에 미치는 영향352
Array Processing 활용363
Fetch Call 최소화: 부분범위처리 원리369
OLTP 환경에서 부분범위처리에 의한 성능개선 원리375
ArraySize 조정에 의한 Fetch Call 감소 및 블록 I/O 감소 효과381
프로그램 언어에서 Array 단위 Fetch 기능 활용385
페이지 처리의 중요성389

오라클 공식문서 ​

주제매뉴얼장 제목
PGA와 SQL work area(optimal / one-pass / multipass)tgdbaTuning the Program Global Area
PGA 구조cncptMemory Architecture (Program Global Area)
PGA_AGGREGATE_TARGET · WORKAREA_SIZE_POLICY · SORT_AREA_SIZErefrnInitialization Parameters
V$SQL_WORKAREA · V$PGASTATrefrnDynamic Performance Views
인덱스 풀 스캔이 정렬을 대신하는 경우tgsqlOptimizer Access Paths (Index Full Scans)
OFFSET / FETCH FIRST (row_limiting_clause)sqlrfSELECT

이 화에서 꼭 확인할 뷰 ​

sql
V$SQL_WORKAREA            -- operation_type · estimated_optimal_size · last_execution · last_tempseg_size
V$SQL_PLAN_STATISTICS_ALL -- last_cr_buffer_gets · last_cu_buffer_gets (TEMP 군살은 cu 쪽에 붙는다)
V$PGASTAT                 -- global memory bound · over allocation count
V$MYSTAT / V$STATNAME     -- sorts (memory) · sorts (disk) · physical writes direct temporary tablespace
V$SESSION_EVENT           -- direct path write temp · direct path read temp

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

화가져가는 것
10포스트잇 계획의 TABLE ACCESS BY INDEX ROWID 줄에 나온 E-Rows 999K vs A-Rows 11. ROWNUM이 있을 때 옵티마이저의 셈, 그리고 Cost에 정렬 CPU가 들어가는 방식(935페이지에서 갈아타는 이유)
11포스트잇 값을 문자열로 박으면 페이지마다 다른 SQL이 된다. 바인드 변수와 커서 공유
14T_ORD_BIG 인덱스가 3개가 됐다(02화 2개 + IX_ORD_BIG_DT_SEQ). 적재 비용 청구서에 한 줄이 더 붙는다
15ARRAYSIZE를 PL/SQL에서 정하는 BULK COLLECT LIMIT
18원장 재측정: Q4+Q4c 11,652 → 약 15 · 재협상서 1호의 재협상 조건에 걸렸는가