EXPLAIN 완전 정복 — 실행 계획 읽는 법
쿼리가 느린데, 원인을 어떻게 찾을 수 있을까요? MySQL은 쿼리를 실행하기 전에 어떤 계획을 세우는지 들여다볼 수 있는 방법을 제공합니다.
EXPLAIN이란
EXPLAIN은 MySQL이 쿼리를 어떻게 실행할 것인지를 보여주는 명령입니다. 어떤 인덱스를 사용하는지, 몇 개의 행을 읽는지, 어떤 순서로 테이블에 접근하는지를 알 수 있습니다.
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
쿼리를 실제로 실행하지 않고 실행 계획만 보여주므로 운영 환경에서도 안전하게 사용할 수 있습니다.
EXPLAIN 출력 컬럼 해석
id — 쿼리 실행 순서
EXPLAIN
SELECT * FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE c.name = '홍길동';
| id | select_type | table |
|---|---|---|
| 1 | SIMPLE | c |
| 1 | SIMPLE | o |
- 같은 id: 조인으로 함께 실행됩니다
- 다른 id: 큰 숫자가 먼저 실행됩니다 (서브쿼리 등)
select_type — 쿼리 유형
| 값 | 설명 |
|---|---|
| SIMPLE | 서브쿼리나 UNION이 없는 단순 쿼리 |
| PRIMARY | 가장 바깥쪽 쿼리 |
| SUBQUERY | WHERE절의 서브쿼리 |
| DERIVED | FROM절의 서브쿼리 (파생 테이블) |
| UNION | UNION의 두 번째 이후 쿼리 |
| MATERIALIZED | 서브쿼리가 임시 테이블로 실체화됨 |
type — 접근 방식 (가장 중요!)
성능에 가장 큰 영향을 미치는 컬럼입니다. 위에서 아래로 갈수록 효율적입니다.
성능 나쁨 성능 좋음
ALL → index → range → ref → eq_ref → const → system
| type | 설명 | 예시 |
|---|---|---|
| ALL | 풀 테이블 스캔 | 인덱스가 없거나 옵티마이저가 인덱스를 사용하지 않기로 결정 |
| index | 풀 인덱스 스캔 | 인덱스 전체를 처음부터 끝까지 읽음 |
| range | 인덱스 범위 스캔 | WHERE id > 100, WHERE id BETWEEN 1 AND 10 |
| ref | 비유니크 인덱스로 검색 | WHERE status = 'active' (일반 인덱스) |
| eq_ref | 유니크/PK 인덱스로 조인 | JOIN ... ON pk = fk |
| const | 유니크/PK 인덱스로 단일 행 | WHERE id = 1 (PK 조건) |
| system | 테이블에 행이 1개뿐 | 시스템 테이블 |
-- type: const (PK로 단일 행 조회)
EXPLAIN SELECT * FROM users WHERE id = 1;
-- type: ref (일반 인덱스로 조회)
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
-- type: ALL (풀 테이블 스캔)
EXPLAIN SELECT * FROM orders WHERE YEAR(created_at) = 2025;
-- 함수로 감싸면 인덱스를 사용할 수 없습니다!
key / key_len — 사용된 인덱스
EXPLAIN SELECT * FROM orders WHERE customer_id = 42 AND status = 'completed';
| key | key_len |
|---|---|
| idx_customer_status | 54 |
- key: 실제 사용된 인덱스 이름. NULL이면 인덱스를 사용하지 않음
- key_len: 인덱스에서 사용된 바이트 수. 복합 인덱스에서 몇 개의 컬럼까지 활용되었는지 파악할 수 있습니다
key_len 계산 예시:
INT: 4 bytes
VARCHAR(50) UTF8MB4: 50 × 4 + 2(길이) = 202 bytes
NULL 허용 컬럼: +1 byte
rows — 예상 검사 행 수
옵티마이저가 이 단계에서 읽을 것으로 예상하는 행의 수입니다. 실제 값이 아닌 통계 기반 추정치입니다.
-- rows가 크면 많은 행을 읽는다는 의미
EXPLAIN SELECT * FROM orders WHERE status = 'pending';
-- rows: 50000 → 5만 행을 읽을 예정
filtered — 필터링 비율
rows 중에서 조건을 만족하여 다음 단계로 전달되는 행의 비율(%)입니다.
rows = 1000, filtered = 10.00
→ 1000행을 읽어서 100행(10%)이 조건을 만족
Extra — 추가 정보 (두 번째로 중요!)
성능 관련 핵심 정보가 여기에 표시됩니다.
| Extra | 의미 | 조치 |
|---|---|---|
| Using index | 커버링 인덱스 (인덱스만으로 처리) | 좋음! |
| Using where | WHERE 조건으로 필터링 | 보통 |
| Using filesort | 정렬을 위한 추가 작업 필요 | 인덱스 개선 검토 |
| Using temporary | 임시 테이블 사용 | GROUP BY/ORDER BY 개선 검토 |
| Using index condition | 인덱스 컨디션 푸시다운 (ICP) | 좋음! |
실전 예제 — 실행 계획 읽기
예제 1: 비효율적인 쿼리
EXPLAIN
SELECT * FROM orders
WHERE YEAR(created_at) = 2025
ORDER BY amount DESC;
id: 1
type: ALL ← 풀 테이블 스캔!
key: NULL ← 인덱스 미사용!
rows: 1000000
Extra: Using where; Using filesort ← 필터링 + 추가 정렬!
문제점:
YEAR(created_at): 함수로 감싸면 인덱스를 사용할 수 없습니다ORDER BY amount DESC: 인덱스가 없어 별도 정렬이 필요합니다
예제 2: 개선된 쿼리
-- 인덱스 추가
CREATE INDEX idx_created_amount ON orders(created_at, amount);
EXPLAIN
SELECT * FROM orders
WHERE created_at >= '2025-01-01' AND created_at < '2026-01-01'
ORDER BY created_at, amount;
id: 1
type: range ← 인덱스 범위 스캔
key: idx_created_amount
rows: 50000
Extra: Using index condition
EXPLAIN FORMAT=JSON
더 상세한 정보를 JSON 형식으로 볼 수 있습니다.
EXPLAIN FORMAT=JSON
SELECT * FROM orders WHERE customer_id = 42\G
{
"query_block": {
"select_id": 1,
"cost_info": {
"query_cost": "10.50" -- 쿼리 전체 비용
},
"table": {
"table_name": "orders",
"access_type": "ref",
"key": "idx_customer",
"rows_examined_per_scan": 10,
"rows_produced_per_join": 10,
"cost_info": {
"read_cost": "2.50",
"eval_cost": "1.00",
"prefix_cost": "10.50"
}
}
}
}
cost_info를 통해 옵티마이저가 산정한 비용을 정확히 확인할 수 있습니다.
EXPLAIN ANALYZE (MySQL 8.0.18+)
가장 강력한 분석 도구입니다. 실제로 쿼리를 실행하고 예측값과 실측값을 함께 보여줍니다.
EXPLAIN ANALYZE
SELECT * FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE c.city = '서울';
-> Nested loop join (cost=234.50 rows=100)
(actual time=0.234..5.678 rows=85 loops=1)
-> Index lookup on c using idx_city (city='서울')
(cost=12.50 rows=50)
(actual time=0.045..0.123 rows=42 loops=1)
-> Index lookup on o using idx_customer (customer_id=c.id)
(cost=4.44 rows=2)
(actual time=0.015..0.089 rows=2.02 loops=42)
핵심 정보는 다음과 같습니다.
- cost: 예측 비용
- rows: 예측 행 수
- actual time: 첫 행 반환 시간..마지막 행 반환 시간 (밀리초)
- rows (actual): 실제 반환 행 수
- loops: 해당 단계가 반복 실행된 횟수
예측과 실측의 차이가 크면 통계 정보가 부정확한 것이므로 ANALYZE TABLE로 갱신을 고려합니다.
ANALYZE TABLE orders; -- 통계 정보 갱신
자주 만나는 성능 문제 패턴
1. type: ALL + rows가 큰 경우
-- 인덱스가 없는 컬럼으로 조회
EXPLAIN SELECT * FROM logs WHERE message LIKE '%error%';
-- type: ALL, rows: 10000000
해결: 풀텍스트 인덱스 또는 별도 검색 엔진 사용
2. Using filesort + Using temporary
EXPLAIN SELECT department, COUNT(*) FROM employees
GROUP BY department ORDER BY COUNT(*) DESC;
-- Extra: Using temporary; Using filesort
해결: GROUP BY 컬럼에 인덱스 추가
3. key_len이 예상보다 짧은 경우
-- 복합 인덱스: (a, b, c)
EXPLAIN SELECT * FROM t WHERE a = 1 AND c = 3;
-- key_len이 a 컬럼만큼만 → b를 건너뛰어 c를 사용하지 못함
해결: WHERE 조건에 b도 포함하거나, 인덱스 순서 변경
정리
type컬럼이 가장 중요합니다. ALL(풀 스캔)은 거의 항상 개선이 필요합니다Extra에서 Using filesort, Using temporary는 성능 저하의 신호입니다rows는 추정치이므로, 정확한 분석이 필요하면EXPLAIN ANALYZE를 사용합니다- 실행 계획 분석은 쿼리 최적화의 시작점이며, 인덱스 설계와 쿼리 변경의 근거가 됩니다
댓글 로딩 중...