쿼리가 느린데, 어디서부터 봐야 할까?

EXPLAIN 은 데이터베이스에게 "이 쿼리를 어떻게 실행할 건지 알려줘"라고 묻는 명령입니다. 공부하다 보니 EXPLAIN을 읽을 줄 아느냐 모르느냐가 "쿼리를 튜닝할 수 있느냐 없느냐"를 결정하더라고요. 실행 계획의 문제를 잡는 것만으로 쿼리 시간을 75%까지 줄일 수 있습니다.

EXPLAIN 기본 사용법

SQL
EXPLAIN SELECT * FROM users WHERE email = 'test@example.com';

쿼리 앞에 EXPLAIN을 붙이면 됩니다. 실제로 쿼리를 실행하지 않고 옵티마이저의 예상 실행 계획만 보여줍니다.

출력 컬럼 해석

MySQL EXPLAIN의 주요 컬럼입니다.

컬럼설명
idSELECT 쿼리의 식별자 (서브쿼리면 번호가 다름)
select_typeSELECT의 유형 (SIMPLE, PRIMARY, SUBQUERY 등)
table접근하는 테이블 이름
type** 접근 방식** (가장 중요!)
possible_keys사용할 수 있는 인덱스 후보
key실제 사용된 인덱스
key_len사용된 인덱스의 바이트 길이
rows옵티마이저가 예상한 검사 행 수
Extra추가 정보 (여기도 중요!)

type 컬럼 — 핵심 중의 핵심

type은 테이블에 어떻게 접근하는지를 나타냅니다. ** 좋은 순서대로** 정리하면:

const (최고)

SQL
-- PK 또는 UNIQUE 인덱스로 상수 조건 검색
EXPLAIN SELECT * FROM users WHERE id = 1;
-- type: const → 최대 1행만 반환, 가장 빠름

eq_ref

SQL
-- JOIN에서 PK 또는 UNIQUE 인덱스 사용
EXPLAIN SELECT * FROM orders o JOIN users u ON o.user_id = u.id;
-- users 테이블: type = eq_ref → JOIN 시 정확히 1행 매칭

ref

SQL
-- 논유니크 인덱스로 검색
EXPLAIN SELECT * FROM users WHERE department_id = 5;
-- type: ref → 인덱스를 사용하지만 여러 행이 매칭될 수 있음

range

SQL
-- 인덱스를 범위로 스캔
EXPLAIN SELECT * FROM users WHERE age BETWEEN 20 AND 30;
-- type: range → 인덱스의 일부 범위를 스캔

index

SQL
-- 인덱스 전체를 스캔 (Full Index Scan)
EXPLAIN SELECT department_id FROM users;
-- type: index → 테이블은 안 읽지만 인덱스 전체를 순회

ALL (최악)

SQL
-- 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)가 있을 때:

SQL
-- 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이 클수록 복합 인덱스를 더 많이 활용하고 있다는 뜻입니다. 각 컬럼의 바이트 수를 알면 몇 번째 컬럼까지 인덱스를 탔는지 역산할 수 있습니다.

PLAINTEXT
VARCHAR(50) + utf8mb4 = 50 * 4 + 2(길이 정보) = 202 바이트
INT = 4 바이트
NULLABLE 컬럼은 +1 바이트

Extra 컬럼 해석

Using index (좋음)

PLAINTEXT
Extra: Using index

커버링 인덱스가 적용되어 **테이블 데이터에 접근하지 않음 **. 인덱스만으로 쿼리가 완료됩니다.

Using where (보통)

PLAINTEXT
Extra: Using where

스토리지 엔진에서 가져온 행을 ** 서버 레이어에서 추가 필터링 **한다는 의미. 인덱스로 걸러지지 않는 조건이 있다는 뜻입니다.

Using filesort (주의)

PLAINTEXT
Extra: Using filesort

인덱스 순서로 정렬할 수 없어 ** 별도의 정렬 작업 **이 필요합니다. ORDER BY가 인덱스와 맞지 않을 때 발생합니다.

SQL
-- 인덱스: (name)
SELECT * FROM users WHERE name = '김철수' ORDER BY age;
-- → Using filesort (age에 대한 인덱스가 없음)

-- 개선: 인덱스를 (name, age)로 변경하면 Using filesort 제거 가능

Using temporary (주의)

PLAINTEXT
Extra: Using temporary

쿼리 실행에 임시 테이블 이 필요합니다. GROUP BY, DISTINCT, UNION 등에서 발생할 수 있습니다.

SQL
-- GROUP BY와 ORDER BY 컬럼이 다를 때
SELECT department, COUNT(*) FROM users GROUP BY department ORDER BY COUNT(*) DESC;
-- → Using temporary; Using filesort

Using index condition (ICP)

PLAINTEXT
Extra: Using index condition

인덱스 컨디션 푸시다운(ICP). 스토리지 엔진 레벨에서 인덱스를 이용해 추가 필터링을 수행합니다. Using where보다 효율적입니다.

EXPLAIN ANALYZE (MySQL 8.0+)

EXPLAIN ANALYZE는 ** 실제로 쿼리를 실행 **하고, 각 단계의 실제 시간과 행 수를 보여줍니다.

SQL
EXPLAIN ANALYZE SELECT * FROM users WHERE age > 25 ORDER BY name;

출력 예시:

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

SQL
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;
PLAINTEXT
+----+------+-------+------+------+------+---------------------------------+
| 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

SQL
-- 인덱스 추가
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at DESC);
PLAINTEXT
+----+------+-------+-------+------------------------+------+-------------+
| 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 체크리스트

실행 계획을 볼 때 이 순서로 확인하면 됩니다.

  1. type이 ALL인가? → 인덱스 추가 검토
  2. key가 NULL인가? → possible_keys가 있는데 key가 NULL이면 옵티마이저가 인덱스를 선택하지 않은 것
  3. rows가 비정상적으로 큰가? → 조건이 충분히 좁히지 못하는 것
  4. Extra에 Using filesort가 있는가? → ORDER BY에 맞는 인덱스 검토
  5. Extra에 Using temporary가 있는가? → GROUP BY/ORDER BY 조합 개선 검토
  6. 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입니다. 감으로 인덱스를 만들지 말고, 실행 계획을 먼저 확인하세요.

댓글 로딩 중...