월말 정산 화면을 열 때마다 로딩 아이콘이 10초씩 돌던 관리자 페이지가 있었다고 해 봅시다. 테이블은 고작 80만 건인데 주문 목록 하나 불러오는 데 이렇게 오래 걸립니다. 서버 사양을 올리자는 말이 나올 즈음, 쿼리 앞에 단어 하나만 붙여 보면 이야기가 달라집니다. 바로 EXPLAIN입니다.

EXPLAIN은 쿼리의 '진단서'다

EXPLAIN은 쿼리를 실제로 실행하지 않고, MySQL이 어떤 순서로 어떤 인덱스를 써서 데이터를 찾을 계획인지를 보여 줍니다. 증상만 보고 약을 처방하는 대신 검사부터 받는 셈입니다.

EXPLAIN
SELECT order_id, total_price
FROM orders
WHERE user_id = 1234
  AND status = 'PAID'
ORDER BY created_at DESC
LIMIT 20;

결과에는 열이 여럿 나오지만, 처음에는 네 가지만 보면 충분합니다. 이 네 칸만 읽어도 문제의 8할은 잡힙니다.

열 개가 넘는 칸을 다 외울 필요는 없다. type, key, rows, Extra 네 칸이면 시작하기에 충분하다.

type — 테이블을 어떻게 뒤졌는가

type은 접근 방식입니다. 좋은 쪽에서 나쁜 쪽으로 대략 이런 순서입니다.

type의미느낌
const / eq_ref기본키·유니크 키로 딱 한 줄아주 좋음
ref일반 인덱스로 일치하는 여러 줄좋음
range인덱스 범위 검색(BETWEEN, >)무난함
index인덱스 전체를 훑음주의
ALL테이블 풀스캔가장 먼저 의심

80만 건짜리 주문 테이블에서 type이 ALL로 찍혔다면, 책에서 한 단어를 찾으려고 첫 쪽부터 끝 쪽까지 넘기고 있다는 뜻입니다. 데이터가 천 건일 때는 티가 안 나다가, 늘어날수록 갑자기 느려지는 이유가 여기 있습니다.

key와 rows — 인덱스를 썼는가, 얼마나 읽었는가

key는 실제로 선택된 인덱스 이름이고, rows는 MySQL이 읽어야 한다고 예상하는 행 수입니다. key가 NULL이면 인덱스를 못 쓴 것입니다. rows가 800,000에 가깝다면 사실상 전부 읽는다는 얘기입니다.

AD

여기서 인덱스를 만들어 봅시다. 조건이 user_id와 status, 정렬이 created_at이므로 복합 인덱스를 이 순서로 겁니다.

ALTER TABLE orders
  ADD INDEX idx_user_status_created (user_id, status, created_at);

다시 EXPLAIN을 돌리면 type은 ref, key는 방금 만든 인덱스, rows는 수십 건 수준으로 내려옵니다. 같은 쿼리인데 읽는 양이 만 분의 일이 된 겁니다.

복합 인덱스는 '순서'가 전부다

복합 인덱스를 처음 만들 때 가장 많이 하는 실수가 컬럼 순서를 아무렇게나 두는 것입니다. 인덱스는 전화번호부처럼 왼쪽 컬럼부터 차례로 정렬됩니다. 성(姓)으로 먼저 찾고, 그 안에서 이름으로 찾는 구조라서, 성을 모르면 이름만으로는 쓸 수 없습니다.

  • (user_id, status, created_at) 인덱스는 user_id만 조건일 때, user_id + status일 때 모두 쓸 수 있습니다.
  • 하지만 status만 조건이면 맨 앞 컬럼이 빠졌으므로 효과가 크게 떨어집니다.
  • 일반적으로 같다(=) 조건 컬럼을 앞에, 범위·정렬 컬럼을 뒤에 둡니다.

Extra 칸에서 숨은 비용 찾기

Extra에는 작은 글씨로 중요한 경고가 적힙니다. 특히 두 가지를 기억해 두세요.

  • Using filesort: 인덱스로 정렬을 해결하지 못하고 별도로 정렬 작업을 했다는 뜻입니다. 데이터가 많으면 부담이 큽니다.
  • Using temporary: 중간 결과를 임시 테이블에 담았다는 뜻입니다. GROUP BY나 DISTINCT에서 자주 보입니다.

위의 복합 인덱스에 created_at을 끼워 넣은 이유가 바로 이것입니다. 정렬 순서까지 인덱스가 이미 갖고 있으니 filesort가 사라집니다.

AD

인덱스를 무력화하는 흔한 습관

인덱스를 만들어도 쿼리를 이렇게 쓰면 소용이 없습니다.

-- 나쁜 예: 컬럼에 함수를 씌우면 인덱스를 못 탄다
SELECT * FROM orders WHERE DATE(created_at) = '2026-10-01';

-- 좋은 예: 컬럼은 그대로 두고 범위로 표현
SELECT * FROM orders
WHERE created_at >= '2026-10-01' AND created_at < '2026-10-02';

문자열 컬럼을 숫자와 비교하거나, LIKE '%키워드'처럼 앞에 와일드카드를 두는 경우도 마찬가지입니다. 또 인덱스가 많다고 좋은 것도 아닙니다. 인덱스는 쓰기(INSERT·UPDATE)를 느리게 하므로, 실제로 쓰이는 것만 남기는 편이 좋습니다.

정리하며

쿼리가 느릴 때는 감으로 인덱스를 추가하기 전에 먼저 EXPLAIN을 붙여 보세요. type이 ALL이 아닌지, key가 NULL이 아닌지, rows가 지나치게 크지 않은지, Extra에 filesort가 없는지, 이 네 가지가 출발점입니다. MySQL 8.x라면 EXPLAIN ANALYZE로 실제 실행 시간까지 비교해 볼 수 있습니다. 운영 서버에 인덱스를 추가할 때는 테이블이 잠기거나 부하가 생길 수 있으니 한가한 시간대에 작은 환경에서 먼저 시험해 보시길 권합니다.

느린 쿼리 앞에서 서버 탓부터 하던 습관, 오늘부터는 한 단어로 바꿔 보세요. 진단이 정확하면 처방은 생각보다 간단합니다.