한줄 요약: 인덱스를 걸었는데도 쿼리가 느리다면, 이 5가지 패턴 중 하나가 범인일 가능성이 높습니다.
인덱스 분명히 걸었는데 왜 이렇게 느리지? 처음엔 저도 그 이유를 몰라서 인덱스만 추가하다가 오히려 DB를 망가뜨린 적이 있습니다. 😅
인덱스가 안 타는 게 왜 문제인가요?
인덱스는 책의 목차와 같습니다. 목차가 있으면 원하는 챕터를 바로 펼칠 수 있지만, 목차를 무시하면 책 전체를 처음부터 끝까지 읽어야 하죠.
SQL에서 인덱스를 타지 않는다는 건, DB가 테이블의 모든 행을 하나씩 훑는 Full Table Scan을 한다는 의미입니다. 데이터가 수백만 건이라면 쿼리 한 번에 수 초, 심하면 수십 초가 걸릴 수 있습니다.
문제는 인덱스를 만들어 놓고도 쿼리를 잘못 작성하면 DB 옵티마이저가 인덱스를 아예 무시해버린다는 점입니다. 오늘은 그 대표적인 5가지 패턴을 실제 코드와 함께 살펴보겠습니다.
인덱스가 안 타는 5가지 패턴 — 실전 예제
아래 예제는 실제로 실행해볼 수 있는 코드입니다. 각 패턴마다 나쁜 쿼리와 좋은 쿼리를 함께 보여드립니다.
-- =============================================
-- 테스트용 테이블 및 인덱스 생성
-- =============================================
CREATE TABLE orders (
id INT PRIMARY KEY,
user_id INT NOT NULL,
status VARCHAR(20) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
created_at DATETIME NOT NULL,
memo VARCHAR(200)
);
-- 자주 조회되는 컬럼에 인덱스 생성
CREATE INDEX idx_user_id ON orders (user_id);
CREATE INDEX idx_created_at ON orders (created_at);
CREATE INDEX idx_status ON orders (status);
CREATE INDEX idx_amount ON orders (amount);
-- =============================================
-- ❌ 패턴 1: 인덱스 컬럼에 함수를 씌운 경우
-- 함수로 감싸면 옵티마이저가 인덱스를 못 씁니다
-- =============================================
-- 나쁜 쿼리: YEAR() 함수로 감싸면 Full Scan 발생
SELECT * FROM orders
WHERE YEAR(created_at) = 2024;
-- 좋은 쿼리: 범위 조건으로 변환하면 인덱스 사용
SELECT * FROM orders
WHERE created_at >= '2024-01-01'
AND created_at < '2025-01-01';
-- =============================================
-- ❌ 패턴 2: 묵시적 형변환 (타입 불일치)
-- 컬럼 타입과 조건 값 타입이 다르면 인덱스 무시
-- =============================================
-- 나쁜 쿼리: user_id는 INT인데 문자열로 비교
SELECT * FROM orders
WHERE user_id = '1001'; -- 문자열 '1001'
-- 좋은 쿼리: 컬럼 타입에 맞는 숫자로 비교
SELECT * FROM orders
WHERE user_id = 1001; -- 숫자 1001
-- =============================================
-- ❌ 패턴 3: LIKE 검색에서 앞에 % 사용
-- 앞에 %가 오면 시작점을 알 수 없어 Full Scan
-- =============================================
-- 나쁜 쿼리: 앞에 % → 인덱스 사용 불가
SELECT * FROM orders
WHERE memo LIKE '%환불%';
-- 좋은 쿼리: 뒤에만 % → 인덱스 사용 가능
SELECT * FROM orders
WHERE memo LIKE '환불%';
-- 꿀팁: 양방향 LIKE가 필요하면 Full-Text Index 고려
-- CREATE FULLTEXT INDEX idx_memo ON orders (memo);
-- SELECT * FROM orders WHERE MATCH(memo) AGAINST('환불');
-- =============================================
-- ❌ 패턴 4: OR 조건 사용 (인덱스 분산 문제)
-- OR은 옵티마이저가 인덱스 활용을 포기하기 쉬움
-- =============================================
-- 나쁜 쿼리: OR로 연결하면 인덱스 활용률 저하
SELECT * FROM orders
WHERE status = 'PAID'
OR status = 'PENDING';
-- 좋은 쿼리: IN으로 변환하면 인덱스 활용 가능
SELECT * FROM orders
WHERE status IN ('PAID', 'PENDING');
-- =============================================
-- ❌ 패턴 5: 인덱스 컬럼에 연산 적용
-- 컬럼 자체를 계산에 쓰면 인덱스 무효화
-- =============================================
-- 나쁜 쿼리: 컬럼에 직접 연산 → Full Scan
SELECT * FROM orders
WHERE amount * 1.1 > 10000;
-- 좋은 쿼리: 상수 쪽을 계산해서 컬럼은 그대로
SELECT * FROM orders
WHERE amount > 10000 / 1.1; -- 약 9090.9
-- =============================================
-- 실행 계획 확인 방법 (EXPLAIN)
-- type이 ALL이면 Full Scan, ref/range면 인덱스 사용
-- =============================================
EXPLAIN SELECT * FROM orders
WHERE created_at >= '2024-01-01'
AND created_at < '2025-01-01';
-- 결과의 'type' 컬럼을 확인하세요
-- ALL → Full Table Scan (느림 ⚠️)
-- index → Index Full Scan (조금 나음)
-- range → 범위 인덱스 스캔 (좋음 ✅)
-- ref → 인덱스 참조 (좋음 ✅)
-- const → 상수 조건 (최고 ✅)
코드를 보면 핵심은 하나입니다. 인덱스 컬럼은 가공하지 말고 원본 그대로 조건에 사용해야 한다는 것입니다.
패턴 1~2는 컬럼을 함수나 형변환으로 가공하는 경우, 패턴 3은 LIKE의 방향 문제, 패턴 4는 OR의 인덱스 분산 문제, 패턴 5는 컬럼에 직접 연산을 적용하는 경우입니다.
⚠️ 실행 계획(EXPLAIN)은 반드시 습관화하세요. 쿼리 앞에 EXPLAIN만 붙이면 인덱스를 타는지 바로 확인할 수 있습니다.
자주 하는 실수 — 그리고 올바른 습관
인덱스 관련해서 현업에서 가장 많이 보이는 실수들을 정리했습니다. 나도 모르게 하고 있진 않은지 체크해보세요.
- 잘못된 방법:
WHERE DATE(created_at) = '2024-06-01'처럼 날짜 컬럼에 함수를 씌워 조회하는 경우입니다.DATE()함수가 컬럼 전체에 적용되면서 인덱스를 전혀 사용할 수 없게 됩니다. 데이터가 많을수록 치명적입니다. - 올바른 방법:
WHERE created_at >= '2024-06-01' AND created_at < '2024-06-02'처럼 범위 조건으로 바꿔야 합니다. 컬럼을 건드리지 않으니 인덱스가 정상적으로 동작하고, 쿼리 속도가 수십 배 빨라질 수 있습니다. - 꿀팁: 복합 인덱스(여러 컬럼을 묶은 인덱스)를 사용할 때는 인덱스를 만든 컬럼 순서대로 조건을 걸어야 합니다. 예를 들어
INDEX(user_id, created_at)로 만들었다면,WHERE user_id = 1조건은 인덱스를 타지만WHERE created_at = '2024-01-01'단독 조건은 이 복합 인덱스를 타지 않습니다. 이걸 인덱스 선두 컬럼 규칙이라고 합니다.
🧠 오늘의 퀴즈
아래 쿼리 중 idx_amount 인덱스를 정상적으로 사용하는 쿼리는 어느 것일까요?
- A:
WHERE amount + 500 > 10000 - B:
WHERE amount > 10000 - 500 - C:
WHERE ROUND(amount) > 9500
⚠️ 정답은 B입니다! A와 C는 컬럼 자체에 연산/함수를 적용해서 인덱스가 무효화됩니다. B는 상수 쪽에서 계산하고 컬럼은 그대로 두기 때문에 인덱스를 정상적으로 사용합니다. 이 원칙 하나만 기억해도 쿼리 성능이 확 달라집니다.
정리하며
인덱스를 만들었다고 끝이 아닙니다. 쿼리를 어떻게 작성하느냐에 따라 인덱스가 살기도 하고 죽기도 합니다.
오늘 배운 5가지 패턴만 피해도 대부분의 느린 쿼리 문제를 해결할 수 있습니다. 그리고 EXPLAIN을 습관처럼 사용하면 문제를 사전에 발견할 수 있습니다.
인덱스 최적화에 익숙해졌다면, 다음 단계로 복합 인덱스 설계 전략과 커버링 인덱스(Covering Index)를 공부해보시길 추천드립니다. 쿼리 성능을 한 단계 더 끌어올릴 수 있습니다.
- 이 글의 핵심: 인덱스 컬럼에 함수·연산·형변환을 쓰지 말고, EXPLAIN으로 실행 계획을 반드시 확인하세요.
- 다음 단계: 복합 인덱스(Composite Index), 커버링 인덱스(Covering Index), 쿼리 옵티마이저 동작 원리
참 쉽죠?