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  |
+----+-------------+--------+------+---------------------+---------------------+---------+-------+------+----------+-------+

각 컬럼의 의미:

컬럼의미
idSELECT 식별자. JOIN 은 같은 id, 서브쿼리는 다른 id
select_typeSIMPLE / 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 만 보고 좋다/나쁘다 하면 안 됩니다.


16-3. Extra — 숨은 비용이 드러나는 곳

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. rowsfiltered — 추정치임을 잊지 마라

rowsfiltered옵티마이저의 추정치입니다. 통계에 기반한 예측이지 실제 값이 아닙니다.

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_idstatus_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.

  1. EXPLAIN 출력 읽고 무엇이 문제인지 진단
  2. type 등급 서열 맞히기
  3. filesort/temporary 를 없애는 인덱스 설계
  4. EXPLAIN ANALYZE 로 추정 vs 실측 괴리 찾기
  5. 옵티마이저 힌트로 인덱스 강제/차단
  6. 히스토그램으로 filtered 추정 개선 확인
  7. 종합 튜닝 — 느린 쿼리를 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 ANALYZEFORMAT=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 = 500filtered 가 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종뿐이라 DELETE0건입니다. 옵티마이저는 히스토그램이 없으면 실제 데이터를 모른 채 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) 하나로 ExtraUsing 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;