테마
02화 · 실습과 기록
여기서 하는 일: 두 번 틀리고, 한 번 이긴다.
1_사건.md를 읽은 것은 "선배가 하는 걸 지켜본 것"이고, 이 문서를 끝내야 직접 해 본 것이 된다. 개념이 막히면2_파고들기.md를 펼친다.시간이 없는 주에는 STEP 0 → 1 → 2 → 5만 한다(약 40분).
STEP 0 — 직전 리비전을 연다 (2분) ★ 매주 처음 하는 일
bash
docker start ora-hanorderbash
cat 리비전/Q1_rev00.sql오늘 고칠 SQL부터 눈으로 확인한다. 지난주에 잰 값은 Buffers 5,827이었다.
sql
-- 전제 확인: T_ORD_BIG 에 인덱스가 없어야 한다 (01화 종료 상태)
SELECT COUNT(*) AS idx_on_ord_big FROM user_indexes WHERE table_name='T_ORD_BIG'; -- 기대 00이 아니어도 실습 스크립트가 알아서 지우고 시작한다. 그래도 그 인덱스가 왜 있는지는 꼭 확인해야 한다. 누가 만들었는지 모르는 인덱스가 운영에 있다면 그 자체로 사건이다.
STEP 1 — 예측을 봉인한다 (3분)
오늘 ORD_DT 에 인덱스를 만들 것이다. rev00 은 TO_CHAR 로 날짜를 가공한다.
Q1. 인덱스를 만들면 Buffers 가 얼마가 될 것 같은가? ______
Q2. 인덱스를 만들었는데 안 타면, 그 이유는 무엇일 것 같은가?
________________________________________________________
Q3. SQL 에서 TO_CHAR 를 없애면 Buffers 가 얼마가 될 것 같은가? ______▶ 적고 나서 펼치기
대부분 Q1에는 "크게 줄어든다", Q3에는 "더 줄어든다"라고 쓴다.
실측값은 Q1이 5,826으로 그대로, Q3도 5,827로 그대로다. 두 번 틀리는 것까지가 이 화의 설계다. 64배는 세 번째 시도에서 나온다.
STEP 2 — 실험 6단계를 돌린다 (5분) ★ 오늘의 본체
bash
cd 10_본편_튜너의_1년/02화_인덱스는_있는데_안_탄다/실습
docker exec -i ora-hanorder sqlplus -s sqlp/sqlp@localhost/FREEPDB1 < 00_인덱스_실험.sqlpowershell
Set-Location 10_본편_튜너의_1년\02화_인덱스는_있는데_안_탄다\실습
Get-Content 00_인덱스_실험.sql -Encoding UTF8 | docker exec -i ora-hanorder sqlplus -s sqlp/sqlp@localhost/FREEPDB1스크립트는 맨 앞에서 인덱스를 전부 지우고 시작한다. 그래서 몇 번을 돌려도 결과가 같다.
⚠️ 왜 초기화가 필요한가 — 실측으로 걸린 함정
[F]에서 만든 커버링 인덱스가 남아 있으면, 다음에 돌릴 때[D]가TABLE ACCESS FULL이 아니라 곧장 91로 나온다. "SQL을 고쳐도 안 탄다"는 이 화의 핵심 장면이 통째로 사라진다. 인덱스가 없는 상태에서 출발해야 이 이야기가 성립한다.
여섯 단계에서 확인할 것
| 단계 | 무엇 | 기대 | ☐ |
|---|---|---|---|
| [A] | 인덱스 없이 rev00 | TABLE ACCESS FULL · 5,826 · E-Rows 2,500 | ☐ |
| [B] | 인덱스 만들고 rev00 | 똑같다. 달라지는 게 없다 | ☐ |
| [C] | rev00 + INDEX 힌트 | 여전히 FULL. 힌트가 조용히 무시된다 | ☐ |
| [D] | rev01 (가공 제거) | 여전히 FULL. 하지만 E-Rows는 28,336 | ☐ |
| [E] | rev01 + INDEX 힌트 강제 | INDEX RANGE SCAN · 27,466 (4.7배 악화) | ☐ |
| [F] | rev02 (커버링) | INDEX RANGE SCAN만 · 91 | ☐ |
각 단계에서 눈으로 짚을 것
[A][B][C] — Predicate 를 보라
filter((TO_CHAR(INTERNAL_FUNCTION("ORD_DT"),'YYYYMMDD')>='20240601' AND ...))
☐ filter 인가 access 인가? → filter. 다 읽고 버렸다
☐ INTERNAL_FUNCTION 이 보이는가? → 컬럼이 함수에 감싸였다는 신호
[D] — 같은 자리에서 함수가 사라진 것을 확인
filter(("ORD_DT">=TO_DATE(...) AND "ORD_DT"<TO_DATE(...)))
☐ E-Rows 가 2,500 -> 28,336 으로 바뀌었는가? (실제 27,390)
[E] — 두 줄의 Buffers 를 비교
TABLE ACCESS BY INDEX ROWID BATCHED ... 27,466 ← 여기서 다 붙는다
INDEX RANGE SCAN ..................... 76 ← 인덱스 자체는 싸다
☐ 인덱스가 비싼 게 아니라 '테이블로 가는 것'이 비싸다는 걸 확인
[F] — TABLE ACCESS 줄이 아예 없는 것을 확인
☐ access(...) 로 바뀌었는가?
☐ 91 인가?STEP 1의 예측을 꺼낸다
| Q1(인덱스를 만들면)을 맞혔나? | |
| "힌트를 줘도 안 탄다"를 예상했나? | |
| "고쳤는데도 FULL"을 예상했나? | |
| "억지로 태우면 4.7배 느려진다"는? |
STEP 3 — 무력화 6종을 손으로 확인한다 (15분)
bash
docker exec -i ora-hanorder sqlplus -s sqlp/sqlp@localhost/FREEPDB1 < 01_무력화_6종.sqlpowershell
Get-Content 01_무력화_6종.sql -Encoding UTF8 | docker exec -i ora-hanorder sqlplus -s sqlp/sqlp@localhost/FREEPDB1무대는 구 시스템의 CUSTOMERS(20,000행 · CUST_X_CITY)다. CITY에는 6가지 값이 고르게 퍼져 있다.
채점표: 돌리기 전에 각 줄의 Operation을 먼저 적어 보자.
| # | 조건 | 내 예상 | 실측 Operation | 실측 Buffers |
|---|---|---|---|---|
| ① | UPPER(city) = 'SEOUL' | TABLE ACCESS FULL | 126 | |
| ① | city = 'SEOUL' | INDEX RANGE SCAN | 10 | |
| ② | order_date = DATE '2024-06-01' | INDEX RANGE SCAN | 4 | |
| ③ | city LIKE '%OUL' | INDEX FAST FULL SCAN | 56 | |
| ③ | city LIKE 'SE%' | INDEX RANGE SCAN | 10 | |
| ④ | city != 'SEOUL' | INDEX FAST FULL SCAN | 56 | |
| ⑤ | city IS NULL | TABLE ACCESS FULL | 126 | |
| ⑥ | ORD_AMT = 50000 (선두 누락) | INDEX FAST FULL SCAN | 3,227 |
📌 여기서 교과서가 틀리는 지점
③④⑥은 흔히 "인덱스를 못 탄다"고 설명하지만, 실측 결과는
INDEX FAST FULL SCAN이다. 인덱스를 쓰기는 쓴다. 다만 찾아가지(RANGE SCAN) 못하고 전부 훑는다. 인덱스가 테이블보다 작으니 테이블 전체 스캔보다는 쌀 수 있다. 정확하게 말하면 이렇다. "찾아갈 수 없어서 훑는다."
①의 숨은 두 번째 손해
UPPER(city)의 E-Rows는 200인데 실제는 3,333이다. 17배나 틀렸다. rev00의 TO_CHAR에서 본 것과 같은 현상이다. 컬럼을 가공하면 인덱스와 통계를 한꺼번에 잃는다.
② 묵시적 형변환 — 어느 쪽이 변환되는가
sql
WHERE order_date = DATE '2024-06-01' -- 리터럴이 변환된다 → 멀쩡하다 (4 블록)
WHERE varchar_col = 12345 -- 컬럼이 변환된다 → TO_NUMBER(varchar_col)스크립트에는 M_CUS.CUS_ID = 123이 주석으로 들어 있다. 주석을 풀어서 돌려 보면 느려지는 게 아니라 ORA-01722로 터진다. 묵시적 형변환에서 가장 고약한 경우로, 성능 문제가 아니라 장애다.
STEP 4 — 교정이 불가능할 때 (5분)
UPPER(city)를 없앨 수 없는 화면이 있다고 하자. 그때는 가공된 값 자체를 인덱싱한다.
sql
CREATE INDEX IX_CUST_CITY_UP ON customers(UPPER(city));
-- SQL 을 한 글자도 안 고쳤는데 RANGE SCAN 을 탄다 (실측 10 블록)대가는 세 가지다. ⓐ 그 함수 형태와 정확히 일치하는 조건에서만 탄다(
LOWER로는 안 탄다). ⓑ 인덱스가 하나 늘어 DML이 느려진다. ⓒ 함수가DETERMINISTIC이어야 한다.SQL을 고칠 수 있으면 그쪽이 먼저다. FBI는 최후의 수단이다.
STEP 5 — T1 리포트 1호 ★ 오늘의 산출물
이 교재에서 처음 쓰는 단건 SQL 튜닝 리포트다. 양식은 T1_단건SQL_튜닝리포트.md를 따른다.
┌──────────────────────────────────────────────────────────────────┐
│ T1 단건 SQL 튜닝 리포트 — 1호 │
├──────────────────────────────────────────────────────────────────┤
│ 대상 : PKG_ORD_STLM.Q1 (기간별 주문 조회) │
│ 측정 일시 : ____________ 측정 조건 : 단독 세션 · 1회 실행 │
│ 리비전 : rev00 → rev02 │
├──────────────────────────────────────────────────────────────────┤
│ 1. 현상 │
│ ______________________________________________________________ │
│ 2. 원인 │
│ ______________________________________________________________ │
│ 3. 조치 (단계별로) │
│ rev01: ______________________________________________________ │
│ rev02: ______________________________________________________ │
│ 4. 효과 (Buffers 로) │
│ ______________________________________________________________ │
│ 5. 리스크 ★ 비우면 반려된다 │
│ ______________________________________________________________ │
│ 6. 회귀 검증 │
│ ______________________________________________________________ │
└──────────────────────────────────────────────────────────────────┘5번 「리스크」에 반드시 들어가야 할 것
지난주에 이 칸을 비웠다가 반려당했다. 오늘은 쓸 거리가 분명히 있다.
T_ORD_BIG의 인덱스가 0개 → 2개로 늘었다. 이제 INSERT/UPDATE마다 인덱스 2개를 갱신해야 한다- 야간 배치(P1)가 100만 건을 적재할 때 이 비용이 드러난다. 청구서는 14화에서 온다
- 오늘 잰 범위는 6월 한 달(2.74%)이다. 범위가 넓어지면 커버링 인덱스도 불리해질 수 있다(04화)
- 운영에 적용할 때는
INVISIBLE로 먼저 만들고 세션 단위로 검증한다
6번 「회귀 검증」 — 결과가 같은가
성능을 고쳤으면 답이 바뀌지 않았음을 증명해야 한다. 검증용 스크립트가 따로 있다.
bash
docker exec -i ora-hanorder sqlplus -s sqlp/sqlp@localhost/FREEPDB1 < 실습/02_경계값_검증.sql확인할 것 두 가지
sql
-- ① rev00 과 rev02 의 결과가 같은가 (T_ORD_BIG)
-- 기대: 양쪽 다 27,390건 / 6,982,065,200
-- ② 날짜에 BETWEEN 을 쓰면 어떻게 되는가
-- T_ORD_BIG : 27,390 으로 '똑같다' <- 전부 자정이라 버그가 숨는다
-- T_ORD : 4,110 vs 3,973 <- 137건 34,159,700원 누락☠️ 오늘 리포트에서 가장 중요한 줄
T_ORD_BIG에서는BETWEEN을 써도 결과가 똑같이 나온다. 테스트도 통과한다. 그런데 같은 코드를T_ORD에 쓰면 3,416만 원이 사라진다.반면 강도현의
TO_CHAR원본은T_ORD에서도 4,110건으로 정확하다.강도현의 코드는 느릴 뿐 답은 맞다.BETWEEN으로 고친 코드는 빠르지만 틀린다.느린 코드를 빠르게 만드는 것이 튜닝이고, 맞는 코드를 틀리게 만드는 것은 장애다. 느린 건 사람이 기다리면 되지만, 답이 틀리면 정산이 틀어진다.
STEP 6 — 서술 답안 (400~600자)
박팀장에게 보낼 회신을 쓴다. 지난주에는 "아직 안 고쳤습니다"라고 보냈지만, 오늘은 보고할 게 있다.
4단계 프레임(현상/진단 → 원인 → 개선안 → 근거/효과)은 01화 3_실습과_기록.md와 같다.
▶ 선배의 첨삭 포인트 (쓰고 나서)
① "인덱스를 만들어서 빨라졌습니다"는 틀린 보고다. 인덱스만 만들었을 때는 아무것도 바뀌지 않았다. 빨라진 건 SQL을 고치고 커버링 인덱스까지 만든 뒤다. 둘 중 하나만 했다면 효과가 없었다. 무엇이 효과를 냈는지 정확히 쓰는 것이 이 리포트의 가치다.
② 64배를 자랑하기 전에 조건을 쓴다. "6월 한 달, 전체의 2.74% 범위에서"가 빠지면 과장이다. 1년치를 조회하면 이 숫자는 안 나온다.
③ 박팀장이 알아듣는 말로 쓴다. "Buffers 5,827 → 91"로는 뜻이 전달되지 않는다. "조회 한 번에 읽는 데이터가 64분의 1로 줄었습니다"라고 옮겨야 한다.
④ 대가를 숨기지 않는다. 인덱스를 2개 만들었으니 야간 배치가 느려질 수 있다. 지금 적어 두지 않으면 14화에서 그 책임을 떠안게 된다.
STEP 7 — 파인만 자가설명
절차는 01화와 같다. 1단 백지 설명 → 2단 막힘 태그(최소 1개) → 3단 원천 복귀 → 4단 재설명 순서로 진행한다.
오늘 자주 나오는 태그 세 가지
| 태그 | 전형적인 문장 |
|---|---|
#인과 | "왜 TO_CHAR를 쓰면 인덱스를 못 쓰는지 설명하다가 막혔다" → 2_파고들기.md §1 |
#용어 | "FAST FULL SCAN과 FULL SCAN의 차이를 말로 못 하겠다" → §2 |
#수치 | "CF가 100만이면 왜 27,466이 되는지 계산이 안 된다" → §5의 근사식에 직접 대입해 본다 |
4단 되묻기 3문 (오늘 버전)
- 조건 반전: "6월 한 달이 아니라 1년치를 조회하면 rev02는 어떻게 되나?"
- 반례 요구: "커버링 인덱스가 오히려 해가 되는 경우를 하나 든다면?"
- 측정 지목: "'인덱스 때문에 배치가 느려졌다'를 증명하려면 무엇을 재야 하나?"
태그는 _막힘로그.md로 옮긴다.
완료 체크리스트
- [ ] STEP 0:
Q1_rev00.sql을 열어 봤다 - [ ] STEP 1: 보기 전에 예측 3개를 적었다
- [ ] STEP 2: [A]~[F] 여섯 단계를 돌리고 91을 확인했다
- [ ] STEP 2:
filter가access로 바뀌고TABLE ACCESS줄이 사라진 것을 눈으로 짚었다 - [ ] STEP 3: 무력화 6종의 예상을 먼저 적고 실측과 대조했다
- [ ] STEP 5: T1 리포트 1호를 썼고, 리스크 칸과 경계값 검증을 채웠다
- [ ] STEP 6: 박팀장 회신을 문장으로 풀어 썼다
- [ ] STEP 7: 막힘 태그를 최소 1개
_막힘로그.md에 옮겼다
완결성 게이트: STEP 5의 리스크 칸과 STEP 7의 태그가 비어 있으면 이 화는 미완성이다.
다음 화로 넘기는 상태
| 무엇 | 값 |
|---|---|
T_ORD_BIG 인덱스 | 2개(IX_ORD_BIG_DT, IX_ORD_BIG_DT_AMT). 지우지 않는다 |
Q1 현재 리비전 | rev02 · Buffers 91 |
| 남긴 빚 | 인덱스 2개의 적재 비용 → 14화 |
| 열어 둔 질문 | 손익분기점은 몇 %인가 → 04화 |
다음 → 03화 「날짜가 문자로 박혀 있다」: 오늘은 TO_CHAR를 지울 수 있었다. 03화에서는 컬럼 자체가 VARCHAR2(8)이라 지울 수가 없다.