실행 계획은 정답이 아니라 단서에 가깝다

MySQL 튜닝 이야기를 하다 보면 결국 EXPLAIN으로 모이게 된다. 실제로 실행 계획은 매우 유용하다. 어떤 테이블부터 읽는지, 어떤 인덱스를 쓰는지, 대략 몇 개의 행을 읽을 것으로 예상하는지 같은 정보를 한 번에 보여주기 때문이다. 다만 여기서 흔히 생기는 오해가 있다. 실행 계획을 읽는 순간 원인이 확정된 것처럼 느끼는 것이다.

실행 계획은 어디까지나 옵티마이저의 판단과 추정치를 보여주는 도구다. 그래서 읽을 때도 “좋다, 나쁘다”를 바로 결정하기보다, 어디에서 많은 행을 읽고 어디에서 추가 작업이 붙는지 추적하는 단서로 보는 편이 낫다. 실행 계획을 잘 읽는다는 것은 표를 외우는 일이 아니라, 쿼리가 실제로 어떤 접근 경로를 택했는지 상상하는 일에 가깝다.

이 글은 MySQL 8.0 버전을 기준으로 한다.


처음에는 어디부터 봐야 할까?

EXPLAIN 결과에는 많은 컬럼이 나온다. 처음부터 전부 해석하려고 하면 오히려 흐름을 놓치기 쉽다. 실무에서는 우선 아래 5가지 핵심 항목부터 보는 편이 효율적이다.

항목 무엇을 뜻하나 읽을 때 무엇을 보면 되나
type 데이터를 찾는 접근 방식 기대보다 넓게 읽고 있지는 않은지 (ALL, index 주의)
key 실제로 선택된 인덱스 기대한 인덱스를 골랐는지, 아예 못 고른 것은 아닌지
rows 읽을 것으로 예상하는 행의 수 조건에 비해 읽는 범위가 지나치게 크지 않은지
filtered 다음 단계로 넘어갈 행의 비율 많이 읽은 뒤 대부분 버리는(필터링) 구조는 아닌지
Extra 정렬, 임시 테이블 등 부가 작업 Using filesort, Using temporary 등의 추가 비용 확인


예시: 기사 목록 쿼리 분석

가장 흔한 예시 중 하나는 최신 게시글 목록처럼 정렬과 LIMIT가 함께 붙는 조회다.

EXPLAIN
SELECT a.id, a.title, a.published_at, a.office_id
FROM article a
WHERE a.status = 'PUBLISHED'
  AND a.office_id = 10
ORDER BY a.published_at DESC LIMIT 20;

이 쿼리의 실행 계획 결과가 아래와 같다고 가정해 보자.

type: ref
key: idx_article_status_office_published_at
rows: 1,280
filtered: 100.00
Extra: Using where

이 결과를 읽어 보면, type: ref는 동등 조건을 이용해 인덱스를 잘 타고 있다는 뜻이고, key는 우리가 설계한 복합 인덱스를 정확히 선택했다는 의미다. rows: 1,280은 20건을 가져오기 위해 대략 1,280건 정도를 후보로 보겠다는 추정치다.

만약 여기서 ExtraUsing filesort가 보인다면, 인덱스가 정렬 조건(published_at)까지 커버하지 못해 별도의 정렬 작업이 발생했다는 신호로 해석하고 인덱스 순서를 조정해야 한다.


쿼리 플랜의 주요 항목

이제 각 항목이 가질 수 있는 상세 값들과, 이를 실무에서 어떻게 해석해야 하는지 깊이 있게 살펴보자.

1. type: 접근 방식의 효율성

type은 테이블에서 데이터를 어떻게 찾는지를 보여준다. 아래 표의 위쪽(system, const)으로 갈수록 성능이 좋고, 아래쪽(ALL)으로 갈수록 나쁘다.

설명 성능
system/const PK나 Unique Key를 사용하여 1행만 바로 찾는 경우 최상
eq_ref 조인 시 조인 키가 PK나 Unique Key인 경우 (1:1 관계) 우수
ref Unique하지 않은 인덱스로 등호(=) 비교를 하는 경우 우수
range 인덱스로 특정 범위를 읽는 경우 (<, >, BETWEEN, IN 등) 보통
index 인덱스 전체를 스캔하는 경우 (풀 테이블 스캔보다 약간 빠름) 나쁨
ALL 테이블 전체를 스캔하는 경우 (인덱스 사용 불가) 최악

indexALL은 위험 신호다. 하지만 range라고 무조건 안심해서도 안 된다. 인덱스를 타더라도 범위가 너무 넓으면(예: 1년치 로그 조회) ALL보다 느려질 수 있다. 항상 rows 수치와 함께 봐야 한다.


2. key: 인덱스 사용보다 ‘스캔 범위’가 핵심이다

key 항목에 인덱스 이름이 나타나면 일단 인덱스는 사용된 것이다. 하지만 인덱스 사용 여부보다 중요한 것은 해당 인덱스가 스캔 범위를 얼마나 효과적으로 좁혀주느냐에 있다.

선택도가 낮은 컬럼(예: 대부분이 ‘PUBLISHED’인 상태값)으로 구성된 단일 인덱스는 시작점 역할만 할 뿐, 실제로는 수만 건의 데이터를 풀 스캔하듯 훑게 될 수 있다. 이럴 때는 statusoffice_id처럼 조건에 사용되는 컬럼들을 결합한 복합 인덱스를 구성하여, 엔진이 읽어야 할 데이터의 절대량을 줄이는 것이 성능 튜닝의 핵심이다.


3. rows & filtered: 추정치와 실제의 괴리

항목 의미 체크 포인트
rows 조사가 예상되는 행의 수 실제 데이터 대비 이 숫자가 너무 크면 인덱스 재검토
filtered 필터링 후 남을 것으로 예상되는 비율 (%) 100%에 가까울수록 읽은 데이터를 버리지 않음

rows는 통계 기반 추정치다. 통계 정보가 오래되었거나 데이터가 한쪽으로 쏠려 있으면 실제와 수만 배 차이가 날 수 있다. rows가 작게 보여도 쿼리가 느리다면 ANALYZE TABLE로 통계를 갱신하거나 실제 쿼리 실행 시간을 대조해 봐야 한다.


4. Extra: 비용이 새는 자리를 보여준다

Extra는 성능 저하의 주범을 찾는 데 가장 유용한 단서를 제공한다.

설명 주의 사항
Using index 인덱스만 보고 쿼리 완료 (Covering Index) 성능에 매우 좋음
Using where 엔진이 스토리지 엔진으로부터 받은 데이터를 다시 필터링함 rows 대비 효율 확인 필요
Using temporary 쿼리 처리를 위해 임시 테이블을 만듦 디스크 사용 시 성능 급락
Using filesort 인덱스를 활용하지 못해 별도로 정렬 작업을 함 정렬 조건에 인덱스 적용 검토

Using temporaryUsing filesort가 목록 조회 쿼리에서 보인다면 거의 예외 없이 튜닝 대상이다. 특히 조인 쿼리에서 조인 후 정렬이 발생하면 비용이 기하급수적으로 늘어날 수 있다.


실행 계획만으로 결론내리면 놓치는 것들

실행 계획은 매우 강력하지만, SQL 한 문장의 관점에 머무르기 쉽다. 실제 병목은 SQL 바깥에 있는 경우도 많다.

  1. 호출 빈도: 실행 계획상 0.1초 걸리는 쿼리라도 초당 1,000번 호출되면 전체 시스템을 마비시킨다.
  2. 데이터 분포: 특정 사용자(예: 파워 유저)의 데이터만 유독 많아 해당 사용자에게만 쿼리가 느려지는 현상은 일반적인 실행 계획만으론 잡기 어렵다.
  3. 잠금(Lock): 실행 계획은 완벽해도 다른 트랜잭션의 잠금 때문에 쿼리가 대기 상태에 빠질 수 있다.


정리하며

EXPLAIN은 복잡한 표를 해석하는 작업처럼 보이지만, 실제로는 쿼리가 데이터를 찾아가는 경로를 추적하는 과정이다. key, rows, Extra를 보는 이유도 결국 그 경로가 좁고 예측 가능한지 확인하기 위해서다.

실행 계획으로 방향을 잡되, 실제 요청 흐름과 데이터 접근 패턴까지 다시 같이 보는 태도를 갖자. 이 순서를 지키면 실행 계획을 과신하지 않으면서도 성능 튜닝의 강력한 무기로 활용할 수 있다.


참조