실행 계획(EXPLAIN) — 쿼리 성능을 읽는 방법
쿼리가 느린데, 어디서부터 봐야 할까?
EXPLAIN 은 데이터베이스에게 "이 쿼리를 어떻게 실행할 건지 알려줘"라고 묻는 명령입니다. 공부하다 보니 EXPLAIN을 읽을 줄 아느냐 모르느냐가 "쿼리를 튜닝할 수 있느냐 없느냐"를 결정하더라고요. 실행 계획의 문제를 잡는 것만으로 쿼리 시간을 75%까지 줄일 수 있습니다.
EXPLAIN 기본 사용법
EXPLAIN SELECT * FROM users WHERE email = 'test@example.com';
쿼리 앞에 EXPLAIN을 붙이면 됩니다. 실제로 쿼리를 실행하지 않고 옵티마이저의 예상 실행 계획만 보여줍니다.
출력 컬럼 해석
MySQL EXPLAIN의 주요 컬럼입니다.
| 컬럼 | 설명 |
|---|---|
| id | SELECT 쿼리의 식별자 (서브쿼리면 번호가 다름) |
| select_type | SELECT의 유형 (SIMPLE, PRIMARY, SUBQUERY 등) |
| table | 접근하는 테이블 이름 |
| type | ** 접근 방식** (가장 중요!) |
| possible_keys | 사용할 수 있는 인덱스 후보 |
| key | 실제 사용된 인덱스 |
| key_len | 사용된 인덱스의 바이트 길이 |
| rows | 옵티마이저가 예상한 검사 행 수 |
| Extra | 추가 정보 (여기도 중요!) |
type 컬럼 — 핵심 중의 핵심
type은 테이블에 어떻게 접근하는지를 나타냅니다. ** 좋은 순서대로** 정리하면:
const (최고)
-- PK 또는 UNIQUE 인덱스로 상수 조건 검색
EXPLAIN SELECT * FROM users WHERE id = 1;
-- type: const → 최대 1행만 반환, 가장 빠름
eq_ref
-- JOIN에서 PK 또는 UNIQUE 인덱스 사용
EXPLAIN SELECT * FROM orders o JOIN users u ON o.user_id = u.id;
-- users 테이블: type = eq_ref → JOIN 시 정확히 1행 매칭
ref
-- 논유니크 인덱스로 검색
EXPLAIN SELECT * FROM users WHERE department_id = 5;
-- type: ref → 인덱스를 사용하지만 여러 행이 매칭될 수 있음
range
-- 인덱스를 범위로 스캔
EXPLAIN SELECT * FROM users WHERE age BETWEEN 20 AND 30;
-- type: range → 인덱스의 일부 범위를 스캔
index
-- 인덱스 전체를 스캔 (Full Index Scan)
EXPLAIN SELECT department_id FROM users;
-- type: index → 테이블은 안 읽지만 인덱스 전체를 순회
ALL (최악)
-- Full Table Scan
EXPLAIN SELECT * FROM users WHERE name LIKE '%철수';
-- type: ALL → 테이블 전체를 처음부터 끝까지 읽음
기억하기 쉽게 정리하면: const > eq_ref > ref > range > index > ALL. ALL이 나오면 일단 인덱스를 의심하세요.
key_len으로 복합 인덱스 활용도 파악
복합 인덱스 (name, age, city)가 있을 때:
-- name만 사용: key_len = name의 바이트 수
EXPLAIN SELECT * FROM users WHERE name = '김철수';
-- name + age 사용: key_len = name + age의 바이트 수
EXPLAIN SELECT * FROM users WHERE name = '김철수' AND age = 25;
-- name + age + city 전부 사용: key_len = 전체 합
EXPLAIN SELECT * FROM users WHERE name = '김철수' AND age = 25 AND city = '서울';
key_len이 클수록 복합 인덱스를 더 많이 활용하고 있다는 뜻입니다. 각 컬럼의 바이트 수를 알면 몇 번째 컬럼까지 인덱스를 탔는지 역산할 수 있습니다.
VARCHAR(50) + utf8mb4 = 50 * 4 + 2(길이 정보) = 202 바이트
INT = 4 바이트
NULLABLE 컬럼은 +1 바이트
Extra 컬럼 해석
Using index (좋음)
Extra: Using index
커버링 인덱스가 적용되어 **테이블 데이터에 접근하지 않음 **. 인덱스만으로 쿼리가 완료됩니다.
Using where (보통)
Extra: Using where
스토리지 엔진에서 가져온 행을 ** 서버 레이어에서 추가 필터링 **한다는 의미. 인덱스로 걸러지지 않는 조건이 있다는 뜻입니다.
Using filesort (주의)
Extra: Using filesort
인덱스 순서로 정렬할 수 없어 ** 별도의 정렬 작업 **이 필요합니다. ORDER BY가 인덱스와 맞지 않을 때 발생합니다.
-- 인덱스: (name)
SELECT * FROM users WHERE name = '김철수' ORDER BY age;
-- → Using filesort (age에 대한 인덱스가 없음)
-- 개선: 인덱스를 (name, age)로 변경하면 Using filesort 제거 가능
Using temporary (주의)
Extra: Using temporary
쿼리 실행에 임시 테이블 이 필요합니다. GROUP BY, DISTINCT, UNION 등에서 발생할 수 있습니다.
-- GROUP BY와 ORDER BY 컬럼이 다를 때
SELECT department, COUNT(*) FROM users GROUP BY department ORDER BY COUNT(*) DESC;
-- → Using temporary; Using filesort
Using index condition (ICP)
Extra: Using index condition
인덱스 컨디션 푸시다운(ICP). 스토리지 엔진 레벨에서 인덱스를 이용해 추가 필터링을 수행합니다. Using where보다 효율적입니다.
EXPLAIN ANALYZE (MySQL 8.0+)
EXPLAIN ANALYZE는 ** 실제로 쿼리를 실행 **하고, 각 단계의 실제 시간과 행 수를 보여줍니다.
EXPLAIN ANALYZE SELECT * FROM users WHERE age > 25 ORDER BY name;
출력 예시:
-> Sort: users.name (actual time=2.5..3.1 rows=1500 loops=1)
-> Filter: (users.age > 25) (actual time=0.05..1.8 rows=1500 loops=1)
-> Table scan on users (actual time=0.04..1.2 rows=10000 loops=1)
읽는 방법:
- 안쪽(들여쓰기 깊은 것)부터 읽습니다 → 데이터가 아래에서 위로 흐릅니다
actual time: 첫 번째 행까지의 시간 .. 마지막 행까지의 시간 (밀리초)rows: 실제 처리된 행 수loops: 이 단계가 반복 실행된 횟수
공부하다 보니 EXPLAIN의
rows(예상)와 EXPLAIN ANALYZE의rows(실제)가 크게 다르면, 통계 정보가 오래되었을 수 있습니다.ANALYZE TABLE로 통계를 갱신하세요.
실전 예시: 느린 쿼리 개선
Before
EXPLAIN SELECT u.name, o.total
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.email = 'test@example.com'
ORDER BY o.created_at DESC;
+----+------+-------+------+------+------+---------------------------------+
| id | type | table | type | key | rows | Extra |
+----+------+-------+------+------+------+---------------------------------+
| 1 | u | users | ALL | NULL | 50000| Using where; Using filesort |
| 1 | o | orders| ALL | NULL |100000| Using where |
+----+------+-------+------+------+------+---------------------------------+
문제점:
- 두 테이블 모두
type = ALL(Full Table Scan) Using filesort발생- 예상 검사 행: 50,000 x 100,000 = 50억 조합
After
-- 인덱스 추가
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at DESC);
+----+------+-------+-------+------------------------+------+-------------+
| id | type | table | type | key | rows | Extra |
+----+------+-------+-------+------------------------+------+-------------+
| 1 | u | users | const | idx_users_email | 1 | NULL |
| 1 | o | orders| ref | idx_orders_user_created| 15 | Using index |
+----+------+-------+-------+------------------------+------+-------------+
개선 결과:
- users:
ALL → const(50,000행 → 1행) - orders:
ALL → ref(100,000행 → 15행) Using filesort제거 (인덱스에 created_at 포함)Using index적용 (커버링 인덱스)
EXPLAIN 체크리스트
실행 계획을 볼 때 이 순서로 확인하면 됩니다.
- type이 ALL인가? → 인덱스 추가 검토
- key가 NULL인가? → possible_keys가 있는데 key가 NULL이면 옵티마이저가 인덱스를 선택하지 않은 것
- rows가 비정상적으로 큰가? → 조건이 충분히 좁히지 못하는 것
- Extra에 Using filesort가 있는가? → ORDER BY에 맞는 인덱스 검토
- Extra에 Using temporary가 있는가? → GROUP BY/ORDER BY 조합 개선 검토
- key_len이 기대보다 짧은가? → 복합 인덱스의 일부만 사용 중
정리
- EXPLAIN은 쿼리 앞에 붙여서 실행 계획을 확인하는 명령
- type 컬럼이 가장 중요: const > eq_ref > ref > range > index > ALL
- key_len으로 복합 인덱스를 몇 번째 컬럼까지 활용했는지 파악
- Extra의 Using filesort, Using temporary는 개선 포인트
- EXPLAIN ANALYZE로 예상 vs 실제 차이를 확인
- ALL이 보이면 인덱스를 의심, Using index가 보이면 잘 최적화된 것
쿼리 튜닝의 시작은 항상 EXPLAIN입니다. 감으로 인덱스를 만들지 말고, 실행 계획을 먼저 확인하세요.