Step 16 — EXPLAIN 과 옵티마이저
학습 목표
- EXPLAIN 의 모든 컬럼(특히
type, key, rows, filtered, Extra)을 정확히 읽는다
type 등급(system~ALL)을 좋고 나쁨으로 서열화한다
- EXPLAIN ANALYZE(8.0.18+)로 추정이 아닌 실측 실행 계획을 읽는다
- FORMAT=TREE / JSON 으로 비용과 실행 흐름을 본다
- 옵티마이저 힌트·인덱스 힌트·옵티마이저 스위치로 계획을 제어한다
- ANALYZE TABLE 과 히스토그램(8.0)으로 옵티마이저의 추정을 개선한다
- "느린 쿼리 → EXPLAIN → 인덱스 설계 → 재측정"의 실전 튜닝 절차를 익힌다
선행 스텝: Step 15 — 인덱스
예상 소요: 90분
16-1. EXPLAIN 이란
EXPLAIN 은 쿼리를 실행하지 않고, 옵티마이저가 세운 실행 계획을 보여줍니다. "이 쿼리를 어떤 순서로, 어떤 인덱스로, 몇 행쯤 읽어서 처리할 예정"인지의 설계도입니다.
EXPLAIN SELECT * FROM orders WHERE customer_id = 1;
결과
+----+-------------+--------+------+---------------------+---------------------+---------+-------+------+----------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+--------+------+---------------------+---------------------+---------+-------+------+----------+-------+
| 1 | SIMPLE | orders | ref | idx_orders_customer | idx_orders_customer | 4 | const | 20 | 100.00 | NULL |
+----+-------------+--------+------+---------------------+---------------------+---------+-------+------+----------+-------+
각 컬럼의 의미:
| 컬럼 | 의미 |
|---|
id | SELECT 식별자. JOIN 은 같은 id, 서브쿼리는 다른 id |
select_type | SIMPLE / PRIMARY / SUBQUERY / DERIVED / UNION 등 |
table | 접근 대상 테이블(또는 <derived2> 같은 파생) |
partitions | 접근한 파티션(파티션 테이블일 때) |
type | 접근 방식. 가장 중요. (아래 16-2) |
possible_keys | 쓸 수 있었던 인덱스 후보 |
key | 실제로 고른 인덱스 (NULL 이면 인덱스 안 씀) |
key_len | 사용한 인덱스의 바이트 길이(복합 인덱스 중 몇 컬럼까지 썼는지 추정) |
ref | 인덱스와 비교된 값(const = 상수, 컬럼명 = 조인 상대) |
rows | 읽을 것으로 추정한 행 수 (추정치!) |
filtered | 그 rows 중 조건을 통과할 비율(%) 추정 |
Extra | 추가 동작. Using index / filesort / temporary 등 (아래 16-3) |
16-2. type — 접근 방식 등급 (제일 먼저 보는 것)
type 은 "테이블에 어떻게 접근하는가"이며, 성능의 핵심 지표입니다. 좋은 순서:
system > const > eq_ref > ref > range > index > ALL
좋음 ←──────────────────────────────────────→ 나쁨
const — PK/UNIQUE 등치 (최고)
EXPLAIN SELECT * FROM customers WHERE customer_id = 1;
| type | key | ref | rows |
| const | PRIMARY | const | 1 | ← 딱 1행. 상수처럼 취급
eq_ref — 조인에서 상대 테이블의 PK/UNIQUE 를 1:1 매칭
EXPLAIN SELECT o.order_id, c.name
FROM orders o JOIN customers c ON c.customer_id = o.customer_id
WHERE o.order_id < 10;
| table | type | key | ref | rows |
| o | range | PRIMARY | NULL | 9 |
| c | eq_ref | PRIMARY | shop.o.customer_id | 1 | ← o의 각 행마다 c를 PK로 1행씩
ref — 비유니크 인덱스 등치 (실무의 주력)
EXPLAIN SELECT * FROM orders WHERE customer_id = 1;
| type | key | ref | rows |
| ref | idx_orders_customer | const | 20 | ← 인덱스로 같은 값 여러 행
range — 범위 검색
EXPLAIN SELECT * FROM orders WHERE order_id BETWEEN 1 AND 50;
| type | key | rows | Extra |
| range | PRIMARY | 50 | Using where | ← BETWEEN, >, <, IN, LIKE 'x%'
index — 인덱스 풀스캔 (주의!)
EXPLAIN SELECT customer_id FROM orders;
| type | key | rows | Extra |
| index | idx_orders_customer | 600 | Using index | ← 인덱스 전체를 훑음
⚠️ 함정 — type: index 는 사실상 풀스캔이다 (Step 15 에서 예고)
"index 니까 인덱스 탔네" 는 오해입니다. 탐색(seek)이 아니라 인덱스 전체를 처음부터 끝까지 읽는 것입니다.
테이블 풀스캔(ALL)보다 조금 나을 뿐(인덱스가 테이블보다 작고, 커버링이면 테이블 접근이 없어서). 좋은 신호가 아닙니다.
ALL — 테이블 풀스캔 (최악, 대용량에서)
EXPLAIN SELECT * FROM orders WHERE shipping_city = '서울';
| type | key | rows | Extra |
| ALL | NULL | 600 | Using where | ← 인덱스 없음. 전 행 스캔
💡 작은 테이블의 ALL 은 정상이다
orders 는 600행뿐이라 옵티마이저가 일부러 풀스캔을 고릅니다. 인덱스로 왔다 갔다 하는 것보다 그냥 다 읽는 게 빠르기 때문입니다.
type: ALL 이 문제가 되는 건 행이 많을 때입니다. Step 15 의 100만 행 access_logs 에서 ALL 은 재앙이었죠.
EXPLAIN 은 항상 rows 와 함께 해석하세요. type 만 보고 좋다/나쁘다 하면 안 됩니다.
Extra 에는 옵티마이저가 추가로 하는 일이 적힙니다. 여기서 성능 문제가 자주 튀어나옵니다.
| Extra | 의미 | 좋음/나쁨 |
|---|
Using index | 커버링 인덱스. 테이블 접근 없음 | 좋음 |
Using where | 스토리지에서 받은 행을 서버가 추가 필터링 | 보통 |
Using index condition | 인덱스 컨디션 푸시다운(ICP). 필터를 인덱스 단계로 밀어냄 | 좋음 |
Using filesort | 정렬을 위해 별도 정렬 수행(메모리/디스크) | 나쁨(대량일 때) |
Using temporary | 임시 테이블 생성(GROUP BY/DISTINCT/UNION 등) | 나쁨(대량일 때) |
Using filesort + Using temporary 가 함께 뜨는 전형적 케이스
EXPLAIN SELECT shipping_city, COUNT(*) c FROM orders GROUP BY shipping_city ORDER BY c DESC;
| type | key | rows | Extra |
| ALL | NULL | 600 | Using temporary; Using filesort |
GROUP BY shipping_city 를 처리하려 임시 테이블을 만들고(Using temporary), ORDER BY c(집계값) 로 다시 정렬합니다(Using filesort). 데이터가 크면 둘 다 비쌉니다.
💡 Using filesort 는 "파일로 정렬한다"는 뜻이 아닙니다. 인덱스 순서를 못 써서 별도 정렬 단계가 필요하다는 의미이고,
데이터가 작으면 메모리에서 끝납니다. 크면 디스크로 넘칩니다. Step 15 처럼 정렬 컬럼을 인덱스에 포함시켜 없앨 수 있습니다.
16-4. rows 와 filtered — 추정치임을 잊지 마라
rows 와 filtered 는 옵티마이저의 추정치입니다. 통계에 기반한 예측이지 실제 값이 아닙니다.
Step 15 의 customer_id = 7 을 다시 봅시다. 인덱스가 있을 때 EXPLAIN 은 rows: 64428 이라고 추정했지만, 실제 행은 33,333 이었습니다. 거의 2배 틀립니다.
rows: 이 단계에서 읽을 것으로 추정한 행 수
filtered: 그 rows 중 WHERE 를 통과할 추정 비율(%)
- 최종 예상 행 =
rows × filtered / 100
추정이 실제와 크게 어긋나면 옵티마이저가 잘못된 계획을 세웁니다. 그 추정을 바로잡는 도구가 ANALYZE TABLE 과 히스토그램(16-8)이고, 추정이 아닌 실측을 보는 도구가 EXPLAIN ANALYZE(다음 절)입니다.
16-5. EXPLAIN ANALYZE — 추정이 아닌 실측 (8.0.18+)
EXPLAIN 은 계획(추정)만 보여줍니다. EXPLAIN ANALYZE 는 쿼리를 실제로 실행하고, 각 단계의 실제 시간·실제 행 수를 함께 보여줍니다.
Step 15 의 access_logs 를 인덱스 없이:
EXPLAIN ANALYZE SELECT COUNT(*) FROM access_logs WHERE customer_id = 7;
결과
-> Aggregate: count(0) (cost=110594 rows=1) (actual time=120..120 rows=1 loops=1)
-> Filter: (access_logs.customer_id = 7) (cost=100633 rows=99615) (actual time=1.69..119 rows=33333 loops=1)
-> Table scan on access_logs (cost=100633 rows=996151) (actual time=1.68..90.6 rows=1e+6 loops=1)
읽는 법 (안쪽 → 바깥쪽 순으로 실행됨):
Table scan on access_logs — 100만 행(rows=1e+6)을 전부 스캔. 실제 90.6ms.
Filter: customer_id = 7 — 추정 99615 행이지만 실제(actual) 33333 행. 여기서 추정 오차가 보입니다.
actual time=1.69..119 — 첫 행까지 1.69ms, 마지막 행까지 119ms.
인덱스를 만든 뒤:
ALTER TABLE access_logs ADD INDEX idx_customer (customer_id);
EXPLAIN ANALYZE SELECT COUNT(*) FROM access_logs WHERE customer_id = 7;
결과
-> Aggregate: count(0) (cost=12981 rows=1) (actual time=3.28..3.28 rows=1 loops=1)
-> Covering index lookup on access_logs using idx_customer (customer_id=7)
(cost=6538 rows=64428) (actual time=0.272..2.49 rows=33333 loops=1)
Table scan(90.6ms) 이 Covering index lookup(2.49ms) 으로 바뀌었습니다. 전체도 120ms → 3.28ms.
💡 actual time=A..B 읽는 법: A = 이 연산이 첫 행을 내놓기까지 걸린 ms, B = 마지막 행까지. loops = 이 연산이 반복된 횟수(조인 안쪽이면 여러 번).
실제 튜닝에서는 rows 추정 vs actual rows 실측의 괴리와, 어느 연산에서 actual time 이 폭증하는지를 봅니다.
⚠️ EXPLAIN ANALYZE 는 쿼리를 진짜로 실행합니다. SELECT 은 안전하지만, UPDATE/DELETE 에 쓰면 실제로 데이터가 바뀝니다.
변경 쿼리를 분석하려면 EXPLAIN(실행 안 함)만 쓰거나, 트랜잭션으로 감싸고 롤백하세요.
16-6. FORMAT=TREE / JSON
FORMAT=TREE — 실행 흐름을 트리로
EXPLAIN FORMAT=TREE
SELECT c.grade, COUNT(*) FROM orders o JOIN customers c ON c.customer_id=o.customer_id
GROUP BY c.grade;
-> Table scan on <temporary>
-> Aggregate using temporary table
-> Nested loop inner join (cost=71 rows=600)
-> Table scan on c (cost=3.25 rows=30)
-> Covering index lookup on o using idx_orders_customer (customer_id=c.customer_id) (cost=0.324 rows=20)
들여쓰기가 깊을수록 먼저 실행됩니다. "customers 를 스캔하고(30행), 각 고객마다 orders 를 인덱스로 찾고, 임시 테이블에 모아 집계"라는 흐름이 보입니다. EXPLAIN ANALYZE 도 이 트리 형식을 씁니다.
FORMAT=JSON — 비용까지 상세히
EXPLAIN FORMAT=JSON SELECT * FROM orders WHERE customer_id = 1;
{
"query_block": {
"cost_info": { "query_cost": "5.00" },
"table": {
"table_name": "orders",
"access_type": "ref",
"key": "idx_orders_customer",
"used_key_parts": ["customer_id"],
"rows_examined_per_scan": 20,
"filtered": "100.00",
"cost_info": {
"read_cost": "3.00", "eval_cost": "2.00", "prefix_cost": "5.00"
}
}
}
}
query_cost 는 옵티마이저가 계산한 추상 비용 단위입니다. 절대값보다는 같은 쿼리를 다르게 썼을 때 비용이 줄어드는지 비교할 때 씁니다. used_key_parts 로 복합 인덱스 중 몇 컬럼을 실제로 썼는지도 볼 수 있습니다.
16-7. 옵티마이저 제어 — 힌트와 스위치
옵티마이저는 대개 옳지만, 통계가 낡거나 데이터가 편향되면 잘못된 인덱스를 고르기도 합니다. 그럴 때 계획을 강제할 수 있습니다.
인덱스 힌트 (전통적)
SELECT ... FROM t USE INDEX (idx_a) ... -- idx_a 중에서 고르라고 권함
SELECT ... FROM t FORCE INDEX (idx_a) ... -- idx_a 를 강하게 강제
SELECT ... FROM t IGNORE INDEX (idx_a) ... -- idx_a 는 쓰지 말라
옵티마이저 힌트 (8.0 권장, /*+ ... */)
주석 형태로 SELECT 바로 뒤에 넣습니다. 인덱스 힌트보다 세밀하게 제어합니다.
EXPLAIN SELECT /*+ NO_INDEX(access_logs idx_customer) */ COUNT(*)
FROM access_logs WHERE customer_id = 7;
| type | key | rows | Extra |
| ALL | NULL | 996151 | Using where | ← 인덱스를 못 쓰게 막으니 풀스캔으로 돌아감
자주 쓰는 옵티마이저 힌트:
| 힌트 | 뜻 |
|---|
/*+ INDEX(t idx) */ | 테이블 t 에 인덱스 idx 사용 |
/*+ NO_INDEX(t idx) */ | 인덱스 idx 사용 금지 |
/*+ JOIN_ORDER(t1, t2) */ | 조인 순서 고정 |
/*+ NO_MERGE(v) */ | 뷰/파생테이블 머지 금지 |
/*+ MAX_EXECUTION_TIME(1000) */ | 이 쿼리 1초 넘으면 중단 |
⚠️ 함정 — 힌트는 최후의 수단이다
힌트로 계획을 강제하면 그 순간엔 빨라져도, 데이터가 커지거나 분포가 바뀌면 강제된 계획이 오히려 발목을 잡습니다.
옵티마이저가 잘못 고른다면, 힌트로 덮기 전에 왜 잘못 고르는지(대개 통계·히스토그램 문제)를 먼저 확인하세요. 힌트는 원인을 못 고칠 때만 씁니다.
옵티마이저 스위치
세션/전역 단위로 옵티마이저 기능을 켜고 끕니다. Step 15 에서 스킵 스캔을 껐던 그것입니다.
SELECT @@optimizer_switch; -- 현재 상태
SET SESSION optimizer_switch = 'skip_scan=off'; -- 스킵 스캔 끄기
SET SESSION optimizer_switch = 'index_merge=off'; -- 인덱스 머지 끄기
💡 특정 최적화가 오히려 계획을 나쁘게 만드는지 실험할 때 유용합니다. "index_merge 를 껐더니 더 빨라지네?" 같은 진단.
전역 변경은 서버 전체에 영향을 주니, 세션 단위(SET SESSION) 로 테스트하세요.
16-8. ANALYZE TABLE 과 히스토그램 (8.0)
옵티마이저의 rows/filtered 추정은 통계에 의존합니다. 통계가 낡으면 추정이 틀리고, 추정이 틀리면 계획이 나빠집니다.
ANALYZE TABLE — 인덱스 통계 갱신
ANALYZE TABLE access_logs;
대량 INSERT/DELETE 후, 또는 "인덱스가 있는데 안 쓴다" 싶을 때 실행합니다. 인덱스의 카디널리티 통계를 다시 계산합니다.
히스토그램 — 인덱스 없는 컬럼의 값 분포
인덱스 통계는 "값이 몇 종인가(카디널리티)"만 압니다. 값이 얼마나 치우쳐 있는지는 모릅니다. status_code 는 200 이 75%, 나머지가 각 5% 인데, 옵티마이저는 이 편향을 모르고 "6종이니 각 1/6" 이라고 뭉뚱그립니다.
히스토그램은 값의 실제 분포를 저장해 이 추정을 정확하게 만듭니다.
히스토그램 없이 status_code = 500 추정:
EXPLAIN SELECT COUNT(*) FROM access_logs WHERE status_code = 500;
| type | rows | filtered | Extra |
| ALL | 996151 | 10.00 | Using where | ← filtered 10% = 막연한 기본 추정
filtered: 10.00 — 근거 없는 기본값입니다(실제 500 은 5%).
히스토그램 생성 후:
ANALYZE TABLE access_logs UPDATE HISTOGRAM ON status_code, method;
EXPLAIN SELECT COUNT(*) FROM access_logs WHERE status_code = 500;
| type | rows | filtered | Extra |
| ALL | 996151 | 4.99 | Using where | ← filtered 4.99% = 실제 분포 반영!
filtered 가 10.00 → 4.99 로 정확해졌습니다(실제 status_code=500 은 정확히 5%). 예상 결과 행이 99,615 → 49,700 으로 바로잡혔습니다.
히스토그램 조회/삭제:
SELECT COLUMN_NAME,
JSON_EXTRACT(HISTOGRAM, '$."histogram-type"') AS htype
FROM information_schema.COLUMN_STATISTICS
WHERE SCHEMA_NAME='shop' AND TABLE_NAME='access_logs';
-- singleton(값 종류가 적을 때) 또는 equi-height(많을 때)
ANALYZE TABLE access_logs DROP HISTOGRAM ON status_code, method;
💡 히스토그램은 언제 쓰나
- 인덱스를 걸기엔 선택도가 낮지만(Step 15), 값이 심하게 편향된 컬럼(상태값, 등급, 국가 등)
- 그 컬럼이
WHERE/JOIN 조건에 자주 쓰여 옵티마이저의 행 수 추정이 중요할 때
인덱스와 달리 쓰기 비용이 없습니다(자동 갱신 안 됨). 대신 데이터가 바뀌면 수동으로 다시 만들어야 합니다.
⚠️ 함정 — 히스토그램은 인덱스가 아니다
히스토그램은 추정을 개선할 뿐, 데이터를 빨리 찾아주지 않습니다. status_code=500 조회 자체는 여전히 풀스캔입니다.
히스토그램은 "옵티마이저가 더 나은 계획(예: 조인 순서)을 세우도록" 돕는 것이지, 그 자체가 조회를 빠르게 하진 않습니다.
16-9. 실전 튜닝 절차
이 코스의 Step 15~16 을 하나의 절차로 묶습니다.
① 느린 쿼리 발견
- slow query log, performance_schema, 또는 애플리케이션 APM 으로 찾는다
- "가장 느린 쿼리"보다 "느리면서 자주 실행되는 쿼리"가 우선순위
② EXPLAIN 으로 진단
- type 이 ALL/index 인가? (풀스캔)
- key 가 NULL 인가? (인덱스 안 씀)
- Extra 에 Using filesort / Using temporary 가 있는가?
- rows 추정이 비현실적으로 크거나, 실제와 동떨어졌는가?
③ 원인별 처방
- 인덱스가 없다 → 인덱스 설계 (Step 15: 선두 컬럼·커버링)
- 쿼리가 못 타게 썼다 → sargable 하게 재작성 (함수 제거, 범위로)
- 통계가 낡았다 → ANALYZE TABLE
- 분포 추정이 틀렸다 → 히스토그램
- 옵티마이저가 오판한다 → (최후) 힌트
④ EXPLAIN ANALYZE 로 재측정
- actual time 이 실제로 줄었는가?
- actual rows 가 추정과 가까워졌는가?
⑤ 부작용 점검
- 새 인덱스가 INSERT/UPDATE 를 얼마나 느리게 하는가? (Step 15: 인덱스는 부채)
- 인비저블 인덱스로 안전하게 검증했는가?
미니 실습 — 절차를 한 번에
"특정 고객의 5xx 에러 로그를 최근순으로" 라는 요구가 있다고 합시다.
-- ① 대상 쿼리 (access_logs, 인덱스 없음)
-- ② EXPLAIN: type ALL, filesort 예상
EXPLAIN SELECT log_id, status_code, logged_at FROM access_logs
WHERE customer_id = 3 AND status_code >= 500 ORDER BY logged_at DESC LIMIT 10;
-- ③ 처방: (customer_id, logged_at) 로 정렬을 인덱스에 태우고,
-- status_code 는 커버링을 위해 포함 → (customer_id, logged_at, status_code)
ALTER TABLE access_logs ADD INDEX idx_tune (customer_id, logged_at, status_code);
-- ④ 재측정
EXPLAIN ANALYZE SELECT log_id, status_code, logged_at FROM access_logs
WHERE customer_id = 3 AND status_code >= 500 ORDER BY logged_at DESC LIMIT 10;
-- ⑤ 정리 (실습이므로 인덱스 제거)
ALTER TABLE access_logs DROP INDEX idx_tune;
④ 재측정 결과 (전 → 후)
BEFORE:
-> Limit: 10 row(s) (actual time=157..157 rows=10 loops=1)
-> Sort: logged_at DESC, limit to 10 (actual time=157..157 rows=10) ← filesort
-> Filter: (customer_id=3 and status_code>=500) (actual ... rows=16667)
-> Table scan on access_logs (actual time=0.23..123 rows=1e+6) ← 100만 풀스캔
AFTER:
-> Limit: 10 row(s) (actual time=0.0445..0.0477 rows=10 loops=1)
-> Filter: (status_code >= 500) (actual time=0.044..0.047 rows=10)
-> Covering index lookup on access_logs using idx_tune (customer_id=3) (reverse)
(actual time=0.043..0.045 rows=19) ← 인덱스로 19행만
157ms → 0.05ms. 풀스캔+filesort 가 커버링 인덱스 역방향 조회((reverse))로 바뀌었습니다.
practice.sql 에서 직접 실행해 확인하세요.
💡 이 시드 데이터는 재현성을 위해 값을 나머지 연산으로 만들어서, customer_id 와 status_code 가 서로 상관되어 있습니다
(예: customer_id=7 은 전부 200, customer_id=3 은 5xx 가 많음). 실제 데이터의 우연한 상관을 흉내 낸 것으로, 튜닝 실습에는 오히려 현실적입니다.
정리
| 항목 | 핵심 |
|---|
type 순위 | system > const > eq_ref > ref > range > index > ALL |
type: index | 인덱스 풀스캔 = 사실상 전수조사. 좋은 게 아니다 |
type: ALL | 대용량에서 최악. 단 작은 테이블에선 정상 |
key | 실제 고른 인덱스. NULL = 인덱스 미사용 |
rows/filtered | 추정치. 실제와 다를 수 있다 |
Extra 경보 | Using filesort, Using temporary |
Using index | 커버링. 테이블 접근 없음(좋음) |
| EXPLAIN ANALYZE | 실제 실행 + actual time/rows. UPDATE/DELETE 엔 위험 |
| FORMAT=TREE/JSON | 실행 흐름 / 비용 상세 |
| 인덱스 힌트 | USE / FORCE / IGNORE INDEX |
| 옵티마이저 힌트 | /*+ INDEX / NO_INDEX / JOIN_ORDER ... */ (최후 수단) |
| ANALYZE TABLE | 인덱스 통계 갱신 |
| 히스토그램(8.0) | 편향된 컬럼의 분포 → filtered 추정 개선. 조회를 빠르게 하진 않음 |
| 튜닝 절차 | 느린 쿼리 → EXPLAIN → 처방 → EXPLAIN ANALYZE 재측정 → 부작용 점검 |
연습문제
exercise.sql 에 7문제가 있습니다. 정답은 solution.sql.
- EXPLAIN 출력 읽고 무엇이 문제인지 진단
type 등급 서열 맞히기
- filesort/temporary 를 없애는 인덱스 설계
- EXPLAIN ANALYZE 로 추정 vs 실측 괴리 찾기
- 옵티마이저 힌트로 인덱스 강제/차단
- 히스토그램으로 filtered 추정 개선 확인
- 종합 튜닝 — 느린 쿼리를 EXPLAIN → 인덱스 → 재측정
다음 단계
Step 13~16 으로 스키마 설계(제약·정규화) → 뷰·생성 컬럼 → 인덱스 → 실행 계획 분석까지,
데이터를 "안전하게 담고 빠르게 꺼내는" 한 사이클을 완주했습니다.
다음 파트에서는 트랜잭션과 격리 수준, 락, 그리고 JSON·윈도우 함수 같은 고급 주제로 이어집니다.
(이 스텝 시리즈는 Step 16 에서 마칩니다. 수고하셨습니다.)
실습 파일
이 스텝은 SQL 파일 3개로 진행합니다. 먼저 practice.sql 을 실행해 본문 16-2~16-9 의 EXPLAIN 출력을 직접 눈으로 확인하고, 그다음 exercise.sql 의 7문제를 스스로 풀어 본 뒤, solution.sql 로 정답과 해설을 대조합니다.
세 파일 모두 USE shop; 으로 시작하며, shop 스키마(sql/01_schema.sql)와 100만 행짜리 access_logs 테이블(sql/04_seed_big.sql)이 이미 만들어져 있다는 전제로 동작합니다. access_logs 는 Step 15 에서 처음 쓰지만 만든 것은 공용 시드 스크립트이고, 보조 인덱스가 하나도 없는 상태(PK log_id 만) 로 시드됩니다. 이 "인덱스 없음"이 Step 15~16 실습의 출발점이므로, 앞 스텝의 인덱스가 남아 있으면 EXPLAIN 결과가 본문과 달라집니다. 그래서 실습으로 만든 인덱스와 히스토그램은 문제마다(그리고 practice.sql 은 파일 끝에서) 반드시 DROP 하도록 되어 있습니다.
practice.sql
본문의 예제를 위에서부터 그대로 따라가는 따라 하기용 스크립트입니다. mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 shop < practice.sql 로 한 번에 돌릴 수 있지만, EXPLAIN ANALYZE 와 FORMAT=TREE 는 여러 줄로 출력되므로 대화형 클라이언트에서 한 문장씩 실행하는 편이 훨씬 읽기 좋습니다(배치로 돌리면 줄바꿈이 \n 문자로 뭉개져 나옵니다).
[16-2] 블록은 const(PK 등치) → eq_ref(조인 1:1) → ref(비유니크 인덱스) → range(BETWEEN) → index(인덱스 풀스캔) → ALL(테이블 풀스캔) 순으로 type 등급을 실제 출력으로 하나씩 재현합니다. 마지막 EXPLAIN SELECT * FROM orders WHERE shipping_city = '서울'; 가 ALL 로 나오는 건 버그가 아니라 600행짜리 작은 테이블이라 옵티마이저가 일부러 고른 결과라는 점을 확인하세요.
[16-5] 블록은 ALTER TABLE access_logs ADD INDEX idx_customer (customer_id); 를 가운데 두고 같은 쿼리를 두 번 EXPLAIN ANALYZE 합니다. 전(Table scan, ~120ms) 과 후(Covering index lookup, ~3ms) 의 actual time 차이가 이 스텝의 핵심 체험입니다.
[16-7] 의 SET SESSION optimizer_switch = 'skip_scan=off'; 다음 줄에 바로 'skip_scan=on' 원복이 들어 있습니다. 세션 단위라 접속을 끊으면 어차피 사라지지만, 같은 세션에서 뒤 실습을 이어 하기 때문에 원복이 필요합니다.
[16-8] 은 ANALYZE TABLE access_logs UPDATE HISTOGRAM ON status_code, method; 전후로 status_code = 500 의 filtered 가 10.00 → 4.99 로 바뀌는 것을 보여주고, 끝에서 DROP HISTOGRAM 으로 되돌립니다.
- 스크립트 맨 끝의
ALTER TABLE access_logs DROP INDEX idx_customer; 와 SHOW INDEX FROM access_logs; 는 뒷정리 확인입니다. 여기서 PRIMARY 만 남아야 정상이며, 남아 있는 인덱스가 있으면 뒤이은 exercise.sql 의 EXPLAIN 결과가 본문과 달라집니다. access_logs 외의 공용 테이블(orders, customers)에는 인덱스를 만들거나 지우지 마세요.
-- =====================================================================
-- Step 16 — EXPLAIN 과 옵티마이저 : practice.sql
-- ---------------------------------------------------------------------
-- 실행: mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 shop < practice.sql
-- * EXPLAIN ANALYZE / FORMAT=TREE 는 대화형 클라이언트에서 봐야 여러 줄이 예쁘게 나옵니다.
-- (배치 실행 시 줄바꿈이 \n 으로 보일 수 있습니다)
--
-- ⚠️ access_logs 에 인덱스/히스토그램을 만들지만 마지막에 전부 정리합니다.
-- 다른 공용 테이블에는 인덱스를 만들거나 지우지 마세요.
-- =====================================================================
USE shop;
-- ---------------------------------------------------------------------
-- [16-2] type — 접근 방식 등급 (system > const > eq_ref > ref > range > index > ALL)
-- ---------------------------------------------------------------------
-- const : PK 등치
EXPLAIN SELECT * FROM customers WHERE customer_id = 1;
-- eq_ref : 조인에서 상대의 PK 를 1:1 매칭
EXPLAIN SELECT o.order_id, c.name
FROM orders o JOIN customers c ON c.customer_id = o.customer_id
WHERE o.order_id < 10;
-- ref : 비유니크 인덱스 등치
EXPLAIN SELECT * FROM orders WHERE customer_id = 1;
-- range : 범위
EXPLAIN SELECT * FROM orders WHERE order_id BETWEEN 1 AND 50;
-- index : 인덱스 풀스캔 (사실상 전수조사!)
EXPLAIN SELECT customer_id FROM orders;
-- ALL : 테이블 풀스캔 (작은 테이블에선 정상 — orders 는 600행뿐)
EXPLAIN SELECT * FROM orders WHERE shipping_city = '서울';
-- ---------------------------------------------------------------------
-- [16-3] Extra — Using filesort / Using temporary
-- ---------------------------------------------------------------------
-- GROUP BY(임시테이블) + ORDER BY 집계값(정렬) → 둘 다 등장
EXPLAIN SELECT shipping_city, COUNT(*) c FROM orders GROUP BY shipping_city ORDER BY c DESC;
-- ---------------------------------------------------------------------
-- [16-5] EXPLAIN ANALYZE — 추정이 아닌 실측 (8.0.18+)
-- ---------------------------------------------------------------------
-- 인덱스 없이: Table scan → 느림 (actual time 확인)
EXPLAIN ANALYZE SELECT COUNT(*) FROM access_logs WHERE customer_id = 7;
ALTER TABLE access_logs ADD INDEX idx_customer (customer_id);
-- 인덱스 후: Covering index lookup → 빠름
EXPLAIN ANALYZE SELECT COUNT(*) FROM access_logs WHERE customer_id = 7;
-- ---------------------------------------------------------------------
-- [16-6] FORMAT=TREE / JSON
-- ---------------------------------------------------------------------
EXPLAIN FORMAT=TREE
SELECT c.grade, COUNT(*) FROM orders o JOIN customers c ON c.customer_id = o.customer_id
GROUP BY c.grade;
EXPLAIN FORMAT=JSON SELECT * FROM orders WHERE customer_id = 1;
-- ---------------------------------------------------------------------
-- [16-7] 옵티마이저 제어 — 힌트와 스위치
-- ---------------------------------------------------------------------
-- 옵티마이저 힌트: 인덱스 사용 금지 → 풀스캔으로 강제
EXPLAIN SELECT /*+ NO_INDEX(access_logs idx_customer) */ COUNT(*)
FROM access_logs WHERE customer_id = 7;
-- 인덱스 힌트 (전통 방식)
EXPLAIN SELECT COUNT(*) FROM access_logs IGNORE INDEX (idx_customer) WHERE customer_id = 7;
EXPLAIN SELECT COUNT(*) FROM access_logs FORCE INDEX (idx_customer) WHERE customer_id = 7;
-- 옵티마이저 스위치 (대화형에서는 \G 로 세로 출력하면 읽기 좋다)
SELECT @@optimizer_switch;
SET SESSION optimizer_switch = 'skip_scan=off';
SET SESSION optimizer_switch = 'skip_scan=on'; -- 원복
-- ---------------------------------------------------------------------
-- [16-8] ANALYZE TABLE 과 히스토그램
-- ---------------------------------------------------------------------
ANALYZE TABLE access_logs;
-- 히스토그램 없이: filtered 10.00 (막연한 기본 추정)
EXPLAIN SELECT COUNT(*) FROM access_logs WHERE status_code = 500;
-- 히스토그램 생성
ANALYZE TABLE access_logs UPDATE HISTOGRAM ON status_code, method;
-- 히스토그램 후: filtered 4.99 (실제 분포 5% 반영)
EXPLAIN SELECT COUNT(*) FROM access_logs WHERE status_code = 500;
-- 히스토그램 조회
SELECT COLUMN_NAME,
JSON_EXTRACT(HISTOGRAM, '$."histogram-type"') AS htype
FROM information_schema.COLUMN_STATISTICS
WHERE SCHEMA_NAME = 'shop' AND TABLE_NAME = 'access_logs';
-- 히스토그램 삭제
ANALYZE TABLE access_logs DROP HISTOGRAM ON status_code, method;
-- ---------------------------------------------------------------------
-- [16-9] 실전 튜닝 절차 — 미니 실습
-- ---------------------------------------------------------------------
-- 요구: "특정 고객의 5xx 에러 로그를 최근순으로 10건"
-- BEFORE: 풀스캔 + filesort
EXPLAIN SELECT log_id, status_code, logged_at FROM access_logs
WHERE customer_id = 3 AND status_code >= 500 ORDER BY logged_at DESC LIMIT 10;
EXPLAIN ANALYZE SELECT log_id, status_code, logged_at FROM access_logs
WHERE customer_id = 3 AND status_code >= 500 ORDER BY logged_at DESC LIMIT 10;
-- 처방: (customer_id, logged_at, status_code) — 등치→정렬→커버링
ALTER TABLE access_logs ADD INDEX idx_tune (customer_id, logged_at, status_code);
-- AFTER: 커버링 인덱스 역방향 조회, filesort 제거 (157ms → 0.05ms)
EXPLAIN ANALYZE SELECT log_id, status_code, logged_at FROM access_logs
WHERE customer_id = 3 AND status_code >= 500 ORDER BY logged_at DESC LIMIT 10;
ALTER TABLE access_logs DROP INDEX idx_tune;
-- ---------------------------------------------------------------------
-- 정리 — access_logs 를 원래 상태(PK만)로
-- ---------------------------------------------------------------------
ALTER TABLE access_logs DROP INDEX idx_customer;
SHOW INDEX FROM access_logs; -- PRIMARY 만 남아야 정상
SELECT 'Step 16 practice 완료' AS msg;
exercise.sql
본문 「연습문제」에서 말한 7문제의 문제지입니다. 각 문제는 진단(주석으로 답 쓰기)과 실행(인덱스를 만들고 EXPLAIN 을 다시 보기)이 섞여 있으니, 빈 줄에 직접 SQL 과 주석을 채워 넣으며 푸세요.
- 문제 1의
EXPLAIN SELECT * FROM access_logs WHERE DATE(logged_at) = '2024-06-15'; 는 일부러 컬럼에 함수를 씌운 안티패턴입니다. DATE(logged_at) 때문에 인덱스를 탈 수 없어 type: ALL, key: NULL 이 나오는 것이 학습 포인트입니다(Step 15 의 sargable 규칙).
- 문제 3은
WHERE customer_id BETWEEN 1 AND 5 GROUP BY customer_id ORDER BY customer_id 에서 Using temporary; Using filesort 를 단일 컬럼 인덱스 하나로 동시에 없애는 것이 목표입니다.
- 문제 5는 파일 안에
ALTER TABLE access_logs ADD INDEX idx_ex5 (customer_id); 와 DROP INDEX idx_ex5; 가 미리 짝으로 들어 있습니다. 그 사이의 "여기에 작성" 자리에 힌트 실습을 채워야 하므로, 파일을 통째로 배치 실행하면 인덱스가 만들어졌다 바로 지워져 아무 관찰도 못 합니다. 한 줄씩 실행하세요.
- 문제 6의 지문은
method 를 "GET/POST/PUT 3종, GET 70% / POST 20% / PUT 10%" 로 안내하는데, solution.sql 은 "4종 ENUM 이라 기본 추정 25%" 라고 말합니다. 모순처럼 보이지만 둘 다 맞습니다 — 컬럼 정의는 ENUM('GET','POST','PUT','DELETE') 로 4종이고, 시드가 넣는 값은 GET/POST/PUT 3종뿐이라 DELETE 는 0건입니다. 옵티마이저는 히스토그램이 없으면 실제 데이터를 모른 채 ENUM 정의상의 4종을 균등하다고 가정해 filtered: 25.00(1/4) 을 내놓습니다. 실제 PUT 은 10% 이니 2.5배 과대추정이고, 이 괴리를 히스토그램으로 잡는 것이 문제 6의 핵심입니다.
- 문제 7은
method = 'GET' AND duration_ms >= 2900 ORDER BY logged_at DESC LIMIT 20 이라는 요구에서 등치 → 정렬 → 범위 순으로 복합 인덱스 컬럼 순서를 정하는 종합 문제입니다.
- 모든 문제에서 실습용 인덱스/히스토그램은 확인 후 반드시 DROP 하세요. 남겨 두면 다음 문제의 EXPLAIN 이 오염됩니다.
-- =====================================================================
-- Step 16 — EXPLAIN 과 옵티마이저 : exercise.sql (문제 7개)
-- ---------------------------------------------------------------------
-- 정답은 solution.sql. EXPLAIN / EXPLAIN ANALYZE 를 직접 돌려 확인하세요.
--
-- ⚠️ access_logs 에 만든 인덱스/히스토그램은 각 문제 끝에서 정리하세요.
-- =====================================================================
USE shop;
-- ---------------------------------------------------------------------
-- [문제 1] 이 EXPLAIN, 무엇이 문제인가
-- ---------------------------------------------------------------------
-- 아래 쿼리의 EXPLAIN 을 보고, (1-1) type/key/Extra 를 근거로 무엇이 문제인지,
-- (1-2) 이 테이블이 100만 행이라면 왜 위험한지 주석으로 진단하세요.
EXPLAIN SELECT * FROM access_logs WHERE DATE(logged_at) = '2024-06-15';
-- 여기에 진단 주석:
-- ---------------------------------------------------------------------
-- [문제 2] type 등급 서열
-- ---------------------------------------------------------------------
-- 다음 type 들을 "좋은 것 → 나쁜 것" 순으로 나열하세요(주석).
-- ALL, ref, const, index, range, eq_ref
-- 그리고 "index 와 ALL 중 무엇이 더 나쁠 수 있는가, 왜인가"를 한 줄로 쓰세요.
-- 여기에 답:
-- ---------------------------------------------------------------------
-- [문제 3] filesort + temporary 없애기
-- ---------------------------------------------------------------------
-- 다음 쿼리는 Using temporary; Using filesort 가 뜹니다.
-- SELECT customer_id, COUNT(*) c FROM access_logs
-- WHERE customer_id BETWEEN 1 AND 5 GROUP BY customer_id ORDER BY customer_id;
-- (3-1) 인덱스 전 EXPLAIN 을 확인하세요.
-- (3-2) temporary 와 filesort 를 둘 다 없애는 인덱스를 만들고 확인하세요.
-- (3-3) 왜 그 인덱스로 둘 다 사라지는지 주석으로 설명하고, 인덱스를 DROP 하세요.
-- 여기에 작성:
-- ---------------------------------------------------------------------
-- [문제 4] 추정 vs 실측 괴리 찾기
-- ---------------------------------------------------------------------
-- access_logs 에 인덱스를 만들고, 아래 쿼리를 EXPLAIN ANALYZE 로 실행하세요.
-- SELECT COUNT(*) FROM access_logs WHERE customer_id = 3;
-- (4-1) EXPLAIN 의 rows 추정값과 EXPLAIN ANALYZE 의 actual rows 를 비교하세요.
-- (4-2) 둘이 얼마나 차이 나는지, 그 차이가 왜 생기는지 주석으로 쓰세요.
-- (인덱스는 확인 후 DROP)
-- 여기에 작성:
-- ---------------------------------------------------------------------
-- [문제 5] 옵티마이저 힌트로 계획 제어
-- ---------------------------------------------------------------------
ALTER TABLE access_logs ADD INDEX idx_ex5 (customer_id);
-- (5-1) 평소엔 idx_ex5 를 쓰는 것을 EXPLAIN 으로 확인:
-- SELECT COUNT(*) FROM access_logs WHERE customer_id = 1;
-- (5-2) 옵티마이저 힌트로 이 인덱스를 "못 쓰게" 막아서 풀스캔이 되게 하세요.
-- (5-3) 같은 것을 인덱스 힌트(IGNORE INDEX)로도 해보세요.
-- 여기에 작성:
ALTER TABLE access_logs DROP INDEX idx_ex5;
-- ---------------------------------------------------------------------
-- [문제 6] 히스토그램으로 filtered 개선
-- ---------------------------------------------------------------------
-- method 는 GET/POST/PUT 3종이며 분포가 편향돼 있습니다(GET 70%, POST 20%, PUT 10%).
-- (6-1) 히스토그램 없이 method = 'PUT' 의 filtered 추정을 EXPLAIN 으로 확인:
-- (6-2) method 에 히스토그램을 만들고 filtered 가 어떻게 바뀌는지 확인:
-- (6-3) 실제 PUT 비율(10%)과 비교하고, 히스토그램을 DROP 하세요.
-- 여기에 작성:
-- ---------------------------------------------------------------------
-- [문제 7] 종합 튜닝
-- ---------------------------------------------------------------------
-- 요구: "GET 요청 중 응답이 느린(duration_ms >= 2900) 로그를, 최근순으로 20건"
-- SELECT log_id, path, duration_ms, logged_at
-- FROM access_logs
-- WHERE method = 'GET' AND duration_ms >= 2900
-- ORDER BY logged_at DESC
-- LIMIT 20;
--
-- (7-1) 인덱스 전 EXPLAIN ANALYZE 로 actual time 을 기록하세요.
-- (7-2) 적절한 인덱스를 설계해서 만드세요. (등치→정렬→범위 순서를 고민)
-- (7-3) 인덱스 후 EXPLAIN ANALYZE 로 actual time 을 다시 재고, 개선을 확인하세요.
-- (7-4) 인덱스를 DROP 하세요.
-- 여기에 작성:
solution.sql
exercise.sql 7문제의 정답과 해설입니다. 먼저 스스로 풀어 본 뒤에 열어 보세요. 단순 정답만이 아니라 "왜 그렇게 되는가"의 근거가 주석으로 길게 붙어 있습니다.
- 정답 3은
idx_ex3 (customer_id) 하나로 Extra 가 Using where; Using index 가 되어 temporary/filesort 가 둘 다 사라지는 이유를 설명합니다. 인덱스가 이미 customer_id 순으로 정렬돼 있어 그룹핑과 정렬을 공짜로 얻고, COUNT(*) 만 필요하므로 커버링까지 성립합니다.
- 정답 4는 EXPLAIN 의
rows 추정(≈66756)과 EXPLAIN ANALYZE 의 actual rows(33333)가 약 2배 어긋나는 실례입니다. customer_id 30종이 고르게 분포하므로 100만/30 ≈ 33333 이 실제값인데, 샘플링 기반 통계가 크게 잡은 것입니다.
- 정답 6은
filtered 가 25.00(4종 ENUM 이라 막연히 1/4) → 10.00(히스토그램이 실제 분포 반영)으로 정확해지는 과정을 보여주고, SELECT ROUND(SUM(method='PUT') / COUNT(*) * 100, 2) 로 실제 비율이 10.00 임을 검증합니다. 본문 16-8 의 "히스토그램은 인덱스가 아니다" 경고가 여기서도 반복됩니다 — 추정만 개선될 뿐 조회는 여전히 풀스캔입니다.
- 정답 7의
idx_q7 (method, logged_at, duration_ms) 는 범위 조건인 duration_ms 를 정렬 컬럼 뒤로 밀어 둔 것이 핵심입니다. 범위를 정렬 앞에 두면 인덱스의 정렬 순서가 깨져 filesort 가 되살아납니다. 결과는 211ms → 0.4ms 이며, path 가 인덱스에 없어 커버링은 아니지만 최종 20건에만 테이블을 접근하므로 충분히 빠릅니다.
- 스크립트 곳곳의
ALTER TABLE access_logs DROP INDEX ... / DROP HISTOGRAM ... 은 뒷정리이므로 생략하지 마세요.
-- =====================================================================
-- Step 16 — EXPLAIN 과 옵티마이저 : solution.sql (정답 + 해설)
-- ---------------------------------------------------------------------
-- ⚠️ access_logs 에 만든 인덱스/히스토그램은 각 문제 끝에서 정리합니다.
-- =====================================================================
USE shop;
-- ---------------------------------------------------------------------
-- [정답 1] 이 EXPLAIN, 무엇이 문제인가
-- ---------------------------------------------------------------------
EXPLAIN SELECT * FROM access_logs WHERE DATE(logged_at) = '2024-06-15';
-- 결과: type: ALL, key: NULL, rows ~996151, Using where
--
-- (1-1) 진단:
-- * type: ALL + key: NULL → 인덱스를 전혀 못 쓰고 전 행을 스캔합니다.
-- * 설령 logged_at 에 인덱스가 있어도 DATE(logged_at) 처럼 컬럼에 함수를 씌우면 못 탑니다(Step 15).
-- (1-2) 100만 행에서 위험한 이유:
-- * 매 실행마다 100만 행을 읽습니다. 이 쿼리가 초당 수십 번 실행되면 그때마다 풀스캔이 겹쳐
-- 디스크/CPU 를 소진하고, 버퍼 풀을 이 테이블로 가득 채워 다른 쿼리까지 느려집니다.
-- * 고침: 함수를 없애고 sargable 범위로.
-- WHERE logged_at >= '2024-06-15' AND logged_at < '2024-06-16'
-- (그리고 logged_at 에 인덱스를 만든다)
-- ---------------------------------------------------------------------
-- [정답 2] type 등급 서열
-- ---------------------------------------------------------------------
-- 좋은 것 → 나쁜 것:
-- const > eq_ref > ref > range > index > ALL
--
-- "index 와 ALL 중 무엇이 더 나쁠 수 있는가":
-- 보통은 ALL(테이블 풀스캔)이 나쁘지만, 둘 다 "전수조사"라는 점이 핵심입니다.
-- index 는 인덱스 전체를 훑는 것이라 커버링이면 테이블 접근이 없어 ALL 보다 나을 때가 많습니다.
-- 그러나 "index 니까 인덱스를 잘 탔다"는 착각이 더 위험합니다 — 실제로는 seek 가 아닌 풀스캔이니까요.
-- 결론: type 은 반드시 rows 와 함께 보고, index/ALL 이 대용량에서 나오면 둘 다 경보로 취급합니다.
-- ---------------------------------------------------------------------
-- [정답 3] filesort + temporary 없애기
-- ---------------------------------------------------------------------
-- (3-1) 인덱스 전: ALL + Using temporary + Using filesort
EXPLAIN SELECT customer_id, COUNT(*) c FROM access_logs
WHERE customer_id BETWEEN 1 AND 5 GROUP BY customer_id ORDER BY customer_id;
-- (3-2) customer_id 인덱스 하나면 충분
ALTER TABLE access_logs ADD INDEX idx_ex3 (customer_id);
EXPLAIN SELECT customer_id, COUNT(*) c FROM access_logs
WHERE customer_id BETWEEN 1 AND 5 GROUP BY customer_id ORDER BY customer_id;
-- 결과: type: range, Extra: "Using where; Using index" — temporary/filesort 둘 다 사라짐!
-- (3-3) 해설:
-- * 인덱스가 customer_id 순으로 정렬돼 있어 GROUP BY customer_id 를 임시테이블 없이 처리
-- ("정렬된 입력을 순서대로 읽으며 그룹 경계를 만난다" → tight/loose index scan).
-- * 같은 정렬 순서라 ORDER BY customer_id 도 추가 정렬(filesort)이 필요 없습니다.
-- * COUNT(*) 만 필요하므로 인덱스만 읽으면 됨(Using index, 커버링).
ALTER TABLE access_logs DROP INDEX idx_ex3;
-- ---------------------------------------------------------------------
-- [정답 4] 추정 vs 실측 괴리 찾기
-- ---------------------------------------------------------------------
ALTER TABLE access_logs ADD INDEX idx_ex4 (customer_id);
EXPLAIN SELECT COUNT(*) FROM access_logs WHERE customer_id = 3;
-- EXPLAIN 의 rows: 약 66756 (추정)
EXPLAIN ANALYZE SELECT COUNT(*) FROM access_logs WHERE customer_id = 3;
-- actual rows: 33333 (실측)
--
-- (4-1)(4-2) 해설:
-- * 추정(≈66756) vs 실측(33333) → 약 2배 차이.
-- * 왜? 인덱스 카디널리티 통계는 "샘플링"으로 추정합니다. customer_id 는 30종이 고르게 분포하므로
-- "100만 / 30 ≈ 33333" 이 실제인데, 통계 추정은 그보다 크게 잡았습니다.
-- * 이 정도 오차로는 계획이 안 바뀌지만, 오차가 크면(예: 10배) 옵티마이저가
-- "인덱스 대신 풀스캔"처럼 잘못된 선택을 합니다. 그때 ANALYZE TABLE / 히스토그램으로 바로잡습니다.
-- * 교훈: EXPLAIN 의 rows 는 "추정"이다. 진짜 값은 EXPLAIN ANALYZE 의 actual rows 로 본다.
ALTER TABLE access_logs DROP INDEX idx_ex4;
-- ---------------------------------------------------------------------
-- [정답 5] 옵티마이저 힌트로 계획 제어
-- ---------------------------------------------------------------------
ALTER TABLE access_logs ADD INDEX idx_ex5 (customer_id);
-- (5-1) 평소엔 인덱스 사용: type ref
EXPLAIN SELECT COUNT(*) FROM access_logs WHERE customer_id = 1;
-- (5-2) 옵티마이저 힌트로 차단 → type ALL
EXPLAIN SELECT /*+ NO_INDEX(access_logs idx_ex5) */ COUNT(*)
FROM access_logs WHERE customer_id = 1;
-- (5-3) 인덱스 힌트로도 동일하게
EXPLAIN SELECT COUNT(*) FROM access_logs IGNORE INDEX (idx_ex5) WHERE customer_id = 1;
--
-- 해설: 둘 다 인덱스를 못 쓰게 만들어 풀스캔으로 되돌립니다.
-- 옵티마이저 힌트(/*+ ... */)는 8.0 권장 방식이고 더 세밀합니다.
-- 인덱스 힌트(USE/FORCE/IGNORE INDEX)는 전통 방식입니다.
-- ⚠️ 실무에서 이런 강제는 최후의 수단 — 데이터가 커지면 강제된 계획이 발목을 잡습니다.
ALTER TABLE access_logs DROP INDEX idx_ex5;
-- ---------------------------------------------------------------------
-- [정답 6] 히스토그램으로 filtered 개선
-- ---------------------------------------------------------------------
-- (6-1) 히스토그램 없이: filtered 25.00 (ENUM 4종이라 막연히 1/4 로 추정)
EXPLAIN SELECT COUNT(*) FROM access_logs WHERE method = 'PUT';
-- (6-2) 히스토그램 생성 후: filtered 10.00 (실제 분포 반영)
ANALYZE TABLE access_logs UPDATE HISTOGRAM ON method;
EXPLAIN SELECT COUNT(*) FROM access_logs WHERE method = 'PUT';
-- (6-3) 검증: 실제 PUT 비율
SELECT ROUND(SUM(method='PUT') / COUNT(*) * 100, 2) AS put_pct FROM access_logs; -- 10.00
--
-- 해설:
-- * method 는 GET/POST/PUT/DELETE 4종 ENUM 이라, 히스토그램 없으면 옵티마이저가 "1/4 = 25%" 로 추정.
-- * 실제 PUT 은 10% 뿐. 히스토그램이 이 편향을 저장해 filtered 를 25 → 10 으로 정확히 잡았습니다.
-- * 주의: 히스토그램은 "추정"만 개선합니다. method='PUT' 조회 자체는 여전히 풀스캔입니다(인덱스가 아님).
-- 이 정확한 추정은 조인 순서 등 "더 나은 계획"을 세우는 데 쓰입니다.
ANALYZE TABLE access_logs DROP HISTOGRAM ON method;
-- ---------------------------------------------------------------------
-- [정답 7] 종합 튜닝
-- ---------------------------------------------------------------------
-- (7-1) 인덱스 전: 풀스캔 + filesort, actual time ~211ms
EXPLAIN ANALYZE SELECT log_id, path, duration_ms, logged_at FROM access_logs
WHERE method = 'GET' AND duration_ms >= 2900 ORDER BY logged_at DESC LIMIT 20;
-- (7-2) 인덱스 설계:
-- 등치 조건 method → 정렬 컬럼 logged_at → 범위 조건 duration_ms 순서.
-- → (method, logged_at, duration_ms)
-- 이유:
-- * method 등치를 선두에 두어 GET 구간으로 좁힌다.
-- * 그 안에서 logged_at 이 정렬돼 있으므로 ORDER BY logged_at DESC 를 filesort 없이
-- 역방향 인덱스 스캔으로 처리한다.
-- * duration_ms 는 범위 조건이라 정렬 컬럼 뒤에 둔다(범위를 정렬 앞에 두면 정렬이 깨진다).
ALTER TABLE access_logs ADD INDEX idx_q7 (method, logged_at, duration_ms);
-- (7-3) 인덱스 후: Index lookup (reverse), filesort 제거, actual time ~0.4ms
EXPLAIN ANALYZE SELECT log_id, path, duration_ms, logged_at FROM access_logs
WHERE method = 'GET' AND duration_ms >= 2900 ORDER BY logged_at DESC LIMIT 20;
-- 211ms → 0.4ms. LIMIT 20 이라 인덱스를 뒤에서부터 조금만 읽고 20건을 채우면 끝납니다.
--
-- 참고: path 는 인덱스에 없어 커버링은 아니지만, 최종 20건에 대해서만 테이블을 읽으므로 충분히 빠릅니다.
-- 커버링까지 원하면 (method, logged_at, duration_ms, path, log_id) 로 넓힐 수 있지만
-- 인덱스가 커지므로 "20건 테이블 접근"과 저울질해야 합니다.
-- (7-4) 정리
ALTER TABLE access_logs DROP INDEX idx_q7;
SELECT 'Step 16 solution 완료' AS msg;