Skip to content

02화 · 인덱스는 있는데 안 탄다 ​

3월 11일 월요일, 지난주 측정값을 들고 출근했다. 오늘은 처음으로 고친다. 그리고 두 번 틀린다.


오전 9시 05분 ​

월요일 아침 메신저에 박팀장 이름이 떴다.

박팀장  09:03
지난주 보고서 잘 봤습니다. 숫자가 많더군요.
그런데 결론이 "아직 고치지 않았습니다"로 되어 있던데,
그래서 언제쯤 빨라지나요? 현업에서 계속 묻습니다.

답장을 쓰다가 세 번 지웠다. "측정 체계부터 만들어야 해서"라고 쓰려다 말았다. 맞는 말이지만, 일주일을 기다린 사람에게 할 말은 아니었다.

선배가 커피를 들고 지나가다 내 화면을 봤다.

"재촉받았구나."

"네. 근데 틀린 말씀은 아니라서요."

"틀린 말 아니지. 팀장님 입장에선 일주일 동안 바뀐 게 하나도 없으니까." 선배가 의자를 끌어왔다. "그래서 오늘 고칠 거야. 근데 시작하기 전에, 매주 하게 될 일 하나만 알려 줄게."


오전 9시 20분 — 매주 처음 하는 일 ​

"리비전이라는 걸 쓸 거야. 조각마다 번호를 붙여 두는 거지."

리비전/
  Q1_rev00.sql     ← 강도현이 남긴 원본. 지난주에 잰 그것

"오늘 Q1을 고치면 Q1_rev01.sql이 생겨. 그리고 매주 맨 처음 할 일은 직전 리비전을 열어서 diff를 떠 보는 거야."

"왜요? 어차피 제가 고친 거라 기억나는데요."

"지금은 기억나지. 12주 뒤에 Q1을 네 번째로 고칠 때도 기억날까?" 선배가 말했다. "그리고 이게 더 중요한데, '점점 나아지고 있다'는 걸 느낌이 아니라 파일로 확인할 수 있어. 18화에서 rev00하고 rev03을 나란히 놓으면 네가 1년 동안 배운 게 한눈에 보여."

Q1_rev00.sql을 열었다. 지난주에 본 바로 그 코드였다.

sql
SELECT COUNT(*), SUM(ORD_AMT)
  FROM T_ORD_BIG
 WHERE TO_CHAR(ORD_DT, 'YYYYMMDD') BETWEEN '20240601' AND '20240630';

지난주에 잰 값은 5,827블록이었다. 6월 주문 27,390건을 세는 데 테이블 전체를 읽었다.

그리고 답은 이미 안다고 생각했다. 지난주에 확인했듯이 T_ORD_BIG에는 인덱스가 하나도 없다.

"인덱스를 만들면 되겠네요."

"만들어 봐."


오전 9시 40분 — 첫 번째 시도 ​

sql
CREATE INDEX IX_ORD_BIG_DT ON T_ORD_BIG(ORD_DT);
Elapsed: 00:00:01.81

100만 건짜리 테이블에 인덱스가 1.8초 만에 생겼다. 생각보다 빨랐다.

선배가 한마디 보탰다. "여긴 연습 DB라 그냥 만들었지만, 운영에서는 INVISIBLE로 먼저 만들어."

"인비저블이요?"

"만들어 두되 옵티마이저한테는 안 보이게 하는 거야. 그 상태에서 네 세션에서만 켜 보고, 괜찮으면 전체에 공개해. 운영 중에 인덱스가 갑자기 생기면 쿼리 수십 개의 실행계획이 한꺼번에 바뀌어. 하나 좋아지자고 아홉 개를 망칠 수도 있어."

나는 받아 적었다. 그리고 통계를 다시 수집한 뒤, 지난주와 똑같은 SQL을 돌렸다.

sql
SELECT /*+ GATHER_PLAN_STATISTICS */ COUNT(*), SUM(ORD_AMT)
  FROM T_ORD_BIG
 WHERE TO_CHAR(ORD_DT, 'YYYYMMDD') BETWEEN '20240601' AND '20240630';

SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(FORMAT => 'ALLSTATS LAST +PREDICATE'));
xplan
------------------------------------------------------------------------------------------
| Id  | Operation           | Name      | Starts | E-Rows | A-Rows |   A-Time   | Buffers |
------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT    |           |      1 |        |      1 |00:00:00.09 |    5826 |
|   1 |  SORT AGGREGATE     |           |      1 |      1 |      1 |00:00:00.09 |    5826 |
|*  2 |   TABLE ACCESS FULL | T_ORD_BIG |      1 |   2500 |  27390 |00:00:00.09 |    5826 |
------------------------------------------------------------------------------------------

나는 숫자를 두 번 봤다.

5,826. 지난주와 같았다. 연산도 그대로 TABLE ACCESS FULL이었다.

인덱스를 만들었는데 달라진 게 하나도 없었다.


오전 10시 00분 — 인덱스가 진짜 있나 ​

처음엔 인덱스를 의심했다. 안 만들어졌거나 깨졌을 수도 있었다.

sql
SELECT index_name, status, num_rows, leaf_blocks, blevel
  FROM user_indexes WHERE table_name = 'T_ORD_BIG';
INDEX_NAME       STATUS     NUM_ROWS  LEAF_BLOCKS     BLEVEL
---------------- -------- ---------- ------------ ----------
IX_ORD_BIG_DT    VALID       1000000         2653          2

멀쩡했다. 상태는 VALID였고 100만 건이 전부 들어 있었다. 리프 블록은 2,653개였다.

"인덱스는 있는데요."

"그럼 오라클한테 쓰라고 시켜 봐." 선배가 말했다. "힌트라는 게 있어. '이 인덱스 써'라고 지시하는 주석이야."

sql
SELECT /*+ GATHER_PLAN_STATISTICS INDEX(o IX_ORD_BIG_DT) */ COUNT(*), SUM(o.ORD_AMT)
  FROM T_ORD_BIG o
 WHERE TO_CHAR(o.ORD_DT, 'YYYYMMDD') BETWEEN '20240601' AND '20240630';
|*  2 |   TABLE ACCESS FULL | T_ORD_BIG |      1 |   2500 |  27390 |00:00:00.09 |    5826 |

힌트를 줬는데도 또 FULL이었다.

"힌트를 무시하는데요?"

"무시한 게 아니야." 선배가 고개를 저었다. "못 쓰는 거야. 오라클은 힌트를 '따를 수 있을 때만' 따라. 못 따르면 조용히 넘어가. 에러도 안 내."

그 말에 좀 짜증이 났다. "에러라도 내 주면 좋잖아요."

"그래 주면 편하지. 근데 이 일이 원래 그래. 네가 틀려도 오라클은 틀렸다고 말해 주지 않아. 그냥 느릴 뿐이야." 선배가 일어섰다. "따라와 봐. 보여 줄 게 있어."


오전 10시 20분 — 출입대장 ​

선배가 향한 곳은 안내 데스크였다. 캐비닛에서 두꺼운 바인더를 하나 꺼냈다. 종이로 된 방문객 출입대장이었다.

"작년 거야. 방문 일시 순으로 적혀 있어."

펼쳐 보니 이런 식이었다.

5월 31일 17:42   김○○   3층 회의실
6월 1일  09:15   이○○   7층
6월 1일  10:03   박○○   7층
...

"자, 6월 1일부터 6월 30일까지 방문자가 몇 명인지 세어 봐."

쉬웠다. 날짜순이니까 6월 1일 첫 줄을 찾아서 7월 1일이 나오기 전까지만 세면 됐다. 앞쪽 1월~5월은 펴 볼 필요도 없었다.

"빠르지. 왜 빠를까?"

"정렬돼 있으니까요. 어디부터 어디까지인지 지목할 수 있잖아요."

"그래. 그게 인덱스야." 선배가 바인더를 탁 쳤다. "인덱스는 빨리 찾는 마법이 아니야. 그냥 정렬된 목록이야. 정렬돼 있으니까 '여기부터 여기까지'라고 지목할 수 있는 거고, 그래서 나머지는 안 봐도 되는 거야."

그러고는 조건을 하나 걸었다.

"이번엔 규칙을 바꿀게. 날짜를 여덟 자리 숫자로 바꿔서, 그 숫자가 20240601 이상 20240630 이하인 것만 세."

어이가 없었다. "결국 똑같은 거잖아요."

"너한테는 똑같지." 선배가 바인더를 가리켰다. "대장에 20240601이라고 적힌 데가 있어?"

없었다. 대장에는 "6월 1일 09:15"라고 적혀 있었다.

"그럼 어떻게 확인할 건데?"

입을 열었다가 도로 닫았다. 방법은 하나뿐이었다. 1월 1일 첫 줄부터 한 줄씩 읽으면서 머릿속으로 여덟 자리로 바꿔 보고, 범위에 드는지 따져야 했다.

"…전부 봐야 하네요."

"대장이 날짜순이어도 아무 소용이 없어진 거지." 선배가 바인더를 덮었다. "네가 만든 인덱스에는 ORD_DT가 날짜 모양 그대로 들어 있어. TO_CHAR로 바꾼 여덟 자리 문자열은 인덱스 어디에도 없어. 그러니 오라클도 한 줄씩 바꿔 보는 수밖에 없는 거야."


오전 10시 50분 — 증거는 이미 화면에 있었다 ​

자리로 돌아와 아까 그 실행계획을 다시 봤다. 아래쪽에 지난주에 배운 Predicate Information이 붙어 있었다.

   2 - filter((TO_CHAR(INTERNAL_FUNCTION("ORD_DT"),'YYYYMMDD')>='20240601' AND
              TO_CHAR(INTERNAL_FUNCTION("ORD_DT"),'YYYYMMDD')<='20240630'))

access가 아니라 filter였다.

지난주에 선배가 말한 바로 그 구분이었다. access는 찾아간 것이고, filter는 다 읽고 버린 것이다. 증거는 처음부터 화면에 있었는데 내가 읽지 못했을 뿐이다.

💡 INTERNAL_FUNCTION은 오라클이 내부에서 끼워 넣은 형변환 표시다. 함수가 컬럼을 감싸고 있다는 신호로 읽으면 된다.

눈에 띄는 게 하나 더 있었다.

E-Rows = 2,500     ← 오라클의 예상
A-Rows = 27,390    ← 실제

11배나 틀렸다. 지난주 ORD_ST에서 본 16배 오추정과는 종류가 달랐다. 그때는 분포를 몰라서 틀린 거였는데, 이번엔…

"컬럼을 가공하면 통계도 못 써." 선배가 설명했다. "오라클한테 ORD_DT 통계는 있어. 근데 네가 물어본 건 TO_CHAR(ORD_DT)야. 그 값에는 통계가 없어. 그럼 어떻게 하겠어?"

"…찍겠네요."

"그래. 기본값으로 찍어. 2,500은 100만의 0.25%인데, 오라클이 '표현식 범위 조건'에 쓰는 고정 추정값이야. 데이터랑은 아무 상관 없는 숫자지."

컬럼을 가공하면 두 가지를 한꺼번에 잃는다. 인덱스를 못 타고, 추정도 제대로 못 한다.

⛳ 오늘은 여기까지(중단 지점 1) 여기까지가 오늘의 절반이다. 인덱스를 만들었는데 안 타는 현상과 그 이유를 봤다. 이어서 읽으면 드디어 고친다. 그리고 한 번 더 틀린다.


오전 11시 30분 — 고친다. 그런데 한 번 더 걸린다 ​

"그럼 TO_CHAR를 빼면 되는 거죠?"

sql
WHERE ORD_DT BETWEEN DATE '2024-06-01' AND DATE '2024-06-30'

내가 이렇게 쓰자 선배가 바로 막았다.

"잠깐. 그거 강도현 씨가 고쳤던 버그를 네가 다시 만드는 거야."

지난주에 본 커밋 로그가 떠올랐다.

2019-03-11  hd.kang
  날짜 타입 비교에서 시분초 때문에 하루가 누락되는 버그 수정. 문자로 통일.

"DATE '2024-06-30'이 몇 시 몇 분이야?"

"…0시 0분이요."

"그럼 6월 30일 오후 2시에 들어온 주문은?"

빠진다. BETWEEN의 끝값이 30일 00:00이니까. 그런데 좀 미심쩍어서 두 가지를 다 돌려 봤다.

sql
SELECT COUNT(*), SUM(ORD_AMT) FROM T_ORD_BIG
 WHERE ORD_DT >= DATE '2024-06-01' AND ORD_DT < DATE '2024-07-01';   -- 27390 / 6,982,065,200

SELECT COUNT(*), SUM(ORD_AMT) FROM T_ORD_BIG
 WHERE ORD_DT BETWEEN DATE '2024-06-01' AND DATE '2024-06-30';        -- 27390 / 6,982,065,200

똑같았다.

"선배님, 똑같은데요. 하나도 안 빠져요."

선배가 눈썹을 치켜올렸다. "그래? 그럼 확인해 봐. T_ORD_BIG의 ORD_DT에 시분초가 들어 있어?"

sql
SELECT COUNT(*) AS 자정아닌행 FROM T_ORD_BIG WHERE ORD_DT <> TRUNC(ORD_DT);
자정아닌행
----------
         0

100만 건이 전부 자정이었다. 시분초가 아예 없으니 빠질 행도 없었다.

"운이 좋네." 선배가 말했다. "그럼 옆에 있는 T_ORD로 똑같이 해 봐."

sql
SELECT COUNT(*) AS 자정아닌행 FROM T_ORD WHERE ORD_DT <> TRUNC(ORD_DT);   -- 99999

10만 건 중 99,999건에 시분초가 있었다. 같은 비교를 돌리자 이번엔 숫자가 갈렸다.

T_ORD에서건수금액
>= 6/1 AND < 7/14,1101,043,455,400
BETWEEN 6/1 AND 6/303,9731,009,295,700
차이137건34,159,700원

3,416만 원이 사라졌다.

한참 동안 그 숫자만 봤다. 방금 내가 쓰려던 코드는, 옆 테이블에서라면 정산 금액 3천 4백만 원을 빠뜨리는 코드였다.

"이게 제일 고약한 종류야." 선배가 말했다. "지금 이 테이블에서는 안 터져. 테스트도 통과해. 그런데 누가 이 코드를 T_ORD에 복사해 쓰는 순간 터지는데, 그땐 네가 고친 코드니까 네 이름이 붙어."

선배가 하나를 더 짚었다.

"이것도 봐 둬. 강도현 씨 원본을 T_ORD에 돌리면 얼마가 나올 것 같아?"

sql
SELECT COUNT(*), SUM(ORD_AMT) FROM T_ORD
 WHERE TO_CHAR(ORD_DT,'YYYYMMDD') BETWEEN '20240601' AND '20240630';   -- 4110 / 1,043,455,400

4,110건. 정확했다.

"강도현 씨 코드는 답이 맞아. 느릴 뿐이야." 선배가 말했다. "네가 방금 하려던 건 빠르면서 틀린 코드를 만드는 거였어. 그건 개선이 아니라 사고야."

나는 아무 말도 못 했다.

"느린 코드를 빠르게 만드는 게 튜닝이고, 맞는 코드를 틀리게 만드는 건 장애야. 둘을 헷갈리면 안 돼."

그래서 올바른 교정 ​

sql
-- Q1_rev01
SELECT COUNT(*), SUM(ORD_AMT)
  FROM T_ORD_BIG
 WHERE ORD_DT >= DATE '2024-06-01'
   AND ORD_DT <  DATE '2024-07-01';   -- 7월 1일 '미만'

시작은 >=로, 끝은 < 다음 달 1일로 잡는다. 끝을 "6월 30일"이 아니라 "7월 1일 미만"으로 잡으면 시분초가 있든 없든 하루가 빠지지 않는다. 게다가 컬럼도 가공하지 않은 그대로 남는다.

"이게 날짜 범위 조건의 정석이야. 외워 둬. 날짜에는 BETWEEN 쓰지 마."


오전 11시 50분 — 두 번째 충격 ​

고친 SQL을 돌렸다. 이번엔 자신이 있었다.

xplan
------------------------------------------------------------------------------------------
| Id  | Operation           | Name      | Starts | E-Rows | A-Rows |   A-Time   | Buffers |
------------------------------------------------------------------------------------------
|*  2 |   TABLE ACCESS FULL | T_ORD_BIG |      1 |  28336 |  27390 |00:00:00.03 |    5827 |
------------------------------------------------------------------------------------------

5,827. 또 TABLE ACCESS FULL이었다.

멍하니 화면만 봤다. 컬럼 가공을 없앴는데도 인덱스를 타지 않았다.

"선배님, 이거 왜 또…"

"이번엔 힌트를 줘 봐." 선배는 태연했다.

sql
SELECT /*+ GATHER_PLAN_STATISTICS INDEX(o IX_ORD_BIG_DT) */ COUNT(*), SUM(o.ORD_AMT)
  FROM T_ORD_BIG o
 WHERE o.ORD_DT >= DATE '2024-06-01' AND o.ORD_DT < DATE '2024-07-01';

이번에는 실행계획이 바뀌었다.

xplan
-------------------------------------------------------------------------------------------------
| Id  | Operation                            | Name          | Starts | A-Rows | ... |  Buffers |
-------------------------------------------------------------------------------------------------
|   2 |   TABLE ACCESS BY INDEX ROWID BATCHED| T_ORD_BIG     |      1 |  27390 |     |    27466 |
|*  3 |    INDEX RANGE SCAN                  | IX_ORD_BIG_DT |      1 |  27390 |     |       76 |
-------------------------------------------------------------------------------------------------

인덱스를 탔다. INDEX RANGE SCAN이었고, 조건도 access(...)로 바뀌어 있었다.

그런데 Buffers가 27,466이었다.

전체 스캔이 5,827이었으니 4.7배 느려졌다.

"……."

"이제 알겠어?" 선배가 말했다. "오라클이 옳았어. 네가 SQL을 고쳐서 인덱스를 쓸 수는 있게 됐는데, 오라클이 계산해 보니 쓰면 손해라서 안 쓴 거야."


오후 12시 10분 — 그럼 뭐가 나아진 건가 ​

좀 허탈했다. 오전 내내 한 일이라고는 숫자를 1 늘린 것(5,826 → 5,827)뿐인 것 같았다.

"아무것도 안 나아졌는데요."

"바뀐 게 두 개 있어. 둘 다 화면에 나와 있으니까 찾아봐."

두 실행계획을 나란히 놓고 비교했다.

rev00 (가공)rev01 (교정)
OperationTABLE ACCESS FULLTABLE ACCESS FULL
Buffers5,8265,827
E-Rows2,50028,336
Predicatefilter(TO_CHAR(...))filter("ORD_DT">=...) (함수가 사라졌다)

"……E-Rows가 정확해졌어요."

"그게 오늘 네가 번 거야." 선배가 몸을 앞으로 기울였다. "아까는 오라클이 '2,500건쯤 되겠지' 하고 계획을 세웠어. 11배나 틀린 짐작을 믿고 말이야. 지금은 28,336이야. 실제가 27,390이니까 거의 맞아."

"그게 왜 중요해요? 어차피 실행계획은 똑같은데."

"지금은 똑같지. 조건이 바뀌면 달라져." 선배가 말했다. "하루치만 조회하면? 1,000건쯤이야. 오라클이 그걸 1,000건으로 보면 인덱스를 쓸 거고, 2,500건으로 찍으면 또 틀린 판단을 하겠지. 네가 고친 건 속도가 아니라 오라클의 눈이야."

선배가 덧붙였다.

"하나 더 있어. 이제 오라클한테 고를 길이 생겼어. 아까는 전체 스캔 하나밖에 없었는데, 지금은 둘이야. 오늘은 전체 스캔이 이겼지만, 내일 데이터가 바뀌면 오라클이 알아서 갈아탈 거야. 네가 한 일은 길을 하나 더 놔 준 거야."


오후 1시 40분 — 캐리어가 돌아왔다 ​

점심을 먹고 와서도 27,466이라는 숫자가 마음에 걸렸다. 2만 7천 건을 찾는 데 2만 7천 블록을 읽는다는 게 이상했다.

"선배님, 아까 그 27,466 말인데요. 왜 이렇게 많아요? 2만 7천 건이면 블록으로는 훨씬 적어야 하지 않아요? 한 블록에 169건 들어간다면서요."

선배가 웃었다. "지난주 캐리어 기억나?"

탕비실 커피 캐리어 얘기였다. 캐리어 하나에 네 잔이 들어가듯, 블록 하나에 169건이 들어간다고 했다.

"캐리어 하나에 169잔이 들어 있다고 했지. 그건 네가 필요한 169건이 한 캐리어에 모여 있을 때 얘기야." 선배가 종이에 뭔가를 그렸다. "그럼 이건 어때? 네가 필요한 2만 7천 잔이 전부 다른 캐리어에 한 잔씩 들어 있다면?"

숫자를 다시 봤다. 27,390건에 27,466블록. 거의 1대 1이었다.

"…캐리어를 2만 7천 개 들고 와야 하네요."

"지금 일어난 일이 딱 그거야." 선배가 숫자 하나를 짚어 줬다.

sql
SELECT index_name, clustering_factor, num_rows FROM user_indexes
 WHERE table_name = 'T_ORD_BIG';
INDEX_NAME       CLUSTERING_FACTOR    NUM_ROWS
---------------- ----------------- -----------
IX_ORD_BIG_DT            1000000       1000000

클러스터링 팩터가 100만이었다. 행수와 똑같았다.

"이게 뭐예요?"

"인덱스 순서대로 읽어 갈 때 테이블 블록을 몇 번 바꿔 타는지, 그 횟수야. 100만 행인데 100만 번 바꿔 탄다는 건…"

"매번 다른 블록이라는 거네요. 최악이잖아요."

"그래. 주문이 날짜순으로 저장돼 있으면 6월 데이터가 한곳에 모여 있을 텐데, 이 테이블은 그렇게 안 쌓였거든." 선배가 펜을 내려놨다. "이 얘기는 여기까지. 04화에서 제대로 할 거야. 지금 더 들어가면 네가 엉뚱한 걸 고치려 들어."


오후 2시 30분 — 테이블에 왜 가야 하지 ​

"그럼 이대로 끝이에요? 오늘 아무것도 못 고치는 거예요?"

선배가 내 화면에서 힌트를 넣은 실행계획을 가리켰다.

xplan
|   2 |   TABLE ACCESS BY INDEX ROWID BATCHED| T_ORD_BIG     |  27390 |    27466 |
|*  3 |    INDEX RANGE SCAN                  | IX_ORD_BIG_DT |  27390 |       76 |

"이 두 줄 중에 어디가 비싸?"

위쪽이었다. 인덱스를 뒤지는 데는 76블록밖에 들지 않았다. 나머지 27,390은 테이블을 읽으러 가면서 붙었다.

"인덱스 타는 건 싼데, 테이블 가는 게 비싸네요."

"그럼 하나 물어보자. 테이블엔 왜 가야 해?"

나는 SQL을 다시 봤다.

sql
SELECT COUNT(*), SUM(ORD_AMT) FROM T_ORD_BIG WHERE ORD_DT >= ... AND ORD_DT < ...

필요한 건 두 가지였다. 날짜로 거르고, 금액을 더한다. 날짜는 인덱스에 있다. 금액은…

"ORD_AMT 때문이에요. 인덱스에 금액이 없으니까 테이블까지 가야 해요."

"그럼?"

그 순간 감이 왔다. "인덱스에 넣으면 되겠네요."

sql
CREATE INDEX IX_ORD_BIG_DT_AMT ON T_ORD_BIG(ORD_DT, ORD_AMT);

이번에는 힌트 없이 돌렸다.

xplan
----------------------------------------------------------------------------------------------------
| Id  | Operation          | Name              | Starts | E-Rows | A-Rows |   A-Time   | Buffers |
----------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |                   |      1 |        |      1 |00:00:00.01 |      91 |
|   1 |  SORT AGGREGATE    |                   |      1 |      1 |      1 |00:00:00.01 |      91 |
|*  2 |   INDEX RANGE SCAN | IX_ORD_BIG_DT_AMT |      1 |  28336 |  27390 |00:00:00.01 |      91 |
----------------------------------------------------------------------------------------------------

   2 - access("ORD_DT">=TO_DATE(' 2024-06-01 00:00:00', ...) AND
              "ORD_DT"<TO_DATE(' 2024-07-01 00:00:00', ...))

91.

나는 숫자를 세 번 봤다. TABLE ACCESS 줄이 아예 없었다. 인덱스만 읽고 끝났다.

5,827 → 91. 64배였다.

조건도 access였다. 처음으로 filter가 아니었다.

"됐네." 선배가 짧게 말했다.

"이거… 그러니까 테이블에는 아예 안 간 거죠?"

"필요한 게 전부 인덱스에 있으면 테이블에 갈 이유가 없어. 이런 걸 커버링 인덱스라고 해. 인덱스가 쿼리를 '덮었다'는 뜻이야." 선배가 커피를 한 모금 마셨다. "자, 오늘 네가 한 걸 순서대로 말해 봐."

나는 차례대로 정리했다.

  1. 인덱스를 만들었다 → 안 탔다 (컬럼을 가공해서)
  2. SQL을 고쳤다 → 그래도 안 탔다 (전체 스캔이 더 쌌으니까. 오라클이 옳았다)
  3. 왜 비싼지 봤다 → 흩어져 있어서 (04화로)
  4. 테이블을 안 읽게 했다 → 91

"좋아. 그게 오늘 리포트에 들어갈 문장이야."


⛳ 오늘은 여기까지(중단 지점 2)Q1_rev02가 나왔고 5,827 → 91이라는 결과를 손에 넣었다. 여기까지만 해도 오늘 할 일은 다 했다. 이어지는 저녁 대화에서 선배가 이 성과에 단서 두 가지를 붙인다.


오후 6시 20분 ​

퇴근 무렵 선배가 내 자리로 왔다. 나는 리포트를 쓰는 중이었다. "5,827 → 91블록, 64배 개선."

"좋은 숫자네. 근데 두 가지만 물어보자." 선배가 의자를 돌려 앉았다. "이게 공짜일 것 같아?"

"…인덱스를 하나 더 만들긴 했죠."

"지금 T_ORD_BIG에 인덱스가 몇 개야?"

IX_ORD_BIG_DT와 IX_ORD_BIG_DT_AMT, 두 개였다.

"주문이 한 건 들어오면 무슨 일이 생기지?"

생각해 봤다. 테이블에 한 줄을 넣고… 인덱스 두 개에도 정렬된 자리를 찾아서 넣어야 한다.

"INSERT가 느려지겠네요."

"그래. 조회가 64배 빨라진 대가는 적재 쪽에서 치르게 돼. 지금은 안 보이지. 야간 배치가 100만 건을 밀어 넣을 때 드러나." 선배가 말했다. "14화에서 청구서가 날아올 거야. 그때 오늘 만든 인덱스 두 개가 영수증에 찍혀 있을 거고."

리포트의 '리스크' 칸을 봤다. 비어 있었다. 지난주에도 그 칸을 비웠다가 반려당했다.

"두 번째." 선배가 말을 이었다. "오늘 네가 잰 건 6월 한 달이야. 27,390건이니까 전체의 2.7%지. 그럼 1년치를 조회하면 어떻게 될까?"

1년이면 33만 건쯤, 전체의 3분의 1이다.

"…인덱스가 더 느려질 것 같은데요."

"어디서부터? 2.7%에서는 인덱스가 이겼어. 50%면 당연히 전체 스캔이 이기고. 그 사이 어딘가에 뒤집히는 지점이 있어. 몇 퍼센트쯤일 것 같아?"

모르겠다고 대답했다.

"나도 지금은 몰라. 재 봐야 알아." 선배가 일어섰다. "그건 04화에서 할 거야. 오늘 만든 인덱스로 범위를 조금씩 넓혀 가면서, 어디서 뒤집히는지 직접 찾아볼 거야."


오늘 남은 것 ​

무엇값
Q1_rev01컬럼 가공 제거. Buffers 변화 없음, E-Rows 2,500 → 28,336
Q1_rev02커버링 인덱스. 5,827 → 91블록(64배)
T1 리포트 1호이 교재에서 처음 쓰는 단건 SQL 튜닝 리포트
Sargable 교정표인덱스를 무력화하는 6가지와 교정형

오늘 배운 한 문장 ​

인덱스는 빨리 찾는 마법이 아니라 정렬된 목록이다. 조건이 정렬된 순서에서 "여기부터 여기까지"를 지목할 수 있으면 access, 지목하지 못하면 filter다.

다음으로 넘기는 것 ​

  • 왜 흩어져 있으면 비싼가 (CF = 1,000,000) → 04화
  • 몇 퍼센트에서 뒤집히는가 (손익분기점) → 04화
  • 인덱스 2개의 적재 비용 → 14화
  • E-Rows가 정확해도 여전히 틀리는 경우 → 10화

다음 → 3_실습과_기록.md에서 오늘 과정을 직접 재현하고, 무력화 6종을 손으로 확인한 뒤 T1 리포트를 쓴다. 개념이 막히면 2_파고들기.md를 펼친다.