테마
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 BY | ORDER BY 전체 정렬 | 입력 전부 |
SORT ORDER BY STOPKEY | Top-N 정렬(ROWNUM <= N이 안쪽에 있을 때) | 상위 N건만 |
WINDOW SORT PUSHED RANK | 분석함수 Top-N(FETCH FIRST / OFFSET 문법이 이것으로 바뀐다) | 상위 N건만 |
SORT AGGREGATE | COUNT(*)·SUM 한 줄 집계 | 정렬하지 않는다(이름만 SORT) |
SORT GROUP BY / HASH GROUP BY | 그룹별 집계 | 그룹 수만큼 |
SORT UNIQUE / HASH UNIQUE | DISTINCT · 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-Mem | Buffers |
|---|---|---|---|
1페이지 · ROWNUM <= 10 안쪽 (rev00 형태) | SORT ORDER BY STOPKEY | 4K (0) | 5,826 |
1페이지 · rn BETWEEN 1 AND 10 바깥에만 | SORT ORDER BY | 48M (0) | 5,826 |
100페이지 · ROWNUM <= 1000 안쪽 | SORT ORDER BY STOPKEY | 206K (0) | 5,826 |
100페이지 · OFFSET 990 ROWS FETCH NEXT 10 ROWS ONLY | WINDOW SORT PUSHED RANK | 206K (0) | 5,826 |
Buffers는 넷 다 같다. Top-N은 일량이 아니라 책상을 줄인다. 그래서 STOPKEY만으로는 "1페이지도 5,826"이라는 사실이 바뀌지 않는다.
강도현의 2019-05-20 커밋 "ROWNUM을 안쪽 인라인뷰로 옮김. 바깥에 두면 전체를 정렬함"은 옳았다. 48M → 4K.
Top-N의 책상은 페이지에 비례한다 (AUTO · 실측)
| 페이지 | N (= 페이지 × 10) | Used-Mem | 등급 | 시간 |
|---|---|---|---|---|
| 1 | 10 | 4K | optimal | 37~38ms |
| 10 | 100 | 24K | optimal | 37~40ms |
| 100 | 1,000 | 206K | optimal | 40~41ms |
| 1,000 | 10,000 | 1,999K | optimal | 52~56ms |
| 10,000 | 100,000 | 12,016K | optimal | 136~155ms |
| 100,000 (마지막) | 1,000,000 | 74,638K | optimal | 532~568ms |
Buffers는 여섯 줄 모두 5,826이다. 책상은 페이지와 함께 커진다. 정비례는 아니어서, 100페이지까지는 페이지당 약 2K, 마지막 페이지까지는 평균 약 0.75K씩 늘어난다.
3. 페이지 곡선 — 네 가지 SQL
이 화의 핵심 표다. 화면도 같고 결과 10건도 같다. Buffers만 다르다.
| 페이지 | rev00 FULL + Top-N 정렬 | rev01 인덱스 NOSORT(자율) | rev01 + 인덱스 강제 | 늦은 테이블 방문 | rev02 포스트잇 |
|---|---|---|---|---|---|
| 1 | 5,826 | 14 | 14 | — | 16 |
| 10 | 5,826 | 104 | 104 | — | — |
| 100 | 5,826 | 1,007 | 1,007 | 16 | 15 |
| 500 | — | 5,020 | 5,020 | — | — |
| 600 | — | 6,024 | 6,024 | — | — |
| 1,000 | 5,826 | 5,826 ← 정렬로 복귀 | 10,037 | 46 | 18 |
| 10,000 | 5,826 | 5,826 ← 정렬로 복귀 | 100,338 | 347 | 17 |
| 100,000 (마지막) | 5,826 | — | 1,003,347 | 3,356 | 17 |
| 곡선의 모양 | 평평하고 높다 | 기울었다(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 | 고른 계획 |
|---|---|---|
| 934 | 9,376 | INDEX FULL SCAN DESCENDING |
| 935 | 9,378 | SORT 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 · 실측)
| 책상 \ 페이지 | 1 | 10 | 100 | 1,000 | 10,000 |
|---|---|---|---|---|---|
| 64KB | optimal | optimal | 184 PASSES · 7,162 | 290 PASSES · 8,013 | 290 PASSES · 8,013 |
| 256KB | optimal | optimal | optimal | 22 PASSES · 6,152 | 22 PASSES · 6,152 |
| 1MB | optimal | optimal | optimal · 5,826 | 1 PASS · 5,831 | 2 PASSES · 5,901 |
| 4MB | optimal | optimal | optimal | optimal · 5,826 | 1 PASS · 5,847 |
| 16MB | optimal | optimal | optimal | optimal · 5,826 | optimal · 5,826 |
| AUTO (512M) | optimal | optimal | optimal · 5,826 | optimal | optimal (마지막 페이지도 optimal · 74,638K) |
(숫자는 Buffers다. optimal 칸은 전부 5,826 안팎이다)
시간 (보조 지표 — 흔들린다)
| 책상 · 페이지 | 등급 | 시간(여러 번 잰 범위) |
|---|---|---|
| AUTO · 100 | optimal | 0.04초 |
| 64KB · 100 | 184 PASSES | 6~24초(대부분 6초 안팎) |
| 64KB · 1,000 | 290 PASSES | 19~80초 |
| 256KB · 1,000 | 22 PASSES | 9~42초 |
| 1MB · 1,000 | 1 PASS | 0.10~0.36초 |
| 1MB · 10,000 | 2 PASSES | 1.5~6.2초 |
| 4MB · 10,000 | 1 PASS | 0.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_OPT | TEMP | 시간 |
|---|---|---|---|---|---|
| 1 · 10 | optimal | 5,826 (0) | 5K · 28K | — | 37ms |
| 20 | optimal | 5,827 | 48K | — | 58ms |
| 25 ~ 55 | 1 PASS | 5,829 ~ 5,834 | 36K ~ 162K | 1M | 46~86ms |
| 60 · 61 | 2 PASSES | 5,839 · 5,841 | 270K · 279K | 1M | 92 · 151ms |
| 62 | 3 PASSES | 5,846 | 387K | 1M | 167ms |
| 63 | 5 PASSES | 5,859 (33) | 639K | 1M | 171~253ms |
| 64 | 184 PASSES | 7,161 (1,335) | 24,795K | 26M | 5.2~7.2초 |
| 100 | 184 PASSES | 7,162 (1,336) | 24,831K | 29M | 6~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 (합치기 폭) | 3 | 29 |
Initial runs (②에서 내려놓은 묶음) | 920 | 4 |
Intermediate runs | 183 | — |
Number of merges | 184 (= 184 PASSES) | 1 |
Input records | 1,000,000 | 1,000,000 |
Output records | 615,924 | 83,081 |
Run directory block reads (buffer cache) | 1,336 | 5 |
마지막 줄이 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 tablespace | 3,674블록 |
physical reads direct temporary tablespace | 1,115~1,212블록 |
direct path write temp | 3,674번 · 5.7~10.6초 |
direct path read temp | 1,115~1,212번 · 4~11ms |
6. "PGA부터 올리자"를 판정하는 법
메모리가 바꾸는 것과 못 바꾸는 것
| 메모리를 올리면 | |
|---|---|
| 등급(패스 수) | 바뀐다. 184 PASSES → optimal |
| 시간 | 바뀐다. 6초 → 0.04초 |
Buffers | 5,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 + FULL | 5,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 STOPKEY | 1,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| rev00 | rev01 | |
|---|---|---|
| Q4 (100페이지) 계획 | SORT ORDER BY STOPKEY + TABLE ACCESS FULL | INDEX FULL SCAN DESCENDING + TABLE ACCESS BY INDEX ROWID + COUNT STOPKEY |
Q4 Buffers | 5,826 | 1,007(인덱스 7 + 테이블 1,000) |
| work area | 206K (0) | 없음. PGA와 무관 |
| Q4c 계획 | TABLE ACCESS FULL | INDEX FAST FULL SCAN ← SQL은 안 고쳤다 |
Q4c Buffers | 5,826 | 3,365 |
| Q1(02화 rev02의 SQL) | 91 | 91(변화 없음) |
| Q2(06화 rev01의 SQL) | 5,868 · plan_hash_value 2292855265 | 5,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) | 0 | 18 |
같은 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;| 페이지 | 100 | 1,000 | 10,000 | 100,000 |
|---|---|---|---|---|
Buffers | 16 | 46 | 347 | 3,356 |
⚠️
LEADING(x) USE_NL(t)를 빼면 적응형 계획이 10,000페이지에서HASH JOIN+T_ORD_BIGFULL을 골라 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만 있으면 범위의 시작점을 지목할 수 없다.
실측 — 몇 페이지째든 같다
| 페이지 | 1 | 2 | 3 | 100 | 1,000 | 10,000 | 100,000 (마지막) |
|---|---|---|---|---|---|---|---|
Buffers | 16 | 15 | 15 | 15 | 18 | 17 | 17 |
| 가져온 행 | 11 | 11 | 11 | 11 | 11 | 11 | 10 ← "다음 없음" |
정합성 회귀 검증 — 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 FULL | 5,826 | 총 1,000,000건 |
| ① 〃 (rev01 인덱스 뒤) | COUNT(*) | INDEX FAST FULL SCAN | 3,365 | 〃 |
| ② 상한까지만 센다 | COUNT(*) FROM (… WHERE ROWNUM <= 1001) | COUNT STOPKEY | 13 | "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)
| ARRAYSIZE | consistent gets | SQL*Net roundtrips | 시간 | |
|---|---|---|---|---|
| 정렬 있음(FULL + 정렬) | 15 | 5,827 | 1,827 | 0.21~0.23초 |
| 〃 | 100 | 5,826 | 275 | 0.06~0.07초 |
| 〃 | 1000 | 5,826 | 29 | 0.04초 |
| 정렬 없음(FULL) | 15 | 7,641 | 1,827 | 0.21초 |
| 〃 | 1000 | 5,854 | 29 | 0.04초 |
| 화면 1페이지 10건 (rev01) | 1 | 18 | 6 | — |
| 〃 | 15 | 14 | 2 | — |
읽는 법
| 정렬 있음 | 정렬 없음 | |
|---|---|---|
| 첫 행이 나오는 때 | 입력을 전부 읽은 뒤 | 읽자마자 |
| 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 DESC | FULL + SORT ORDER BY STOPKEY(40K) | 4,511 | 20231231-0037 · 95503 (오전 9:55) ← 틀림 |
판매번호: ORDER BY SLE_NO DESC | INDEX FULL SCAN DESCENDING CF10_PK · 정렬 없음 | 7 | 20231231-0612 · 225914(그날 마지막) |
SLE_NO DESC로 정렬하면 100페이지는 28, 1,000페이지는 225다(현업 정렬의 100페이지는 4,510, Top-N 802K).
정렬 키를 바꾸기 전에 잰 전제
| 전제 | 확인 | 실측 |
|---|---|---|
| 판매번호 앞 8자리 = 판매일자 | SUBSTR(SLE_NO,1,8) = SLE_DT | 500,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 계획 | Q4 | Q4c | 한 번 클릭 |
|---|---|---|---|---|---|
| rev00 | 없음(강도현 원본) | FULL + SORT ORDER BY STOPKEY 206K (0) | 5,826 | 5,826 | 11,652 |
| rev01 | 인덱스 (ORD_DT, ORD_SEQ) · SQL 그대로 | INDEX FULL SCAN DESCENDING · 정렬 없음 | 1,007 | 3,365 | 4,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 DESC | 1,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
15. 원천 좌표
전체 지도:
00_자료준비/06_원서와_공식문서_지도.mdPDF 페이지 = 책 페이지 − 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) | 02 | 581 |
| 데이터 모델 측면에서의 검토 | 03 | 592 |
| 소트가 발생하지 않도록 SQL 작성 | 04 | 598 |
| 인덱스를 이용한 소트 연산 대체(Sort Order By 대체 · 대체하지 못하는 경우) | 05 | 602 · 605 |
| Sort Area를 적게 사용하도록 SQL 작성 · Top-N 쿼리 | 06 (1)(2) | 608 · 609 |
| 분석함수에서의 Top-N 쿼리 | 06 (3) | 613 |
| Sort Area 크기 조정(PGA 관리 방식 · 크기 결정 공식 · 적정 크기) | 07 | 615 ~ 620 |
성능고도화 I권 05장 — 데이터베이스 Call 최소화
| 주제 | 책 페이지 |
|---|---|
| Call 통계 · User Call vs Recursive Call | 346 · 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) | tgdba | Tuning the Program Global Area |
| PGA 구조 | cncpt | Memory Architecture (Program Global Area) |
PGA_AGGREGATE_TARGET · WORKAREA_SIZE_POLICY · SORT_AREA_SIZE | refrn | Initialization Parameters |
V$SQL_WORKAREA · V$PGASTAT | refrn | Dynamic Performance Views |
| 인덱스 풀 스캔이 정렬을 대신하는 경우 | tgsql | Optimizer Access Paths (Index Full Scans) |
OFFSET / FETCH FIRST (row_limiting_clause) | sqlrf | SELECT |
이 화에서 꼭 확인할 뷰
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이 된다. 바인드 변수와 커서 공유 |
| 14 | T_ORD_BIG 인덱스가 3개가 됐다(02화 2개 + IX_ORD_BIG_DT_SEQ). 적재 비용 청구서에 한 줄이 더 붙는다 |
| 15 | ARRAYSIZE를 PL/SQL에서 정하는 BULK COLLECT LIMIT |
| 18 | 원장 재측정: Q4+Q4c 11,652 → 약 15 · 재협상서 1호의 재협상 조건에 걸렸는가 |