옵티마이저가 이 SQL을 실제로 어떻게 실행할지 결정한 결과물. 튜닝의 출발점이다.
왜 계획이 여러 개인가
SQL은 "무엇"만 선언하고 "어떻게"는 안 적는다. SELECT * FROM orders o JOIN member m ON ... WHERE ... 한 줄을 실행하는 방법은 수십 가지다.
어느 테이블을 먼저 읽을까 인덱스를 탈까 풀 스캔할까 조인을 Nested Loop · Hash · Merge 중 무엇으로 할까
그리고 방법 사이의 속도 차이가 수천 배까지 난다. 그 선택을 옵티마이저가 대신 하고, 결과가 실행계획이다.
EXPLAIN과 EXPLAIN ANALYZE는 다르다
- EXPLAIN — 계획만 보여준다 (추정치). 쿼리를 실행하지 않는다
- EXPLAIN ANALYZE — 실제로 실행해 진짜 시간과 행 수까지 보여준다
진단할 때는 반드시 ANALYZE를 본다. 추정만 봐서는 통계가 거짓말을 하는지 알 수 없기 때문이다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE member_id = 42 ORDER BY created_at DESC LIMIT 20;
Limit (actual time=0.05..0.07 rows=20)
-> Index Scan using idx_orders_member_created on orders ← 인덱스로 바로 진입(좋음)
Index Cond: (member_id = 42)
(estimated rows=18 actual rows=20) ← 추정 ≈ 실제 (통계 건강)
읽는 세 가지 요령
① 가장 안쪽(들여쓰기가 깊은) 노드부터 실행된다. 데이터가 안에서 밖으로 흐른다.
② estimated rows vs actual rows의 차이를 본다. 추정 100인데 실제 100만이면 통계가 거짓말을 한 것이고, 나쁜 계획의 원흉이다. 이 경우 인덱스나 쿼리를 고치기 전에 통계부터 갱신한다.
③ 큰 테이블에 Seq Scan이 보이면 인덱스 누락이나 인덱스 무력화를 의심한다.
MySQL은 type 칼럼이 핵심 신호다
- 좋은 순서
- const — PK로 1행
- eq_ref — 유니크 인덱스 조인
- ref — 인덱스 동치
- range — 범위
- index — 인덱스 풀스캔
- ALL — 풀 테이블 스캔 ← 큰 테이블이면 빨간불
Extra 칼럼의 Using filesort·Using temporary 도 경고다 — 정렬이나 임시 테이블을 따로 만든다는 뜻이라 비싸다. 인덱스 순서를 ORDER BY에 맞추면 filesort가 사라진다.
면접 함정
- ❌ "EXPLAIN이 쿼리를 실행한다" →
EXPLAIN만으로는 실행하지 않는다.ANALYZE를 붙여야 실행한다(그래서 UPDATE/DELETE에 쓸 때는 트랜잭션으로 감싸고 롤백한다). - ❌ "계획이 항상 같다" → 통계·데이터량·파라미터에 따라 바뀐다. 운영과 개발 DB의 계획이 다른 게 정상이다.
UPDATE·DELETE의 계획도 안전하게 볼 수 있다
-- ANALYZE 는 실제로 실행하므로 트랜잭션으로 감싸고 롤백한다
BEGIN;
EXPLAIN (ANALYZE, BUFFERS) DELETE FROM orders WHERE created_at < '2020-01-01';
ROLLBACK;
계획이 바뀌는 걸 잡아내기
-- PostgreSQL: 느린 쿼리를 총합 기준으로 뽑는다
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT calls, mean_exec_time, total_exec_time, query
FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;
여기서 중요한 통찰 하나 — "가장 느린 한 방"보다 "자주 불려 총합이 큰 쿼리" 가 대개 더 아프다.
| 1ms 짜리가 100만 번 | → | 총 1,000초 |
|---|---|---|
| 1초 짜리가 10번 | → | 총 10초 |
- 우선순위는 평균이 아니라 호출수 × 평균 의 총합으로 잡는다
MySQL은 slow query log와 sys 스키마로 같은 일을 한다.
SELECT * FROM sys.statement_analysis ORDER BY total_latency DESC LIMIT 10;