Theme:

쿼리가 느린데, 원인을 어떻게 찾을 수 있을까요? MySQL은 쿼리를 실행하기 전에 어떤 계획을 세우는지 들여다볼 수 있는 방법을 제공합니다.

EXPLAIN이란

EXPLAIN은 MySQL이 쿼리를 어떻게 실행할 것인지를 보여주는 명령입니다. 어떤 인덱스를 사용하는지, 몇 개의 행을 읽는지, 어떤 순서로 테이블에 접근하는지를 알 수 있습니다.

SQL
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;

쿼리를 실제로 실행하지 않고 실행 계획만 보여주므로 운영 환경에서도 안전하게 사용할 수 있습니다.

EXPLAIN 출력 컬럼 해석

id — 쿼리 실행 순서

SQL
EXPLAIN
SELECT * FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE c.name = '홍길동';
idselect_typetable
1SIMPLEc
1SIMPLEo
  • 같은 id: 조인으로 함께 실행됩니다
  • 다른 id: 큰 숫자가 먼저 실행됩니다 (서브쿼리 등)

select_type — 쿼리 유형

설명
SIMPLE서브쿼리나 UNION이 없는 단순 쿼리
PRIMARY가장 바깥쪽 쿼리
SUBQUERYWHERE절의 서브쿼리
DERIVEDFROM절의 서브쿼리 (파생 테이블)
UNIONUNION의 두 번째 이후 쿼리
MATERIALIZED서브쿼리가 임시 테이블로 실체화됨

type — 접근 방식 (가장 중요!)

성능에 가장 큰 영향을 미치는 컬럼입니다. 위에서 아래로 갈수록 효율적입니다.

PLAINTEXT
성능 나쁨                                          성능 좋음
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개뿐시스템 테이블
SQL
-- 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 — 사용된 인덱스

SQL
EXPLAIN SELECT * FROM orders WHERE customer_id = 42 AND status = 'completed';
keykey_len
idx_customer_status54
  • key: 실제 사용된 인덱스 이름. NULL이면 인덱스를 사용하지 않음
  • key_len: 인덱스에서 사용된 바이트 수. 복합 인덱스에서 몇 개의 컬럼까지 활용되었는지 파악할 수 있습니다
PLAINTEXT
key_len 계산 예시:
INT: 4 bytes
VARCHAR(50) UTF8MB4: 50 × 4 + 2(길이) = 202 bytes
NULL 허용 컬럼: +1 byte

rows — 예상 검사 행 수

옵티마이저가 이 단계에서 읽을 것으로 예상하는 행의 수입니다. 실제 값이 아닌 통계 기반 추정치입니다.

SQL
-- rows가 크면 많은 행을 읽는다는 의미
EXPLAIN SELECT * FROM orders WHERE status = 'pending';
-- rows: 50000 → 5만 행을 읽을 예정

filtered — 필터링 비율

rows 중에서 조건을 만족하여 다음 단계로 전달되는 행의 비율(%)입니다.

PLAINTEXT
rows = 1000, filtered = 10.00
→ 1000행을 읽어서 100행(10%)이 조건을 만족

Extra — 추가 정보 (두 번째로 중요!)

성능 관련 핵심 정보가 여기에 표시됩니다.

Extra의미조치
Using index커버링 인덱스 (인덱스만으로 처리)좋음!
Using whereWHERE 조건으로 필터링보통
Using filesort정렬을 위한 추가 작업 필요인덱스 개선 검토
Using temporary임시 테이블 사용GROUP BY/ORDER BY 개선 검토
Using index condition인덱스 컨디션 푸시다운 (ICP)좋음!

실전 예제 — 실행 계획 읽기

예제 1: 비효율적인 쿼리

SQL
EXPLAIN
SELECT * FROM orders
WHERE YEAR(created_at) = 2025
ORDER BY amount DESC;
PLAINTEXT
id: 1
type: ALL              ← 풀 테이블 스캔!
key: NULL              ← 인덱스 미사용!
rows: 1000000
Extra: Using where; Using filesort   ← 필터링 + 추가 정렬!

문제점:

  • YEAR(created_at): 함수로 감싸면 인덱스를 사용할 수 없습니다
  • ORDER BY amount DESC: 인덱스가 없어 별도 정렬이 필요합니다

예제 2: 개선된 쿼리

SQL
-- 인덱스 추가
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;
PLAINTEXT
id: 1
type: range            ← 인덱스 범위 스캔
key: idx_created_amount
rows: 50000
Extra: Using index condition

EXPLAIN FORMAT=JSON

더 상세한 정보를 JSON 형식으로 볼 수 있습니다.

SQL
EXPLAIN FORMAT=JSON
SELECT * FROM orders WHERE customer_id = 42\G
JSON
{
  "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+)

가장 강력한 분석 도구입니다. 실제로 쿼리를 실행하고 예측값과 실측값을 함께 보여줍니다.

SQL
EXPLAIN ANALYZE
SELECT * FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE c.city = '서울';
PLAINTEXT
-> 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로 갱신을 고려합니다.

SQL
ANALYZE TABLE orders;  -- 통계 정보 갱신

자주 만나는 성능 문제 패턴

1. type: ALL + rows가 큰 경우

SQL
-- 인덱스가 없는 컬럼으로 조회
EXPLAIN SELECT * FROM logs WHERE message LIKE '%error%';
-- type: ALL, rows: 10000000

해결: 풀텍스트 인덱스 또는 별도 검색 엔진 사용

2. Using filesort + Using temporary

SQL
EXPLAIN SELECT department, COUNT(*) FROM employees
GROUP BY department ORDER BY COUNT(*) DESC;
-- Extra: Using temporary; Using filesort

해결: GROUP BY 컬럼에 인덱스 추가

3. key_len이 예상보다 짧은 경우

SQL
-- 복합 인덱스: (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를 사용합니다
  • 실행 계획 분석은 쿼리 최적화의 시작점이며, 인덱스 설계와 쿼리 변경의 근거가 됩니다
댓글 로딩 중...