인덱스를 분명히 만들어놨는데, EXPLAIN을 보면 type이 ALL인 경우가 있습니다. 인덱스가 있어도 왜 사용되지 않을까요?

인덱스를 만들었다고 항상 사용되는 것은 아닙니다. 옵티마이저가 인덱스를 무시하는 데는 명확한 이유가 있습니다. 이 패턴들을 알아두면 쿼리 성능 문제를 빠르게 진단할 수 있습니다.

1. 인덱스 컬럼에 함수 사용

가장 흔한 실수입니다. 인덱스 컬럼에 함수를 적용하면 B+Tree 구조를 활용할 수 없습니다.

SQL
-- 인덱스: created_at

-- 인덱스 사용 X: 컬럼에 함수 적용
SELECT * FROM orders WHERE YEAR(created_at) = 2026;
SELECT * FROM orders WHERE DATE(created_at) = '2026-03-19';
SELECT * FROM users WHERE UPPER(name) = 'KIM';
SELECT * FROM orders WHERE amount + 100 > 1000;

-- 인덱스 사용 O: 범위 조건으로 변환
SELECT * FROM orders
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';

SELECT * FROM orders
WHERE created_at >= '2026-03-19' AND created_at < '2026-03-20';

-- 인덱스 사용 O: 값 쪽에 연산 적용
SELECT * FROM orders WHERE amount > 1000 - 100;

핵심 원칙: 인덱스 컬럼은 가공하지 않고, 비교 값을 가공합니다.

MySQL 8.0 함수 기반 인덱스

불가피하게 함수를 써야 한다면, MySQL 8.0의 ** 함수 기반 인덱스(Functional Index)** 를 활용합니다.

SQL
-- 함수 결과를 인덱스로 생성
CREATE INDEX idx_year ON orders ((YEAR(created_at)));
CREATE INDEX idx_lower_email ON users ((LOWER(email)));

-- 이제 함수를 써도 인덱스 사용 가능
SELECT * FROM orders WHERE YEAR(created_at) = 2026;
SELECT * FROM users WHERE LOWER(email) = 'test@test.com';

2. 암묵적 형변환

컬럼 타입과 비교 값의 타입이 다르면 MySQL이 암묵적으로 형변환 을 수행합니다.

SQL
-- phone은 VARCHAR(20) 타입, 인덱스 있음

-- 인덱스 사용 X: 문자열 컬럼에 숫자 비교 → 모든 행을 숫자로 변환
SELECT * FROM users WHERE phone = 01012345678;

-- 인덱스 사용 O: 올바른 타입으로 비교
SELECT * FROM users WHERE phone = '01012345678';

MySQL의 형변환 규칙:

  • 문자열과 숫자 비교 시 → 문자열을 숫자로 변환
  • 따라서 VARCHAR 컬럼에 숫자를 비교하면, 컬럼의 모든 값이 변환되어야 함
  • 사실상 모든 행에 함수가 적용되는 것과 같음
SQL
-- 자주 발생하는 형변환 실수들

-- 인덱스 사용 X: INT 컬럼에 문자열 비교 (이 경우는 보통 인덱스 사용 가능)
-- 하지만 혼란을 피하기 위해 올바른 타입을 사용하는 것이 좋습니다
SELECT * FROM orders WHERE id = '42';  -- 대부분 OK, 하지만 습관적으로 피해야 함

-- 인덱스 사용 X: 문자셋 불일치
-- utf8_general_ci 컬럼과 utf8mb4_general_ci 값 비교 시 변환 발생 가능

3. LIKE 패턴의 시작이 와일드카드

SQL
-- 인덱스: name

-- 인덱스 사용 X: 앞에 % (어디서 시작하는지 알 수 없음)
SELECT * FROM users WHERE name LIKE '%철수';
SELECT * FROM users WHERE name LIKE '%철%';

-- 인덱스 사용 O: 앞부분 고정 (접두사 매칭)
SELECT * FROM users WHERE name LIKE '김%';
SELECT * FROM users WHERE name LIKE '김철%';

B+Tree는 왼쪽부터 정렬 되어 있으므로, 시작 부분이 고정되어야 인덱스 범위를 결정할 수 있습니다.

와일드카드가 앞에 있어야 하는 경우 대안:

  • 풀텍스트 인덱스 사용
  • ** 역인덱스 컬럼** 추가 (REVERSE 함수 + 함수 기반 인덱스)
  • ** 검색 엔진** (Elasticsearch) 도입

4. OR 조건

SQL
-- 인덱스: idx_name(name), idx_email(email)

-- 인덱스 사용 가능: 양쪽 모두 인덱스 있음 (Index Merge 가능)
SELECT * FROM users WHERE name = '김철수' OR email = 'kim@test.com';
-- EXPLAIN: type=index_merge, Extra: Using union(idx_name, idx_email)

-- 인덱스 사용 X: 한쪽에 인덱스 없음
SELECT * FROM users WHERE name = '김철수' OR phone = '01012345678';
-- phone에 인덱스가 없으면 전체 풀 스캔

OR 조건 최적화:

SQL
-- 방법 1: UNION으로 분리 (각각 인덱스 활용)
SELECT * FROM users WHERE name = '김철수'
UNION
SELECT * FROM users WHERE phone = '01012345678';

-- 방법 2: 누락된 인덱스 추가
CREATE INDEX idx_phone ON users(phone);

5. NOT, 부정 조건

SQL
-- 인덱스: status

-- 인덱스 사용 제한: 부정 조건
SELECT * FROM orders WHERE status != 'cancelled';
SELECT * FROM orders WHERE status NOT IN ('cancelled', 'refunded');

-- 대부분의 행이 결과에 포함되면 옵티마이저가 풀 스캔 선택
-- 소수의 행만 결과에 포함되면 인덱스 사용할 수도 있음

부정 조건은 "해당되지 않는 모든 행"을 의미하므로, 결과 범위가 넓어 인덱스 이점이 줄어듭니다.

6. IS NULL / IS NOT NULL

SQL
-- 인덱스: deleted_at

-- InnoDB에서는 NULL도 인덱스에 포함됩니다
-- 인덱스 사용 가능: IS NULL
SELECT * FROM users WHERE deleted_at IS NULL;

-- 인덱스 사용 여부는 NULL 비율에 따라 다름
-- NULL이 소수면 인덱스 사용, 대부분이 NULL이면 풀 스캔
SELECT * FROM users WHERE deleted_at IS NOT NULL;

7. 카디널리티가 낮은 컬럼

SQL
-- gender 컬럼: 'M', 'F' 두 가지 값만 존재
CREATE INDEX idx_gender ON users(gender);

-- 옵티마이저: 인덱스로 50%를 읽는 것보다 풀 스캔이 더 빠르다고 판단
SELECT * FROM users WHERE gender = 'M';
-- type: ALL (풀 스캔)

옵티마이저는 결과 행이 전체의 약 20~30% 이상이면 인덱스 대신 풀 스캔을 선택하는 경향이 있습니다. 랜덤 I/O(인덱스)보다 순차 I/O(풀 스캔)가 더 빠를 수 있기 때문입니다.

8. 복합 인덱스의 최좌선 미충족

SQL
-- 인덱스: (department, age, salary)

-- 인덱스 사용 X: 선두 컬럼 없음
SELECT * FROM employees WHERE age = 28;
SELECT * FROM employees WHERE salary > 5000;
SELECT * FROM employees WHERE age = 28 AND salary > 5000;

-- 인덱스 사용 O: 선두 컬럼 있음
SELECT * FROM employees WHERE department = 'Engineering';
SELECT * FROM employees WHERE department = 'Engineering' AND age = 28;

9. 테이블이 너무 작은 경우

SQL
-- 행이 수십 개밖에 없는 테이블
-- 인덱스 탐색 비용 > 풀 스캔 비용
-- 옵티마이저가 풀 스캔을 선택하는 것이 정상

10. 통계 정보가 오래된 경우

옵티마이저는 인덱스 통계(Cardinality 등) 를 기반으로 결정합니다. 통계가 실제 데이터와 다르면 잘못된 판단을 할 수 있습니다.

SQL
-- 테이블 통계 갱신
ANALYZE TABLE users;

-- 인덱스 통계 확인
SHOW INDEX FROM users;
-- Cardinality 컬럼 확인

진단 도구와 해결 절차

1단계: EXPLAIN으로 확인

SQL
EXPLAIN SELECT * FROM users WHERE name = '김철수';

확인 포인트:

  • type: ALL → 풀 스캔 (인덱스 미사용)
  • type: index → 인덱스 풀 스캔 (인덱스를 읽지만 전체 스캔)
  • type: range → 인덱스 범위 스캔 (양호)
  • type: ref → 인덱스 참조 (양호)
  • type: const → PK/유니크 상수 조회 (최적)

2단계: 가능한 인덱스 확인

SQL
-- possible_keys vs key
EXPLAIN SELECT * FROM users WHERE age > 20;
-- possible_keys: idx_age (사용 가능한 인덱스)
-- key: NULL (실제로 선택된 인덱스 — NULL이면 미사용)

3단계: 옵티마이저 힌트로 강제

SQL
-- 인덱스 사용 강제 (디버깅/비교용)
SELECT * FROM users FORCE INDEX (idx_age) WHERE age > 20;

-- 인덱스 무시 (다른 인덱스 사용 유도)
SELECT * FROM users IGNORE INDEX (idx_age) WHERE age > 20;

-- MySQL 8.0 옵티마이저 힌트
SELECT /*+ INDEX(users idx_age) */ * FROM users WHERE age > 20;

4단계: 쿼리 또는 인덱스 수정

PLAINTEXT
문제: 함수 사용 → 범위 조건으로 변환 또는 함수 기반 인덱스
문제: 형변환 → 올바른 타입 사용
문제: OR 조건 → UNION 또는 인덱스 추가
문제: 카디널리티 낮음 → 복합 인덱스로 조합
문제: 통계 오래됨 → ANALYZE TABLE

정리

인덱스가 사용되지 않는 주요 원인:

원인해결법
컬럼에 함수 적용범위 조건으로 변환 / 함수 기반 인덱스 (8.0)
암묵적 형변환올바른 데이터 타입 사용
LIKE '%...'풀텍스트 인덱스 / 검색 엔진
OR 조건 (한쪽 인덱스 없음)UNION / 인덱스 추가
카디널리티가 너무 낮음복합 인덱스로 선택도 향상
결과 행이 너무 많음조건 추가 / 풀 스캔이 정상
복합 인덱스 최좌선 미충족인덱스 재설계
통계 정보 오래됨ANALYZE TABLE

주의할 점

FORCE INDEX는 최후의 수단이다

FORCE INDEX로 인덱스를 강제하면 당장은 해결되지만, 데이터 분포가 변하면 오히려 성능이 악화될 수 있습니다. 근본 원인(함수 사용, 형변환, 통계 오래됨)을 먼저 해결해야 합니다.

암묵적 형변환은 코드 리뷰에서 잡기 어렵다

WHERE phone = 01012345678 같은 코드는 문법 오류가 아니라 정상 실행됩니다. 하지만 인덱스를 무효화시키므로 성능이 크게 떨어집니다. 타입을 명시적으로 맞추는 코딩 규칙이 필요합니다.

인덱스를 만들기 전에 EXPLAIN으로 확인하고, 만든 후에도 EXPLAIN으로 검증하는 습관이 중요합니다

댓글 로딩 중...