인덱스가 안 타는 이유 — 만들어도 느린 쿼리의 공통 원인

스터디·10분 읽기

인덱스가 안 타는 이유, 왜 알아야 하나요?

인덱스 기본 글에서 인덱스는 데이터를 빠르게 찾기 위한 구조라고 정리했습니다. 그런데 실무에서는 인덱스를 만들어도 이런 상황이 자주 나옵니다.

  • 분명 인덱스를 만들었는데 EXPLAIN 결과가 ALL입니다
  • WHERE 조건이 있는데도 풀 테이블 스캔이 나옵니다
  • 인덱스를 추가했는데 쿼리가 거의 빨라지지 않습니다

이때 중요한 것은 "인덱스가 있다"가 아니라 옵티마이저가 그 인덱스를 쓰는 편이 유리하다고 판단하느냐 입니다. 이 글에서는 인덱스가 안 타는 대표적인 이유를 원인과 판단 기준 중심으로 정리합니다.

Phase 1. 인덱스가 있어도 항상 쓰는 것은 아닙니다

인덱스가 있으면 DB가 무조건 그 인덱스를 사용할 것 같지만, 실제로는 그렇지 않습니다. 옵티마이저는 여러 실행 계획 중에서 비용(cost)이 더 낮다고 추정되는 계획을 선택합니다.

왜 풀 스캔을 선택할까?

EXPLAIN
SELECT *
FROM users
WHERE gender = 'F';

gender 컬럼에 인덱스가 있어도, 테이블의 절반 이상이 F라면 어떨까요? 인덱스를 타고 많은 행을 찾은 뒤 다시 테이블로 돌아가 읽는 비용이, 처음부터 순서대로 전부 읽는 비용보다 더 클 수 있습니다.

즉, 인덱스가 안 타는 이유는 종종 인덱스를 못 써서가 아니라 써도 이득이 없어서입니다.

핵심 판단 기준

옵티마이저는 대략 이런 요소를 함께 봅니다.

  • 조건이 얼마나 많은 행을 걸러내는가
  • 인덱스를 타고 찾은 뒤 테이블을 다시 읽어야 하는가
  • 정렬과 필터링을 인덱스로 같이 해결할 수 있는가
  • 통계 정보상 어느 계획이 더 저렴해 보이는가

이 네 가지를 이해하면 왜 옵티마이저가 인덱스를 선택하지 않았는지를 훨씬 쉽게 해석할 수 있습니다.

Phase 2. 인덱스 컬럼을 가공하면 B+Tree를 활용하기 어렵습니다

인덱스는 원본 값 기준으로 정렬되어 있습니다. 그래서 인덱스 컬럼에 함수를 적용하면 정렬 순서를 그대로 활용하기 어려워집니다.

함수 적용

-- ❌ created_at 인덱스가 있어도 비효율적
WHERE YEAR(created_at) = 2026

-- ✅ 원본 값 기준 범위 검색
WHERE created_at >= '2026-01-01'
  AND created_at < '2027-01-01'

YEAR(created_at)는 인덱스에 저장된 원본 값이 아니라 가공된 결과입니다. B+Tree는 2026-03-10 12:34:56 같은 원본 값을 기준으로 정렬되어 있으므로, 연도만 잘라 비교하면 처음부터 끝까지 계산해 봐야 할 수 있습니다.

문자열 조작

-- ❌ name 인덱스 활용이 어려움
WHERE LEFT(name, 3) = '김철수'

-- ✅ 전방 일치 검색
WHERE name LIKE '김철수%'

-- ❌ 앞에 와일드카드가 붙으면 시작 위치를 바로 찾기 어려움
WHERE name LIKE '%철수'

LIKE '%철수'처럼 앞에 와일드카드가 붙는 경우도 마찬가지입니다. B+Tree는 왼쪽부터 정렬되어 있으므로, 앞부분이 고정되지 않으면 시작 위치를 바로 찾기 어렵습니다.

참고: MySQL 8.0에서는 함수 기반 인덱스(functional index)를 사용할 수 있지만, 기본 원칙은 그대로입니다. 함수 결과를 따로 인덱싱하지 않는 이상 원본 컬럼 인덱스는 활용하기 어렵습니다.

Phase 3. 암묵적 형변환이 인덱스를 망칠 수 있습니다

SQL은 타입이 다르면 내부적으로 형변환을 시도합니다. 문제는 이 형변환이 컬럼 쪽에서 일어나는 경우, 엔진과 타입 조합에 따라 인덱스를 제대로 활용하지 못할 수 있다는 점입니다.

숫자 컬럼 vs 문자열 비교

-- user_id가 VARCHAR인데 숫자로 비교
WHERE user_id = 123

반대로 user_id가 숫자인데 '123'처럼 문자열로 비교하는 경우도 주의해야 합니다. 어떤 방향으로 형변환할지는 엔진과 타입 조합에 따라 다르므로, 실행 계획이 달라질 수 있습니다.

가장 안전한 방법은 컬럼 타입과 비교값 타입을 맞추는 것입니다.

-- ✅ 타입 일치
WHERE user_id = '123'

조인 조건의 타입 불일치

-- orders.user_id: BIGINT
-- users.id: VARCHAR
SELECT *
FROM orders o
JOIN users u ON o.user_id = u.id;

조인 키 타입이 다르면 한쪽 값을 계속 변환해야 하고, 그 결과 인덱스를 제대로 활용하지 못할 수 있습니다. 조인 성능이 이상하게 느린 경우에는 양쪽 컬럼 타입부터 먼저 확인하는 편이 좋습니다.

Phase 4. 복합 인덱스는 "있다"보다 "순서가 맞다"가 중요합니다

복합 인덱스는 컬럼을 여러 개 넣었다고 끝나지 않습니다. 어떤 순서로 정렬되어 있는가가 핵심입니다.

왼쪽부터 맞아야 합니다

CREATE INDEX idx_status_created_at ON orders (status, created_at);

이 인덱스가 있을 때:

-- ✅ 잘 맞는 경우
WHERE status = 'PAID'
WHERE status = 'PAID' AND created_at >= '2026-01-01'

-- ❌ 일반적인 탐색에는 부적합
WHERE created_at >= '2026-01-01'

인덱스는 status부터 정렬되어 있으므로, 첫 번째 컬럼을 건너뛰고 created_at만으로 탐색하는 것은 일반적으로 비효율적입니다.

범위 조건 이후는 활용이 약해집니다

CREATE INDEX idx_a_b_c ON sample (a, b, c);

-- a는 동등 조건, b는 범위 조건
WHERE a = 1
  AND b > 10
  AND c = 100

이 경우 보통 a, b까지는 인덱스를 잘 활용할 수 있지만, 범위 조건이 들어간 뒤의 c는 일반적인 B+Tree 탐색에서 범위를 더 좁히기 어렵고 필터링 단계로 밀릴 가능성이 큽니다.

즉, 복합 인덱스는 "컬럼이 다 들어갔다"보다 동등 조건 → 범위 조건 → 정렬 조건 순서에 얼마나 잘 맞는가가 중요합니다.

Phase 5. 선택도가 낮으면 인덱스가 손해일 수 있습니다

선택도(selectivity)는 조건이 얼마나 많은 데이터를 걸러내는가를 뜻합니다. 선택도가 낮으면 인덱스가 있어도 큰 도움이 되지 않습니다.

이런 컬럼은 주의해야 합니다

  • 성별: M, F
  • 상태값: ACTIVE, INACTIVE
  • 삭제 여부: Y, N
  • boolean 플래그
CREATE INDEX idx_is_deleted ON posts (is_deleted);

SELECT *
FROM posts
WHERE is_deleted = 'N';

전체 데이터의 95%가 N이라면, 이 조건은 거의 아무것도 걸러내지 못합니다. 그러면 인덱스를 타고 대량의 PK를 읽은 뒤 테이블로 다시 접근하는 것보다, 풀 스캔이 더 낫다고 판단될 수 있습니다.

해결 방향

선택도가 낮은 단일 컬럼 인덱스는 단독으로 두기보다, 다른 조건과 결합한 복합 인덱스로 설계하는 경우가 많습니다.

-- is_deleted 단독보다, 실제 조회 패턴에 맞춘 조합이 유리할 수 있음
CREATE INDEX idx_is_deleted_created_at ON posts (is_deleted, created_at);

물론 이 경우에도 is_deleted가 항상 먼저 와야 하는지는 쿼리 패턴에 따라 달라집니다. 핵심은 실제 WHERE 조건 조합을 기준으로 인덱스를 설계해야 한다는 점입니다.

Phase 6. 조회 행이 너무 많으면 인덱스보다 풀 스캔이 낫습니다

보조 인덱스는 보통 "인덱스 탐색 → PK 조회 → 실제 행 읽기" 과정을 거칩니다. 그런데 조건에 맞는 행이 너무 많으면 이 랜덤 I/O가 크게 늘어납니다.

SELECT *가 특히 불리한 이유

SELECT *
FROM orders
WHERE status = 'PAID';

인덱스를 타더라도 결국 필요한 컬럼이 많으면 테이블 페이지를 다시 많이 읽어야 합니다. 조회 비율이 높을수록 보조 인덱스의 장점이 줄어듭니다.

반면 필요한 컬럼이 적고, 인덱스 안에서 해결되는 쿼리는 훨씬 유리합니다.

-- 커버링 인덱스 가능성
SELECT status, created_at
FROM orders
WHERE status = 'PAID';

즉, 같은 조건이어도 SELECT *인지, 필요한 컬럼만 조회하는지에 따라 옵티마이저 판단이 달라질 수 있습니다.

Phase 7. 정렬과 조건이 따로 놀면 인덱스 이점을 잃습니다

인덱스는 검색뿐 아니라 정렬에도 도움이 됩니다. 하지만 WHEREORDER BY가 인덱스 순서와 맞지 않으면 별도 정렬이 필요해집니다.

정렬 컬럼 순서가 맞지 않는 경우

CREATE INDEX idx_status_created_at ON orders (status, created_at);

SELECT *
FROM orders
WHERE status = 'PAID'
ORDER BY updated_at DESC;

이 인덱스는 status, created_at 기준 정렬만 자연스럽게 지원합니다. 그런데 updated_at으로 정렬하면, 필터링은 인덱스를 일부 활용하더라도 결국 Using filesort가 발생할 수 있습니다.

정렬 방향과 컬럼 구성이 함께 중요합니다

SELECT *
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 20;

이 쿼리가 자주 호출된다면, WHERE에서 먼저 user_id를 좁힌 뒤 created_at 순서로 정렬할 수 있는 user_id, created_at 인덱스는 매우 강력할 수 있습니다. 반대로 인덱스가 created_at, user_id 순서면 이 쿼리 패턴에는 기대만큼 효율적이지 않을 수 있습니다.

즉, 인덱스는 WHERE만 보는 것이 아니라 필터링 + 정렬 + LIMIT까지 한 세트로 봐야 합니다.

Phase 8. 통계 정보가 틀리면 실행 계획도 틀릴 수 있습니다

옵티마이저는 실제 데이터를 전부 읽어 보고 판단하지 않습니다. 통계 정보(statistics) 를 기반으로 어느 계획이 저렴할지 추정합니다.

왜 통계가 중요할까?

  • 데이터 분포가 최근에 크게 바뀌었는데 통계가 오래됨
  • 특정 값에 데이터가 몰려 있는데 샘플링이 부정확함
  • 개발 환경과 운영 환경의 데이터 분포가 완전히 다름

이런 경우 실제로는 인덱스가 유리한데도, 옵티마이저가 풀 스캔을 고를 수 있습니다.

그래서 무엇을 확인해야 할까?

  • EXPLAINrows 추정치가 비정상적으로 큰지
  • 최근 데이터 분포가 크게 바뀌지 않았는지
  • 통계 갱신이 필요한 상황인지

MySQL에서는 ANALYZE TABLE로 통계를 갱신할 수 있습니다.

ANALYZE TABLE orders;

참고: 통계가 문제인 경우는 "쿼리 문법이 틀렸다"가 아니라 "옵티마이저가 잘못 추정했다"에 가깝습니다. 그래서 같은 쿼리가 어떤 환경에서는 빠르고, 어떤 환경에서는 느릴 수 있습니다.

한눈에 보는 대표 원인

실무에서는 인덱스가 안 탈 때 아래 항목을 순서대로 의심해 보면 원인을 빠르게 좁힐 수 있습니다.

원인 왜 인덱스가 안 타는가 대표 대응
컬럼 가공 원본 값 정렬을 활용할 수 없음 범위 조건, 함수 기반 인덱스 검토
암묵적 형변환 컬럼/비교값 타입 불일치 타입 일치
복합 인덱스 순서 불일치 leftmost prefix를 못 맞춤 컬럼 순서 재설계
선택도 낮음 너무 많은 행을 읽음 복합 인덱스, 조건 재검토
조회 행 수 과다 랜덤 I/O가 풀 스캔보다 비쌈 커버링 인덱스, 조회 컬럼 축소
정렬 조건 불일치 필터링과 정렬을 같이 해결 못 함 WHERE + ORDER BY 기준 재설계
통계 부정확 옵티마이저 비용 추정 오류 통계 갱신, 데이터 분포 확인

정리

  1. 인덱스가 있다고 항상 사용하는 것은 아닙니다 — 옵티마이저는 가장 싸 보이는 계획을 고릅니다
  2. 컬럼 가공과 암묵적 형변환은 가장 흔한 실수입니다 — 원본 값 기준 탐색이 깨질 수 있습니다
  3. 복합 인덱스는 순서가 핵심입니다 — 컬럼이 들어 있다는 사실보다 WHERE, ORDER BY 패턴에 맞는지가 중요합니다
  4. 선택도가 낮거나 조회 행이 너무 많으면 풀 스캔이 더 나을 수 있습니다 — 인덱스도 비용이 드는 구조입니다
  5. 정렬과 LIMIT까지 함께 봐야 합니다 — 좋은 인덱스는 검색과 정렬을 같이 해결합니다
  6. 실행 계획은 통계 정보에 의존합니다 — 통계가 틀리면 옵티마이저 판단도 틀릴 수 있습니다