한줄 요약: EXPLAIN으로 쿼리 실행 계획을 읽고, 인덱스 누락과 풀스캔 문제를 찾아 느린 쿼리를 최적화하는 실전 방법을 배웁니다.
“왜 이 쿼리는 이렇게 느릴까요?” 처음엔 저도 쿼리 앞에 EXPLAIN만 붙이면 뭔가 나오는데, 그게 뭔 뜻인지 하나도 몰랐습니다. 오늘은 실행 계획 읽는 법을 함께 배워봅시다.
EXPLAIN이란 — 쿼리의 속마음을 들여다보는 도구
EXPLAIN은 데이터베이스가 우리의 SQL 쿼리를 “어떻게” 실행할 계획인지 보여주는 명령어입니다.
마치 요리사가 레시피를 보고 “이 재료는 냉장고에서 꺼내고, 저건 먼저 볶고…” 계획을 세우는 것처럼요.
데이터베이스도 쿼리를 받으면 “어느 테이블부터 읽을까?”, “인덱스를 쓸까 전체 스캔할까?” 계획을 세웁니다.
이 계획을 읽을 수 있으면, 쿼리가 느린 이유를 정확히 찾을 수 있습니다.
인덱스를 타지 않고 전체 테이블을 스캔하는지, 불필요한 조인이 있는지 한눈에 보이거든요.
실무에서 “갑자기 서비스가 느려졌어요!” 할 때 가장 먼저 보는 게 바로 이 실행 계획입니다.
기본 사용법 — EXPLAIN으로 쿼리 분석하기
실제로 느린 쿼리를 분석해봅시다. 사용자 주문 내역을 조회하는 쿼리입니다.
-- 실행 계획 확인하기
EXPLAIN
SELECT u.name, o.order_date, o.total_amount
FROM users u
JOIN orders o ON u.user_id = o.user_id
WHERE o.order_date >= '2024-01-01'
AND o.status = 'completed';
-- MySQL 8.0 이상에서 더 자세한 정보 보기
EXPLAIN ANALYZE
SELECT u.name, o.order_date, o.total_amount
FROM users u
JOIN orders o ON u.user_id = o.user_id
WHERE o.order_date >= '2024-01-01'
AND o.status = 'completed';
쿼리 앞에 EXPLAIN만 붙이면 실제 실행 없이 계획만 보여줍니다.
EXPLAIN ANALYZE는 실제로 실행까지 해서 예상 시간과 실제 시간을 비교해줍니다.
결과 테이블에는 type, key, rows, Extra 같은 컬럼이 나오는데, 이게 핵심입니다.
특히 type 컬럼은 접근 방식을 보여줍니다. ALL이면 전체 테이블 스캔(느림), index나 ref면 인덱스 사용(빠름)입니다.
rows는 예상 검색 행 수인데, 이 숫자가 크면 성능 문제가 생길 가능성이 높습니다.
실행 계획 핵심 컬럼 읽기 — 이것만 보면 됩니다
실행 계획 결과에서 꼭 봐야 할 컬럼들을 정리했습니다.
-- 실행 계획 예시 결과
+----+-------------+-------+------+---------------+---------+---------+-------+------+-------------+
| id | select_type | table | type | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+---------+---------+-------+------+-------------+
| 1 | SIMPLE | o | ALL | NULL | NULL | NULL | 50000 | Using where |
| 1 | SIMPLE | u | ref | PRIMARY | 4 | o.user_id| 1 | NULL |
+----+-------------+-------+------+---------------+---------+---------+-------+------+-------------+
-- 문제 발견: orders 테이블이 type=ALL (전체 스캔)
-- 해결: order_date와 status에 복합 인덱스 생성
CREATE INDEX idx_orders_date_status
ON orders(order_date, status);
-- 인덱스 생성 후 다시 확인
EXPLAIN
SELECT u.name, o.order_date, o.total_amount
FROM users u
JOIN orders o ON u.user_id = o.user_id
WHERE o.order_date >= '2024-01-01'
AND o.status = 'completed';
type: ALL(최악) → index → range → ref → const(최고) 순으로 좋습니다.
key: 실제 사용된 인덱스 이름입니다. NULL이면 인덱스를 안 쓴다는 뜻이니 빨간불입니다.
rows: 검색할 예상 행 수입니다. 전체 데이터 대비 10% 이상이면 인덱스 효과가 적습니다.
Extra 컬럼도 중요합니다. Using filesort는 디스크 정렬(느림), Using temporary는 임시 테이블 생성(느림)을 의미합니다.Using index는 인덱스만으로 처리(빠름)한다는 뜻이니 좋은 신호입니다.
자주 하는 실수와 해결법
실무에서 자주 보는 실행 계획 관련 실수들입니다.
- 잘못된 방법:
type=ALL이 나와도 “데이터가 적으니까 괜찮겠지” 하고 넘어갑니다. 지금은 테스트 데이터 100건이지만, 실서비스에선 100만건이 될 수 있습니다. ⚠️ 초기부터 인덱스를 설계해야 나중에 고생하지 않습니다. - 잘못된 방법: 모든 컬럼에 인덱스를 만들면 빠를 거라 생각합니다. 인덱스가 많으면 INSERT/UPDATE가 느려지고, 옵티마이저가 잘못된 인덱스를 선택할 수도 있습니다.
- 올바른 방법: WHERE, JOIN, ORDER BY에 자주 쓰이는 컬럼 조합으로 복합 인덱스를 만듭니다.
EXPLAIN으로 실제로 사용되는지 꼭 확인하세요. - 꿀팁:
EXPLAIN FORMAT=JSON을 쓰면 더 상세한 정보를 JSON으로 볼 수 있습니다. 복잡한 서브쿼리 분석할 때 유용합니다. PostgreSQL은EXPLAIN (FORMAT JSON, ANALYZE)를 사용하세요.
실전 최적화 사례 — 10초 쿼리를 0.1초로
실제로 겪었던 느린 쿼리 최적화 과정입니다.
-- 문제의 쿼리 (10초 소요)
SELECT p.product_name, COUNT(*) as order_count
FROM products p
JOIN order_items oi ON p.product_id = oi.product_id
JOIN orders o ON oi.order_id = o.order_id
WHERE o.order_date BETWEEN '2024-01-01' AND '2024-12-31'
AND o.status = 'completed'
GROUP BY p.product_id, p.product_name
ORDER BY order_count DESC
LIMIT 10;
-- EXPLAIN 결과 분석
-- orders 테이블: type=ALL, rows=500000 (전체 스캔 발생!)
-- order_items 테이블: type=ALL, rows=2000000
-- Extra: Using temporary; Using filesort (임시 테이블과 디스크 정렬)
-- 해결 1: orders 테이블에 복합 인덱스 생성
CREATE INDEX idx_orders_date_status
ON orders(order_date, status);
-- 해결 2: order_items의 외래키에 인덱스 생성
CREATE INDEX idx_order_items_order_id
ON order_items(order_id);
-- 최적화 후 다시 실행 (0.1초로 개선!)
-- orders: type=range, rows=15000 (인덱스 범위 스캔)
-- order_items: type=ref, rows=3 (인덱스 조인)
첫 번째 EXPLAIN에서 type=ALL과 Using filesort를 발견했습니다.
50만 건의 orders를 전부 읽고, 200만 건의 order_items를 조인한 뒤, 디스크에서 정렬까지 했으니 느릴 수밖에 없었죠.
날짜와 상태로 필터링하는 복합 인덱스를 만들자, 검색 대상이 50만 건에서 1.5만 건으로 줄었습니다.
조인에 쓰이는 order_id에도 인덱스를 추가하니 조인 속도가 극적으로 개선됐습니다.
결과적으로 100배 빠른 쿼리가 완성됐습니다.
정리하며
EXPLAIN은 느린 쿼리의 원인을 찾는 가장 확실한 방법입니다.
실행 계획을 읽을 줄 알면, 감으로 인덱스를 추가하는 게 아니라 정확한 근거를 갖고 최적화할 수 있습니다.
처음엔 복잡해 보이지만, type, key, rows 세 가지만 집중해서 보면 됩니다.type=ALL이 보이면 인덱스 누락을 의심하고, rows가 크면 조건을 더 구체화하거나 인덱스를 개선하세요.
매번 쿼리 작성 후 습관적으로 EXPLAIN을 확인하다 보면, 어느새 최적화된 쿼리를 자연스럽게 작성하게 됩니다.
- 이 글의 핵심: EXPLAIN으로 type, key, rows를 확인해 인덱스 누락과 전체 스캔 문제를 찾아 최적화합니다.
- 다음 단계: 인덱스 설계 전략(B-Tree, Covering Index), 쿼리 힌트(USE INDEX, FORCE INDEX), 실행 계획 강제 변경 방법을 공부해보세요.
🧠 퀴즈: 이 실행 계획의 문제점은?
다음 실행 계획을 보고 어떤 문제가 있는지, 어떻게 개선할 수 있는지 생각해보세요.
EXPLAIN SELECT * FROM users WHERE email LIKE '%@gmail.com';
-- 결과
+----+-------------+-------+------+---------------+------+---------+------+--------+-------------+
| id | select_type | table | type | key | rows | Extra |
+----+-------------+-------+------+---------------+------+---------+------+--------+-------------+
| 1 | SIMPLE | users | ALL | NULL | 100000 | Using where |
+----+-------------+-------+------+---------------+------+---------+------+--------+-------------+
정답: LIKE '%@gmail.com'처럼 와일드카드가 앞에 오면 인덱스를 사용할 수 없습니다.
email 컬럼에 인덱스가 있어도 type=ALL로 전체 스캔이 발생합니다.
해결 방법: 도메인별 검색이 자주 필요하다면, 도메인을 별도 컬럼으로 분리하거나 Full-Text Search 인덱스를 고려하세요.
또는 email LIKE 'user123%'처럼 앞부분이 고정된 패턴으로 검색 조건을 바꾸면 인덱스를 탈 수 있습니다.
참 쉽죠?