Skip to content

09화 · 100페이지째부터 화면이 멈춘다 ​

4월 29일 월요일, 01화에서 재 놓고 한 번도 열지 않은 Q4를 연다. 운영팀은 "PGA부터 올리자"고 한다. 근거로 든 문장은 내가 06화 리포트에 쓴 문장이다.


오전 9시 00분 ​

출근해 보니 박팀장의 메시지가 와 있었다.

박팀장  08:52
정산팀 조민우 씨 건입니다. 정산 목록 화면이 100페이지쯤 넘어가면 멈춘답니다.
1페이지는 괜찮답니다.
그런데 지난번 리포트(T1 4호)에 "PGA 가 줄면 같은 계획이 TEMP 를 쓰기 시작하고
수십 배 느려진다"고 쓰셨더군요. 그럼 PGA 부터 올리면 되는 것 아닙니까?
인프라팀은 오늘 오후에 올릴 수 있다고 합니다.

메시지를 두 번 읽었다. 처음엔 좀 우쭐해서였다. 박팀장이 내 리포트의 「환경 의존성」 칸까지 읽은 것이다. 06화에서 처음으로 채운 바로 그 칸이었다.

두 번째로 읽은 건 불안해서였다. 내가 쓴 문장이 처방전이 되어 돌아와 있었다.

선배가 커피를 들고 지나가다 화면을 봤다. "네가 쓴 문장이 돌아왔네."

"네. 근데 틀린 말은 아니잖아요. 06화에서 64KB로 좁혔더니 28배에서 47배나 느려졌으니까요."

"그럼 올리면 돼?"

대답하려다 말았다. 06화에서 잰 건 Q2의 해시 조인이었고, 박팀장이 말하는 건 정산 목록 화면이었다. 그 화면이 무슨 SQL인지는 아직 열어 보지도 않았다.

"…재 봐야죠."

"그게 맞는 순서야." 선배가 의자를 끌어왔다.


오전 9시 20분 — 8주 만에 여는 조각 ​

리비전부터 열었다. Q4_rev00.sql은 01화에서 재 놓고 한 번도 손대지 않은 조각이다.

sql
SELECT *
  FROM (SELECT ROWNUM rn, a.*
          FROM (SELECT ORD_SEQ, CUS_ID, ORD_DT, ORD_AMT
                  FROM T_ORD_BIG ORDER BY ORD_DT DESC, ORD_SEQ DESC) a
         WHERE ROWNUM <= 1000)
 WHERE rn > 990;

-- Q4c : 화면 하단 "총 N건"
SELECT COUNT(*) FROM T_ORD_BIG;

rn > 990. 991번째부터 1,000번째까지이고, 한 페이지가 10건이니까 100페이지째다. 01화에서 강도현의 패키지를 돌릴 때부터 이 조각은 줄곧 100페이지를 재고 있었다.

01화 원장에 적힌 숫자는 Q4 5,826 + Q4c 5,826 = 11,652였다. 화면 한 장에 10건을 보여 주려고 테이블 전체를 두 번 읽는 셈이다.

그런데 마음에 걸리는 게 하나 있었다.

"선배님, 02화에서 ORD_DT에 인덱스를 만들었잖아요. 이 SQL은 ORDER BY ORD_DT DESC고요. 인덱스는 정렬된 목록이라면서요. 그럼 Q4도 그사이에 바뀌었을 수 있지 않아요?"

"좋은 의심이야. 재 봐."

xplan
| Id  | Operation               | Name      | Starts | E-Rows | A-Rows |   A-Time   | Buffers |  OMem |  1Mem | Used-Mem |
|   0 | SELECT STATEMENT        |           |      1 |        |     10 |00:00:00.04 |    5826 |       |       |          |
|*  1 |  VIEW                   |           |      1 |   1000 |     10 |00:00:00.04 |    5826 |       |       |          |
|*  2 |   COUNT STOPKEY         |           |      1 |        |   1000 |00:00:00.04 |    5826 |       |       |          |
|   3 |    VIEW                 |           |      1 |   1000K|   1000 |00:00:00.04 |    5826 |       |       |          |
|*  4 |     SORT ORDER BY STOPKEY|          |      1 |   1000K|   1000 |00:00:00.04 |    5826 |   232K|   232K|  206K (0)|
|   5 |      TABLE ACCESS FULL  | T_ORD_BIG |      1 |   1000K|   1000K|00:00:00.02 |    5826 |       |       |          |

5,826. 01화와 같았다. 연산도 TABLE ACCESS FULL에 SORT ORDER BY STOPKEY 그대로였고, Q4c도 5,826 그대로였다.

02화 인덱스는 Q4를 건드리지도 못했다.

"왜 안 탔을까요?"

"그건 이따 해." 선배가 손을 저었다. "지금 네 앞에 있는 건 '100페이지에서 멈춘다'는 증언이야. 증언부터 재 봐."


오전 9시 50분 — 1페이지는 정말 괜찮은가 ​

조민우 씨의 말을 그대로 옮기면 두 문장이었다. 1페이지는 괜찮다. 100페이지쯤 넘어가면 멈춘다.

페이지 번호에 따라 뭔가가 달라진다는 얘기였다. 같은 SQL을 페이지 번호만 바꿔 가며 여섯 번 돌렸다. 1, 10, 100, 1,000, 10,000페이지, 그리고 마지막 페이지인 100,000페이지다.

    PAGE    BUFFERS USED_MEM_KB LAST_EXEC        MS
-------- ---------- ----------- ---------- --------
       1       5826           4 OPTIMAL        37.2
      10       5826          24 OPTIMAL        37.5
     100       5826         206 OPTIMAL        40.4
    1000       5826       1,999 OPTIMAL        51.7
   10000       5826      12,016 OPTIMAL       136.2
  100000       5826      74,638 OPTIMAL       532.0

Buffers 열에서 눈이 멈췄다. 여섯 줄이 전부 5,826이었다.

"선배님. 1페이지도 5,826이에요. …1페이지가 괜찮은 게 아니었네요. 1페이지도 테이블을 통째로 읽고 있었어요. 다만 빨리 끝나서 아무도 몰랐던 거고요."

"그래. 그럼 1페이지와 100페이지는 뭐가 달라?"

다른 건 USED_MEM_KB 한 열뿐이었다. 4 → 24 → 206 → 1,999 → 12,016 → 74,638. 06화에서 본 그 열, 바로 책상이었다. 100페이지를 보려면 최신 1,000건을 줄 세워야 하고, 마지막 페이지라면 100만 건 전부를 줄 세워야 한다. 줄 세울 건수가 페이지 번호에 비례하니 책상도 따라서 넓어졌다.

"그런데 이상하지 않아? 100페이지는 1,000건을 줄 세우려고 100만 건을 다 읽었는데, 책상은 206K밖에 안 썼어. 100만 건을 다 책상에 올리면 그보다 훨씬 클 텐데."

그건 생각해 보지 못했다. 선배가 말했다. "해 봐. ROWNUM 조건을 안쪽에서 빼서 바깥에만 걸어 봐."

sql
SELECT * FROM (SELECT ROWNUM rn, a.*
                 FROM (SELECT ORD_SEQ, CUS_ID, ORD_DT, ORD_AMT
                         FROM T_ORD_BIG ORDER BY ORD_DT DESC, ORD_SEQ DESC) a)
 WHERE rn BETWEEN 1 AND 10;
|   4 |     SORT ORDER BY     |           |      1 |   1000K|   1000K|00:00:00.20 |    5826 |    54M|  2574K|   48M (0)|

1페이지인데 48M이었다. STOPKEY라는 글자도 사라져 있었다.

"STOPKEY가 붙은 정렬은 위에서 몇 건만 필요한지 알고 하는 정렬이야." 선배가 말했다. "책상에는 '지금까지 본 것 중 최신 1,000건'만 올려 두고, 그보다 오래된 건 보자마자 버려. 그래서 100만 건을 다 넘겨 보면서도 책상에는 1,000건 자리만 있으면 돼. 이걸 Top-N 정렬이라고 해. ROWNUM을 바깥에만 두면 오라클은 몇 건에서 멈출지 모르니까 100만 건을 전부 책상에 올려."

커밋 로그를 찾아봤다.

2019-05-20  hd.kang
  정산 목록 페이징: ROWNUM 을 안쪽 인라인뷰로 옮김. 바깥에 두면 전체를 정렬함.
  총건수 표시는 정산팀 화면 요구사항.

강도현은 알고 있었다. ROWNUM을 안쪽에 둔 건 실수가 아니라 일부러 고친 결과였다. 48M을 206K로 줄인 사람이 바로 강도현이었다.

두 번째 줄도 다시 읽었다. 총건수 표시는 정산팀 화면 요구사항. 페이지마다 5,826블록을 읽는 Q4c는 강도현이 원해서 넣은 게 아니었다.

"그런데 100페이지가 0.04초예요. 안 멈추는데요."

"여긴 너 혼자 쓰는 서버야." 선배가 말했다. "정산팀은 이 화면을 월말 오전에 다 같이 써. 그때 한 사람이 받는 책상은 몇 KB일까?"


오전 10시 30분 — 책상을 좁힌다 ​

06화에서 쓴 방법을 그대로 꺼냈다. 이 컨테이너에서 동시 사용자 수십 명을 만들어 낼 수는 없으니, 대신 내 세션 하나의 책상 크기를 직접 정한다.

sql
ALTER SESSION SET workarea_size_policy = MANUAL;
ALTER SESSION SET sort_area_size = 65536;     -- 64KB. 06화와 같은 값

그리고 페이지를 하나씩 넘겼다.

TAG  DESK      PAGE    BUFFERS CU_GETS LAST_EXEC    USED_KB   OPT_KB TEMP_MB         MS
W1   64K          1       5826       0 OPTIMAL            4        5               36.9
W1   64K         10       5826       0 OPTIMAL           24       28               37.4
W1   64K         50       5832       6 1 PASS            49      135       1       63.6
W1   64K         63       5859      33 5 PASSES          65      639       1      171.2
W1   64K         64       7161   1,335 184 PASSES        65   24,795      26    5,159.6
W1   64K        100       7162   1,336 184 PASSES        65   24,831      29    5,908.1

1페이지 37밀리초, 10페이지 37밀리초. 50페이지에서 64밀리초로 조금 느려졌고, 63페이지는 171밀리초였다. 그리고 64페이지에서 5초가 걸렸다. 한 페이지 차이로 5패스가 184패스가 됐고, TEMP는 1MB에서 26MB로 뛰었다.

"……멈췄네요."

조민우 씨가 한 말이 그대로 재현됐다. 1페이지는 괜찮고, 뒤로 갈수록 조금씩 느려지다가, 어느 페이지에서 갑자기 멈춘다.

100페이지의 실행계획을 띄웠다.

|*  4 |     SORT ORDER BY STOPKEY|  ...  |00:00:05.95 |    7162 |   1212 |   3674 |    24M|  1796K|66560  (18|      29M|

괄호 안 숫자가 세 자리라 칸이 모자라서 잘려 있었다. 대신 V$SQL_WORKAREA를 조회하니 184 PASSES로 나왔다. 세션 통계도 찍어 봤다.

SORTS_DISK TEMP_READS TEMP_WRITES
         1      1,212       3,674

EVENT                       WAITS  WAITED_MS
direct path write temp       3674       5783
direct path read temp        1212          5

sorts (disk)가 1 올랐고, direct path write temp 대기가 3,674번 있었다. 그 대기 시간만 5.8초였다.

💡 시간은 잴 때마다 흔들린다. 같은 100페이지인데도 6초 안팎, 가장 나쁠 때는 24초가 나왔다. 컨테이너가 쓰는 디스크가 바쁘면 TEMP 쓰기가 길어진다. 흔들리지 않는 값은 184 PASSES와 3,674블록이다.

BUFFERS도 5,826에서 7,162로 늘어 있었다. 06화의 해시 조인은 TEMP를 써도 블록 수가 그대로였는데, 이번엔 1,336이 더 붙어 있었다. CU_GETS 열의 값이 정확히 그 숫자였다. 그 정체는 오후에 밝혀진다.


오전 11시 00분 — 내 첫 번째 답 ​

"그럼 박팀장님 말이 맞는 거네요." 내가 말했다. "책상이 좁아서 멈추는 거니까, PGA를 올리면 돼요."

"올려 봐."

64KB를 1MB로 올렸다. 16배다.

W2   1M         100       5826       0 OPTIMAL          206      232              41.1

100페이지가 41밀리초로 돌아왔다. 박팀장에게 보낼 답장을 머릿속으로 쓰기 시작했는데, 선배가 물었다. "1,000페이지는?"

W2   1M        1000       5831       5 1 PASS         1,025    1,620       2     102.6
W2   1M       10000       5901      75 2 PASSES       1,032   40,104      37   1,578.3

1,000페이지에서 다시 TEMP로 내려갔다. 10,000페이지는 2패스에 1.5초였다. "16MB로 올려 봐."

W3   16M       1000       5826       0 OPTIMAL        1,999    2,250              51.6
W3   16M      10000       5826       0 OPTIMAL       12,016   13,519             146.5

10,000페이지까지는 살아났다. 마지막 페이지는 AUTO로 돌려 넉넉히 줘 봤다. 74,638K, 약 73MB를 쓰고 나서야 0패스로 끝났다.

결과를 표 한 장으로 모았다. 그날 잰 다른 크기도 함께 넣었다.

책상 \ 페이지1101001,00010,000
64KBoptimaloptimal184패스290패스290패스
256KBoptimaloptimaloptimal22패스22패스
1MBoptimaloptimaloptimal1패스2패스
4MBoptimaloptimaloptimaloptimal1패스
16MBoptimaloptimaloptimaloptimaloptimal
Buffers5,8265,8265,826 ~ 7,1625,826 ~ 8,0135,826 ~ 8,013

"이 표를 한 문장으로 말해 봐."

대각선이 보였다. 책상을 넓힐 때마다 멈추는 칸이 오른쪽으로 밀려났을 뿐, 없어지지는 않았다.

"…PGA를 올리면 멈추는 페이지가 뒤로 밀릴 뿐이에요. 그리고 맨 아랫줄을 보면 Buffers는 5,826 아래로 한 블록도 안 내려가요." 책상을 넓혀 봤자 TEMP 때문에 붙었던 군살(최대 2,187)만 빠질 뿐이었다.

"책상을 넓혀도 100만 건을 처음부터 끝까지 넘겨 보는 일은 그대로야." 선배가 말했다. "PGA는 정렬을 빨리 하게 해 줄 뿐이지 정렬을 안 하게 해 주지는 않아. 그리고 하나 더. 마지막 페이지는 한 번에 73MB야. 한 사람이 한 번 누를 때마다 말이야. 정산팀이 몇 명이지?"

머릿속에서 쓰던 박팀장 답장을 지웠다.


⛳ 오늘은 여기까지(중단 지점 1) 여기까지가 오늘의 절반이다. 왜 멈추는지, 그리고 PGA를 올리면 무엇이 바뀌고 무엇이 그대로인지를 봤다. 이어서 읽으면 정렬을 아예 없앤다. 그 대가로 "뒤로 갈수록 느려진다"는 현상이 진짜로 나타난다.


오후 1시 10분 — 그 5초 동안 ​

점심을 먹고 와서도 184라는 숫자가 계속 걸렸다. "184패스면 뭘 184번 한 거예요?"

"트레이스로 볼 수 있어." 선배가 이벤트 하나를 알려 줬다. 정렬 하나가 끝날 때 내부 통계를 파일로 떨궈 주는 10032였다.

---- Sort Parameters ------------------------------
sort_area_size                    65536
max intermediate merge width      3
---- Sort Statistics ------------------------------
Initial runs                              920
Intermediate runs                         183
Number of merges                          184
Input records                             1000000
Run directory block reads (buffer cache)  1336

"Initial runs 920. 이게 뭘까?"

06화의 책상을 떠올렸다. "책상에 펼칠 수 있는 만큼… 64KB만큼씩 줄 세워서, 묶음으로 바닥에 내려놓은 게 920개요?"

"그래. 그 묶음을 run이라고 해. 그럼 마지막에는 920개 묶음을 하나로 합쳐야지. 합칠 때는 묶음마다 맨 위 한 장씩만 책상에 올려놓고, 그중 제일 최신 걸 집어 가는 거야. 그런데 64KB 책상에는 묶음 몇 개의 맨 윗장이 한 번에 올라갈까?"

max intermediate merge width 3. "…세 개요."

"920개 묶음을 세 개씩 합쳐서 새 묶음을 만들고, 그걸 또 세 개씩 합치고. 그렇게 184번 합친 거야. multipass가 그런 뜻이야." 1MB 책상으로 같은 트레이스를 떠 보니 합치기 폭 29, 묶음 4개, 합치기 1번이었다. 한 번에 합치기가 끝나는 onepass다. 책상 위에서 정렬이 다 끝나면 묶음 자체가 생기지 않는데, 이것이 optimal이다.

"정렬은 원래 이 세 단계야." 선배가 화이트보드에 썼다.

① 책상만큼 읽어서 줄 세운다  → ② 묶음(run)으로 내려놓는다  → ③ 묶음들을 합친다(merge)
   optimal  : ①에서 끝
   onepass  : ②를 하고, ③을 한 번에
   multipass: ③을 여러 번

"그리고 Run directory block reads 1336. 아침에 Buffers에 붙었던 그 1,336이야. 바닥에 내려놓은 묶음들의 목차를 버퍼 캐시에서 읽은 값이지. 일량이 늘어서 생긴 게 아니라 TEMP를 쓰는 동안에만 붙는 군살이야."

63페이지와 64페이지 사이의 절벽도 여기서 설명이 됐다. 63페이지까지는 5패스여도 TEMP가 1MB였다. 상위 630건만 추려 가며 정렬하고 있었다는 뜻으로 읽혔다. 그런데 64페이지에서는 오라클이 추정한 optimal 크기가 639K에서 24,795K로, TEMP가 1MB에서 26MB로 한꺼번에 뛰었다. 상위 몇 건만 쥐고 가던 정렬이, 들어오는 행을 거의 다 바닥에 내려놓는 정렬로 바뀐 것이다(트레이스로 보면 100만 건이 들어와서 615,924건이 묶음으로 나갔다).

💡 10032 트레이스는 실습/90_정렬_트레이스_10032.sql로 직접 뜰 수 있다. 읽는 법은 2_파고들기.md §5에 있다.


오후 1시 40분 — 정렬된 목록이라면서요 ​

"그럼 책상을 아예 안 쓰게 할 수는 없어요?"

"아침에 네가 한 질문으로 돌아가 봐."

02화에서 만든 인덱스 IX_ORD_BIG_DT(ORD_DT)는 정렬된 목록이다. 그런데 Q4는 왜 또 정렬을 하는가.

일단 INDEX_DESC(T_ORD_BIG IX_ORD_BIG_DT) 힌트를 줘서 그 인덱스를 거꾸로 읽게 해 봤다. 실행계획은 그대로 SORT ORDER BY STOPKEY와 TABLE ACCESS FULL이었다. 힌트가 또 무시당했다. 02화에서 TO_CHAR를 씌운 채로 힌트를 줬을 때와 같았다.

"02화 때는 컬럼을 가공해서였잖아요. 지금은 가공도 안 했는데요."

"02화 2_파고들기 맨 앞에 적어 둔 게 있어. B*Tree의 성질 하나."

그 문서를 열었다. §1에 경고 상자가 있었다. NULL은 저장되지 않는다.

COLUMN_NAM NULLABLE              ORD_DT_NULL_ROWS
ORD_DT     Y                                    0
ORD_SEQ    N

ORD_DT는 NULL을 허용하는 컬럼이었다. 실제 NULL은 0건인데도 그랬다. "0건인데요."

"지금은 0건이지. 오라클은 지금 데이터를 보고 계획을 세우는 게 아니라 약속을 보고 세워. NULL이 들어올 수 있는 컬럼에 만든 단일 인덱스는 '모든 행이 여기 있다'고 약속하지 못해. 그 인덱스만 읽어서 줄을 세우면 NULL인 행이 빠질 수 있잖아."

그럼 약속을 대신 해 주면 될 것 같았다. WHERE ORD_DT IS NOT NULL을 붙이고, 정렬 키도 ORD_DT 하나로 줄였다.

xplan
|   4 |     TABLE ACCESS BY INDEX ROWID| T_ORD_BIG     |      1 |   1000K|   1000 |00:00:00.01 |    1007 |
|*  5 |      INDEX FULL SCAN DESCENDING| IX_ORD_BIG_DT |      1 |   1000 |   1000 |00:00:00.01 |       7 |

SORT 줄이 사라졌다. 인덱스를 거꾸로 7블록 읽고, 테이블에 1,000번 갔다가, 1,000건째에서 멈췄다. 1,007.

"됐다!" 내가 말하자 선배가 고개를 저었다. "정렬 키를 원래대로 돌려 봐. ORD_SEQ DESC까지."

xplan
|*  4 |     SORT ORDER BY STOPKEY               |               |  ...  |    1002K| 76800 | 76800 |67584  (0)|
|   5 |      TABLE ACCESS BY INDEX ROWID BATCHED| T_ORD_BIG     |  ...  |    1002K|       |       |          |
|*  6 |       INDEX FULL SCAN DESCENDING        | IX_ORD_BIG_DT |  ...  |    2655 |       |       |          |

힌트로 밀어붙였더니 인덱스 전체 2,655블록을 읽고 테이블에 100만 번 간 다음, 또 정렬을 했다. 1,002K.

"ORD_SEQ가 인덱스에 없으니까요. 같은 날짜끼리는 순서를 인덱스가 모르니까 결국 전부 다시 줄 세워야 하고…"

"그래. 없는 게 두 개야. NULL에 대한 약속, 그리고 두 번째 정렬 키." 선배가 말했다. "그런데 ORD_SEQ는 왜 붙였을까? 날짜만으로 정렬하면 안 돼?"

같은 날짜인 행이 하루에 몇 건인지 세어 봤다. 913~914건. 하루치 주문이 전부 같은 ORD_DT 값이었다(02화에서 본 대로 전부 자정이다).

"날짜만으로는 하루 913건끼리 순서가 안 정해져요. 그러면 페이지 경계에서 어떤 행이 9페이지에 나올지 10페이지에 나올지가…"

"실행할 때마다 달라질 수 있어. 정렬 키는 유일해야 해." 선배가 말했다. "강도현 씨가 또 맞았네."

이제 답은 하나였다. 정렬 키 두 개를 그 순서대로 담은 인덱스다. 그 인덱스는 NULL 문제도 함께 푼다. ORD_SEQ가 NOT NULL이니까 복합 인덱스에서는 ORD_DT가 NULL이어도 행이 빠지지 않는다. 02화 경고 상자의 두 번째 줄에 적힌 내용이었다.

sql
CREATE INDEX IX_ORD_BIG_DT_SEQ ON T_ORD_BIG(ORD_DT, ORD_SEQ);

그리고 SQL은 한 글자도 바꾸지 않고 다시 돌렸다.

xplan
| Id  | Operation                      | Name              | Starts | E-Rows | A-Rows |   A-Time   | Buffers |
|   0 | SELECT STATEMENT               |                   |      1 |        |     10 |00:00:00.01 |    1007 |
|*  1 |  VIEW                          |                   |      1 |   1000 |     10 |00:00:00.01 |    1007 |
|*  2 |   COUNT STOPKEY                |                   |      1 |        |   1000 |00:00:00.01 |    1007 |
|   3 |    VIEW                        |                   |      1 |   1000 |   1000 |00:00:00.01 |    1007 |
|   4 |     TABLE ACCESS BY INDEX ROWID| T_ORD_BIG         |      1 |   1000K|   1000 |00:00:00.01 |    1007 |
|   5 |      INDEX FULL SCAN DESCENDING| IX_ORD_BIG_DT_SEQ |      1 |   1000 |   1000 |00:00:00.01 |       7 |

5,826 → 1,007.

그리고 오른쪽 세 열(OMem, 1Mem, Used-Mem)이 통째로 없었다.

"책상이 없어요."

"정렬을 안 하니까. 이제 이 SQL은 PGA가 얼마든 상관없어." 선배가 말했다. "아침에 박팀장이 물어본 것에 대한 진짜 답이 이거야. PGA를 올리는 게 아니라 아예 안 쓰게 하는 거."

이것이 Q4_rev01이다. 02화의 rev02처럼 SQL은 그대로이고 인덱스 하나가 달라졌다.


오후 2시 00분 — 손대지 않은 쿼리 ​

"좋아. 그런데 하나만 확인해." 선배가 말했다. "Q4c도 돌려 봐."

"Q4c는 안 고쳤는데요."

"그러니까."

xplan
|   2 |   INDEX FAST FULL SCAN| IX_ORD_BIG_DT_SEQ |      1 |   1000K|   1000K|00:00:00.04 |    3365 |

5,826 → 3,365. COUNT(*)가 방금 만든 인덱스로 세고 있었다.

나는 아무것도 안 했는데 실행계획이 바뀌어 있었다. 이번엔 좋은 쪽으로 바뀌었다. 그런데 좋은 쪽이라서 오히려 더 오싹했다. 나쁜 쪽으로 바뀌었어도 나는 몰랐을 것이다.

"왜 이게 바뀌었는지 말해 봐."

"ORD_SEQ가 NOT NULL이니까 이 인덱스엔 100만 행이 다 있다는 게 보장되고… 그럼 테이블 대신 더 작은 인덱스를 세면 되니까요."

"02화에서 내가 뭐라고 했지? 운영이면 인덱스를…"

"INVISIBLE로 먼저 만들라고 하셨죠. 운영 중에 인덱스가 갑자기 생기면 쿼리 수십 개의 실행계획이 한꺼번에 바뀐다." 나는 그 문장을 그대로 외우고 있었다. "그게 이거였네요."

Q1과 Q2도 돌려 봤다. Q1은 그대로 91이었고, Q2도 그대로 해시 조인에 5,868이었다. 이번에는 Q4c 하나만 바뀌었다.

⚠️ 그런데 실제로 INVISIBLE로 만들어 내 세션에서만 켜 보니, Q4c는 그 인덱스를 썼는데 정작 Q4는 정렬 계획 그대로였다(힌트를 주면 1,007로 탄다). 이 버전에서 확인한 사실이고, 원인은 찾지 못했다. 자세한 내용은 2_파고들기.md §7에 있다.


오후 2시 30분 — 뒤로 가 본다 ​

이제 박팀장에게 보낼 숫자가 생겼다. 그런데 선배는 거기서 멈추지 않았다. "아침에 했던 거 다시 해 봐. 페이지를 뒤로 넘겨."

TAG     PAGE    BUFFERS SORT_OP
E          1         14 (정렬 없음)
E         10        104 (정렬 없음)
E        100       1007 (정렬 없음)
E        500       5020 (정렬 없음)
E        600       6024 (정렬 없음)
E       1000       5826 SORT ORDER BY STOPKEY
E      10000       5826 SORT ORDER BY STOPKEY

14 → 104 → 1,007 → 5,020 → 6,024. 한 페이지마다 10블록씩 늘었다. 600페이지에서는 이미 테이블 전체(5,826)보다 많이 읽고 있었다. 그리고 1,000페이지부터는 옵티마이저가 아침의 그 계획, 즉 전체를 읽고 정렬하는 계획으로 돌아가 있었다. 갈아타는 지점을 찾아보니 934페이지까지는 인덱스, 935페이지부터는 정렬이었다.

힌트로 인덱스를 끝까지 강제해 봤다. 1,000페이지는 10,037, 10,000페이지는 100,338, 마지막 페이지는 1,003,347이었다.

"이제야 보이네요." 내가 말했다. "'뒤로 갈수록 느려진다'는 게 Buffers로요. 아침엔 1페이지나 100페이지나 똑같았는데."

"왜 10블록씩이야?"

"한 페이지가 10건이고… 앞 페이지의 행을 하나 버릴 때마다 테이블에 한 번씩 가니까요. 04화에서 본 CF가 100만이라, 가는 족족 다른 블록이고요."

"그래서 100페이지를 보려면 앞의 990건을 테이블까지 가서 꺼내 보고 버려." 선배가 말했다. "02화에서 내가 너한테 뭘 물었지?"

테이블에 왜 가야 해? "…버릴 행은 테이블까지 갈 필요가 없어요. 인덱스에서 몇 번째인지만 세고, 마지막 10건만 테이블에 가면 돼요."

인덱스에서 ROWID만 뽑아 1,000번째까지 세고, 991~1,000번째 ROWID 10개로만 테이블에 가는 SQL을 짰다.

H        100         16
H       1000         46
H      10000        347
H     100000       3356

100페이지가 1,007 → 16. 마지막 페이지도 3,356으로 테이블 전체보다 쌌다.

"훨씬 낫지." 선배가 말했다. "그런데 기울기가 0이 됐어?"

아니었다. 16 → 46 → 347 → 3,356. 값은 여전히 페이지를 따라 커졌다. 기울기가 300분의 1쯤으로 줄었을 뿐이다. 인덱스 리프 한 블록에 300건쯤 들어 있으니, 300건을 셀 때마다 한 블록씩 읽는 셈이다.

"아직 세고 있잖아." 선배가 일어섰다. "따라와."


오후 2시 50분 — 포스트잇 ​

선배가 향한 곳은 02화 때 그 안내 데스크였다. 선배는 캐비닛에서 그 바인더를 다시 꺼냈다. 방문 일시 순으로 적힌 정렬된 목록, 방문객 출입대장이었다.

"최근 방문자부터 거꾸로, 열 명씩 불러 줘. 1페이지."

쉬웠다. 마지막 장을 펴서 맨 아래 열 줄을 읽었다.

"100페이지."

마지막 장 맨 아래에 손가락을 대고 위로 세기 시작했다. 하나, 둘, 셋… 줄을 짚으며 장을 넘겼다. 한참 걸렸다. 990. 그리고 열 줄을 읽었다.

"101페이지."

다시 마지막 장을 펴고 손가락을 맨 아래에 대려는 순간이었다.

"왜 처음부터 세?"

"……어디까지 셌는지 모르니까요."

선배가 데스크 위의 포스트잇을 한 장 뜯었다. 그리고 내가 방금 읽은 열 줄 바로 위, 다음에 읽을 줄에 붙였다.

"101페이지."

포스트잇이 붙은 곳을 펴고 거기서부터 열 줄을 읽었다. 셀 게 없었다.

"아까 SQL이 하던 게 그 손가락이야. '몇 번째부터'를 물으면 앞에서부터 세는 수밖에 없어. 990번째가 어디 있는지는 990까지 세 봐야 알아. 페이지가 뒤로 갈수록 느려지는 건 그래서야. OFFSET의 본질은 세기야."

"포스트잇은요?"

"'어디부터'를 묻는 거야. 지난번에 멈춘 자리의 값을 들고 와서 거기서부터 열 줄을 읽는 거지. 인덱스는 정렬된 목록이니까 그 값이 어디 있는지는 찾아가면 돼. 02화에서 배웠지? 지목할 수 있으면 access야."


오후 3시 10분 — 세지 않는 SQL ​

자리로 돌아와 포스트잇을 SQL로 옮겼다.

10건을 보여 주려고 11건을 가져온다. 11번째 행이 있으면 "다음 페이지가 있다"는 뜻이고, 그 11번째 행의 (ORD_DT, ORD_SEQ)가 다음 페이지의 시작점, 곧 포스트잇이 된다. 출입대장에서 선배가 포스트잇을 붙인 자리도 다음에 읽을 줄이었다.

sql
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'))

INDEX FULL SCAN이 아니라 INDEX RANGE SCAN이었다. 조건도 access였다. 포스트잇 자리를 찾아간 것이다.

페이지를 뒤로 넘겨 봤다.

TAG     PAGE    BUFFERS ROWS_OUT
A          1         16       11
B          2         15       11
B          3         15       11
C        100         15       11
C       1000         18       11
C      10000         17       11
C     100000         17       10

1페이지 16, 100페이지 15, 마지막 페이지 17. 곡선이 아니라 직선이었다. 마지막 페이지에서는 10건만 나왔다. 11번째 행이 없으니 "다음 없음"이다.

그 직선을 한참 들여다봤다. 아침의 5,826도, 오후의 1,007도, 마지막 페이지의 1,003,347도 전부 세느라 쓴 값이었다.

그리고 지난주에 만든 정합성 회귀 체크리스트를 꺼냈다. 건수와 합계, 양방향 MINUS를 맞춰 보는 표다. 아침에 rev00으로 본 100페이지와 포스트잇으로 본 100페이지는 건수 10 = 10, 합계 2,627,200 = 2,627,200, MINUS 0 / 0이었다. 날짜가 바뀌는 92페이지(12월 30일 3건 + 29일 7건)도 똑같았다.

그런데 체크리스트에는 C 항목이 있었다. 잘라 내기 전 전체로. 화면에 나온 10건만 맞춰 보는 건, 지난주에 TOP 5만 보고 넘어갈 뻔했던 것과 같은 실수다. 그래서 포스트잇으로 10만 페이지를 끝까지 넘기면서 전체 정렬 결과와 한 줄씩 맞춰 봤다.

pages=100000 rows=1000000 sum=255398623700 mismatch=0

3.5초가 걸렸다. 100만 건에 합계는 한빛유통 데이터의 지문 그대로였고, 어긋난 행은 0이었다. 빠진 행도 겹친 행도 없었다.

"바로 바꾸죠." 내가 말하자 선배가 물었다. "바꾸면 화면에서 뭐가 없어져?"

정산 목록 화면을 떠올렸다. 아래쪽에 페이지 번호가 쭉 늘어서 있고, 맨 끝에 "총 1,000,000건"이 찍혀 있다.

"…'100페이지로 바로 가기'가 안 돼요. 포스트잇은 앞 페이지를 지나와야 생기니까요. 그리고 총건수요. 그건 아직 매번 세고 있어요."

"그 둘을 없애도 되는지, 네가 정할 수 있어?"

"…아니요."

"그럼 물으러 가자." 선배가 노트북을 덮었다. "설계를 바꾸는 것도 튜닝이야. 다만 혼자서는 못 해."


오후 3시 40분 — 조민우 씨의 자리 ​

정산팀은 3층에 있었다. 조민우 씨는 정산 목록 화면을 띄워 놓고 있었다. 화면 아래쪽에 "총 1,000,000건"이 보였다.

"멈추는 거 보러 오신 거죠? 지금 해 볼까요?" 조민우 씨가 다음 페이지 버튼에 손을 올렸다.

"아니요, 그건 저희가 재 봤어요." 나는 수첩을 폈다. 오는 길에 선배가 쥐여 준 질문 세 개가 적혀 있었다. "하나만 먼저 여쭤볼게요. 100페이지까지는 왜 넘기세요?"

조민우 씨가 잠깐 멈칫했다. 그런 질문은 처음 받아 본다는 얼굴이었다. "어제 마감분을 보려고요. 하루에 900건 넘게 들어오니까, 어제 걸 보려면 90 몇 페이지까지 가야 돼요. 월요일엔 금요일 걸 봐야 하니까 더 가고요."

아침에 센 숫자가 떠올랐다. 하루 913~914건. 한 페이지에 10건이면 하루치가 91페이지 남짓이다. 그러니까 "100페이지째"는 곧 "어제"였다.

"그러니까 100페이지가 필요한 게 아니라 어제가 필요하신 거네요."

"…그렇죠. 날짜로 찾는 기능이 없으니까 넘기는 거예요."

선배가 노트북을 열어 보여 줬다. 포스트잇 SQL의 시작점을 날짜 하나로 바꾼 것이었다.

sql
         WHERE ORD_DT < DATE '2024-12-21'      -- 12월 20일 건부터
xplan
|   0 | SELECT STATEMENT               |                   |      1 |        |     11 |00:00:00.01 |      15 |
|*  4 |     INDEX RANGE SCAN DESCENDING| IX_ORD_BIG_DT_SEQ |      1 |     13 |     11 |00:00:00.01 |       4 |

15. 날짜를 넣으면 그날 마지막 주문부터 바로 나왔다. "아, 이거 좋네요. 이게 되면 100페이지는 안 넘겨도 돼요."

두 번째 질문이었다. "아래 총건수는 어디에 쓰세요?"

"총건수요?" 조민우 씨가 처음 보는 것처럼 화면 아래를 내려다봤다. "…글쎄요. 0건인지 아닌지는 봐요. 근데 정확한 건수는 월말 정산 리포트에서 따로 맞추니까, 여기 숫자는 안 봐요. 이거 원래 예전 팀장님이 넣어 달라고 하셨다던데요."

커밋 로그의 두 번째 줄이 떠올랐다. 총건수 표시는 정산팀 화면 요구사항. 그 요구를 한 사람은 이미 이 팀에 없었다. 그런데도 그 요구 때문에 페이지마다 5,826블록을, 인덱스가 생긴 지금도 3,365블록을 읽고 있었다.

"그 자리에 '다음 페이지 있음'만 표시하면 안 될까요? 그건 거의 공짜예요." 나는 11번째 행 얘기를 꺼냈다.

"상관없어요. 다음 버튼만 있으면 돼요."

세 번째 질문은 선배가 했다. "나중에 정렬 기준을 추가하실 계획이 있나요? 금액 큰 순이라든지."

"그건… 있으면 좋겠는데요."

"그럼 그때 다시 말씀해 주세요." 선배가 말했다. "그때는 오늘 만든 인덱스로는 안 되고, 다시 재야 하거든요. 그 조건을 오늘 문서에 적어 둘게요."


오후 4시 20분 — 문서 한 장 ​

자리로 돌아와 문서를 하나 썼다. 선배가 건넨 양식의 제목은 「화면 요구 재협상서」였다.

화면 요구 재협상서 1호 — 정산 목록 (정산팀 조민우)
────────────────────────────────────────────────────────────────────
 Q1. 총건수는 누가 무엇에 쓰나?   → 아무도 안 본다. 정본은 월말 정산 리포트
                                     ⇒ 없앤다. "다음 페이지 있음"만 표시
 Q2. 100페이지까지 왜 가나?       → 어제 마감분을 찾으려고 (하루 = 91페이지 남짓)
                                     ⇒ "날짜로 가기" + 다음/이전. 번호 점프는 없앤다
 Q3. 정렬 기준은 몇 개 필요한가?   → 지금은 최신순 하나. 금액순은 "있으면 좋겠다"
                                     ⇒ 최신순 하나로 간다
 한 번 클릭의 값                   11,652 블록 → 약 15 블록
 다음 재협상 조건                  정렬 기준이 추가될 때 · 화면에 검색 조건이 생길 때
────────────────────────────────────────────────────────────────────
Q4 (100페이지)Q4c한 번 클릭
rev00 정렬 + 매번 COUNT5,8265,82611,652
rev01 인덱스가 정렬을 대신1,0073,3654,372
rev02 포스트잇 + 다음 있음15—약 15

rev01까지는 SQL을 고친 것이고, rev02는 화면을 고친 것이었다. 4,372에서 15로 가는 길은 SQL 안이 아니라 3층에 있었다.

"선배님. 01화 T9에 이렇게 적었잖아요. '설계를 바꿔야 하는 문제로 판명될 때(그건 튜닝이 아니라 재협상이다).' 그런데 아까는 설계를 바꾸는 것도 튜닝이라고 하셨고요."

"둘 다 맞아." 선배가 말했다. "T9는 'SQL만 붙잡고 계속 깎지 마라'는 뜻이야. 4,372를 3,000으로 만들려고 일주일을 쓰느니, 3층에 가서 질문 세 개 하는 게 맞아. 그리고 그 질문을 하러 가는 것까지가 튜너의 일이야. 혼자 정하지 않는 것, 그게 재협상이야."


⛳ 오늘은 여기까지(중단 지점 2)Q4_rev02가 나왔고, 한 번 클릭에 11,652 → 약 15라는 결과를 손에 넣었다. 「화면 요구 재협상서」 1호도 썼다. 오늘 할 몫은 여기까지만 해도 다 한 셈이다. 이어지는 저녁 장면에서는 엑셀 버튼 하나와 본사 메일 하나가 기다린다. 둘 다 정렬 이야기다.


오후 5시 00분 — 엑셀 버튼 ​

조민우 씨한테서 메신저가 왔다. "아까 말씀 못 드린 게 있는데, '엑셀 내려받기'도 느려요. 한 달 치 받으면 한참 걸려요."

6월 한 달이면 27,390건이다. 02화에서 센 숫자다. 이번엔 페이지 단위가 아니라 통째로 받는다.

"Buffers만 보지 말고 받는 쪽도 봐." 선배가 말했다. "27,390건을 클라이언트가 한 번에 받아 가?"

아니었다. 클라이언트는 정해진 건수씩 여러 번 받으러 온다. 그 한 번이 Fetch Call이고, 한 번에 받는 건수가 ARRAYSIZE다. SQL*Plus의 기본값은 15다. AUTOTRACE로 통계만 찍어 봤다.

                          consistent gets   SQL*Net roundtrips
정렬 있음  ARRAYSIZE 15             5,827                1,827
정렬 있음  ARRAYSIZE 100            5,826                  275
정렬 있음  ARRAYSIZE 1000           5,826                   29

왕복 1,827번 → 29번. 1,827은 27,390 ÷ 15와 거의 같다. 시간은 0.23초 → 0.04초로 줄었다. 그런데 consistent gets는 꿈쩍도 하지 않았다.

"정렬이 있어서 그래." 선배가 말했다. "정렬은 첫 줄을 내주기 전에 전부 읽어. 그러니까 몇 건씩 받아 가든 읽는 양은 같아. 정렬을 빼 봐."

정렬을 뺐더니 이번엔 consistent gets도 바뀌었다. ARRAYSIZE 15에서는 7,641, 1000에서는 5,854였다.

"정렬이 없으면 오라클은 읽으면서 바로 내줘. 15건 내주고 쉬고, 다음 Call에서 읽던 블록을 다시 잡고. 그 '다시 잡기'가 1,800번 가까이 붙은 거야." 선배가 말했다. "이걸 부분범위 처리라고 해. 오늘 오후에 네가 정렬을 없앤 Q4가 1,000건째에서 멈출 수 있었던 것도 같은 성질 덕분이야. 정렬이 있었으면 멈출 수가 없어."

아침 첫 장면에서 1페이지도 5,826이었던 이유도 이것이었다. 정렬은 다 읽어야 첫 줄이 나온다. 엑셀 버튼이 한 번에 받는 건수는 화면 프로그램 쪽 설정이라 개발팀에 확인을 요청했다. 드라이버마다 기본값이 다르기 때문이다.


오후 5시 40분 — 본사에서 온 메일 ​

메일이 하나 더 와 있었다. 보낸 곳은 본사 유통지원팀, 03화의 그 사람들이었다.

───────────────────────────────────────────────────────────
 [회신] SLE_HMS 데이터 교정 건
───────────────────────────────────────────────────────────
 교정 승인합니다. 다음 달 정기 점검 때 적용 예정입니다.

 ※ 별건: 매출 목록 화면에서 "최신순" 첫 줄이 매일 오전 9시대
   거래로 나온다는 민원이 매장에서 계속 들어옵니다. 같은 원인일까요?
───────────────────────────────────────────────────────────

ora-dist를 열었다. 매출 목록 화면의 SQL은 Q4와 모양이 같았다. ORDER BY SLE_DT DESC, SLE_HMS DESC로 정렬하고, FULL에 Top-N 정렬로 4,511블록을 읽었다.

        RN SLE_NO          SLE_DT   SLE_HM     SHP_NO
         1 20231231-0037   20231231 95503        1005
         2 20231231-0036   20231231 95021        1004

12월 31일 맨 윗줄이 95503이었다. 오전 9시 55분이다. 그날 마지막 거래는 225914, 밤 10시 59분이었다.

"같은 원인이네요. 03화의 그 33,241건이요. '95503'하고 '225914'를 글자로 비교하면 첫 글자 9가 2보다 크니까요."

"그래. 3월엔 그 때문에 집계에서 2억 9천만 원이 빠졌고, 오늘은 정렬이 틀어졌어. 교정은 다음 달이야. 어떻게 할래?"

"LPAD로 정렬하면…" 말하다가 멈췄다. 03화에서 그건 증상 처방이라고 했다. 게다가 컬럼을 가공하면 인덱스로 정렬을 대신할 길도 막힌다. 오늘 오후에 배운 내용이었다.

"정렬 키를 다시 골라 봐. 이 테이블에 이미 시간 순서로 줄 서 있는 게 없어?"

SLE_NO가 있었다. '20231231-0612'처럼 날짜 여덟 자리, 하이픈, 그날의 일련번호로 되어 있다. 게다가 그 컬럼에는 PK 인덱스가 이미 있었다.

xplan
|   4 |     TABLE ACCESS BY INDEX ROWID| CF10    |      1 |    500K|     10 |00:00:00.01 |       7 |
|   5 |      INDEX FULL SCAN DESCENDING| CF10_PK |      1 |     10 |     10 |00:00:00.01 |       4 |

        RN SLE_NO          SLE_DT   SLE_HM     SHP_NO
         1 20231231-0612   20231231 225914       1002

4,511 → 7. 맨 윗줄은 밤 10시 59분 14초였다. 새 인덱스도, 데이터 교정도 없이 얻은 결과였다.

"이건 전제에 기댄 판단이야. 판매번호가 시간 순으로 매겨진다는 전제 말이야." 선배가 말했다. "06화 대장처럼 전제를 적고 재 봐."

TOTAL_ROWS PREFIX_MATCHES      INVERSIONS     DUP_SECOND_PAIRS
    500000         500000               0                13585

앞 8자리가 판매일자와 같은 행은 500,000 / 500,000이었다. 하루 안에서 판매번호 순서와 시각 순서가 뒤집힌 곳은 0이었다. 그리고 하나 더 있었다. 원래 정렬 키 (SLE_DT, SLE_HMS)는 같은 초에 겹치는 쌍이 13,585개였다. 오후에 배운 대로라면 원래 화면의 정렬 키는 순서가 틀렸을 뿐 아니라 유일하지도 않았다. SLE_NO는 PK라서 유일하다.

마지막으로 승인된 교정을 복사본에서 미리 해 봤다. 원본 CF10에는 한 글자도 쓰지 않았다. 교정하고 나니 원래 화면 정렬과 판매번호 정렬이 50만 행 전부에서 같은 순번을 냈다. 어긋난 행은 0. 복사본은 지웠다.

눈에 띄는 게 하나 더 있었다. 여기서는 1,000페이지가 225블록이었다. 한빛유통에서는 인덱스로 끝까지 가면 1,000페이지가 10,037이었다. 같은 OFFSET인데 44배나 차이가 났다. CF10_PK의 클러스터링 팩터는 9,340으로, 테이블 블록 수 4,583에 가깝다. 버리는 행들이 같은 블록에 모여 있으면 세는 비용도 싸다. 04화의 커피 캐리어가 여기서 다시 나왔다.

유통지원팀에 회신을 썼다. 화면의 정렬 기준을 판매번호로 바꾸면 오늘부터 맞는 순서가 나옵니다(전제와 검증 첨부). 데이터 교정은 예정대로 진행해 주십시오. 영업시간 집계는 교정이 있어야 맞습니다.


오후 6시 20분 — 박팀장에게 ​

마지막으로 아침에 받은 메시지에 답장을 썼다. 세 번을 고쳐 썼다.

나  18:14
PGA 는 올리지 않았습니다.
올리면 화면이 멈추는 페이지가 뒤로 밀릴 뿐이고(64KB→1MB 에서 100페이지는 살지만
1만 페이지는 다시 느려짐), 화면 한 번 누를 때 읽는 양은 그대로입니다.
대신 정렬 자체를 없앴고(인덱스 1개), 정산팀과 화면 요구를 다시 정했습니다
(총건수 제거 · 날짜로 이동). 한 번 누를 때 읽는 양이 11,652 블록에서 약 15 블록이 됩니다.
상세는 T1 7호와 화면 요구 재협상서 1호로 드립니다.

선배가 어깨 너머로 읽더니 물었다. "블록 숫자를 박팀장이 알아들을까?"

"…'한 번 누를 때 읽는 양이 700분의 1 남짓'이라고 한 줄 더 넣을게요."

"좋아. 그럼 마지막으로 하나." 선배가 의자를 돌려 앉았다. "rev02는 유효기간이 없어?"

이제는 그 질문이 나올 줄 알고 있었다. "있어요. 재협상서에 적었어요. 정렬 기준이 늘면 정렬 순서마다 인덱스와 포스트잇이 따로 필요해요. 그리고 오늘 만든 인덱스까지 T_ORD_BIG의 인덱스가 세 개가 됐어요. 14화에 청구서가 와요."

"또 하나 있어." 선배가 말했다. "포스트잇 값을 SQL 문자열에 그대로 박으면 어떻게 돼? 페이지마다 다른 SQL이 되지. 그건 11화야. 그리고 오늘 실행계획에서 E-Rows가 100만인데 실제로는 11건이었잖아. 옵티마이저가 '몇 건에서 멈출지'를 어떻게 셈하는지는 10화에서 볼 거고."

"그리고 오늘 박팀장 메시지 말이야." 선배가 일어서며 말했다. "네 리포트를 읽고 처방을 쓴 거잖아. 리포트에 쓴 문장을 남들은 처방으로 읽어. 다음부터 「환경 의존성」 칸에는 '그러니 무엇을 하라'가 아니라 '무엇을 재 보라'까지 써."


오늘 남은 것 ​

무엇값
Q4_rev01인덱스 (ORD_DT, ORD_SEQ), SQL은 그대로. 100페이지 5,826 → 1,007 · Q4c 5,826 → 3,365(계획이 저절로 바뀜)
Q4_rev02포스트잇 + 다음 있음. 어느 페이지든 15~18 · 한 번 클릭에 11,652 → 약 15
T1 리포트 7호처음으로 「채택하지 않은 대안」에 PGA 증설이 들어간 리포트
「화면 요구 재협상서」 1호 ★이 교재의 새 표준 문서
ora-dist 매출 목록정렬 키를 SLE_NO로 · 4,511 → 7 · 순서가 바로잡힘(전제 검증 첨부)

오늘 배운 한 문장 ​

정렬은 다 읽어야 첫 줄이 나온다. 그리고 OFFSET은 앞에서부터 센다. 메모리는 정렬을 빨리 하게 할 뿐 안 하게 하지는 못하고, 인덱스는 정렬을 없애 주지만 세는 일은 남긴다. 세는 일까지 없애려면 SQL이 아니라 화면이 묻는 질문을 바꿔야 한다. "몇 번째부터"가 아니라 "어디부터"를 묻게 하는 것이다.

다음으로 넘기는 것 ​

  • E-Rows 1000K인데 A-Rows 11: ROWNUM이 있을 때 옵티마이저의 셈 → 10화
  • 포스트잇 값을 문자열로 박으면 페이지마다 다른 SQL → 11화(바인드 변수)
  • T_ORD_BIG 인덱스 3개의 적재 비용 → 14화
  • ARRAYSIZE를 PL/SQL에서 정하는 법(BULK COLLECT LIMIT) → 15화
  • 재협상서의 재검토 조건(정렬 기준을 추가할 때) → 조건에 걸리면 다시 잰다 · 18화 원장 재측정

다음 → 3_실습과_기록.md에서 페이지 곡선을 직접 그리고, 책상을 좁혀 절벽을 찾고, 포스트잇을 붙인 뒤 T1 7호와 재협상서 1호를 쓴다. 개념이 막히면 2_파고들기.md를 펼친다.