인덱스가 있는데 안 타는 이유

인덱스를 만들었는데 쿼리는 그대로 느리고, 개발 DB 에선 잘 타던 인덱스를 운영에서만 안 타요. 옵티마이저가 인덱스를 못 쓰는 경우와 일부러 안 쓰는 경우를 가려내는 순서를 정리했어요.

느린 쿼리를 잡으려고 인덱스를 만들었어요. 컬럼도 맞고 이름도 제대로 붙였고, 만들어진 것도 확인했어요.

그런데 쿼리는 그대로 느려요. EXPLAIN 을 보면 순차 스캔이에요. 인덱스가 분명히 있는데 안 씁니다.

더 헷갈리는 건 개발 DB 에서는 인덱스를 탄다는 거예요. 같은 쿼리, 같은 스키마인데 운영에서만 안 타요. 그래서 "인덱스가 깨졌나" 싶어 다시 만들어 보지만 결과는 같습니다.

이 상태에서 대부분 힌트를 강제로 걸어 넘어가요. 당장은 빨라지니까요. 그게 왜 나중에 더 큰 문제가 되는지는 아래에서 얘기할게요.

먼저 알아 둘 게 하나 있어요. 옵티마이저가 인덱스를 안 쓰는 게 옳은 판단인 경우가 생각보다 많다는 것.

왜 그런가

옵티마이저는 인덱스를 "쓸 수 있으면 쓴다"가 아니라 "싸면 쓴다"로 판단해요. 그래서 안 타는 이유는 크게 두 갈래예요. 못 쓰는 경우와, 쓸 수 있는데 안 쓰기로 한 경우.

못 쓰는 경우는 조건이 인덱스 구조와 안 맞을 때예요. 컬럼에 함수를 씌우면(예: DATE(created_at) = '...') 인덱스에 저장된 값과 비교 대상이 달라져서 못 써요. 타입이 다르면 암묵 형변환이 일어나면서 같은 일이 벌어지고요. 앞이 열린 LIKE 패턴, 복합 인덱스의 왼쪽 컬럼을 건너뛴 조건도 여기 속해요.

쓸 수 있는데 안 쓰는 경우가 더 헷갈려요. 인덱스 스캔은 인덱스를 읽고 그다음 테이블 본체를 다시 읽어야 해요. 이 두 번째 읽기가 흩어져 있으면 임의 접근이라 비싸요. 반면 순차 스캔은 처음부터 끝까지 죽 읽는 거라 한 번에 많이 읽을 수 있고요.

그래서 조건에 맞는 행이 전체의 일정 비율을 넘으면, 순차 스캔이 실제로 더 빨라요. 옵티마이저는 통계로 그 비율을 추정해서 고릅니다. 개발 DB 에서 인덱스를 타는 건 데이터가 적어서 추정치가 다르기 때문이에요.

코드로 보면

조건을 인덱스가 이해할 수 있게:

코드
· 컬럼에 함수를 씌우면 인덱스를 못 쓴다
 WHERE DATE(created_at) = '2026-08-06'
 WHERE created_at >= '2026-08-06'
 AND created_at <  '2026-08-07'

· 꼭 함수 형태로 써야 하면 표현식 인덱스를 만든다
 CREATE INDEX idx_created_date ON t ((DATE(created_at)));

· 타입이 다르면 암묵 형변환이 인덱스를 무력화한다
 WHERE user_code = 12345      -- user_code 가 VARCHAR
 WHERE user_code = '12345'

· 복합 인덱스 (a, b) 에서 b 만 쓰면 못 탄다
 WHERE b = 1
 WHERE a = 1 AND b = 1

✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.

실전에서는

안 타는 이유를 순서대로 짚는 게 진단의 핵심이에요. 목록으로 정리하면 이렇습니다.

첫째, 조건이 인덱스와 안 맞는 경우. 컬럼에 씌운 함수, 암묵 형변환, 앞이 열린 LIKE, 복합 인덱스의 왼쪽 컬럼 누락. 이건 쿼리를 고쳐야 해요.

둘째, 통계가 낡은 경우. 옵티마이저는 통계로 "몇 행이 나올지"를 추정하는데, 통계가 실제와 크게 다르면 잘못된 선택을 해요. 대량 적재 직후가 특히 위험해요.

셋째, 선택도가 실제로 나쁜 경우. 값이 두세 종류뿐인 컬럼에 인덱스를 걸면, 조건에 맞는 행이 전체의 30~50% 라 순차 스캔이 정말로 더 빨라요. 이때 옵티마이저는 옳은 선택을 하고 있는 거예요. 인덱스가 문제가 아니라 인덱스 설계가 문제입니다.

넷째, 데이터가 적은 경우. 몇천 행짜리 테이블은 통째로 읽는 게 항상 빨라요. 개발 DB 에서 실험해 봐야 의미가 없는 이유예요.

다섯째, 파라미터에 따라 계획이 갈리는 경우. 같은 쿼리라도 넘긴 값이 흔한 값이냐 드문 값이냐에 따라 최적 계획이 달라요. 그래서 어떤 요청은 빠르고 어떤 요청은 느린 현상이 생겨요.

여기서 자주 하는 실수가 힌트로 강제하는 거예요. 인덱스를 쓰라고 강제하면 그 순간은 빨라져요. 그런데 데이터 분포가 바뀌면 그 강제가 최악의 선택이 됩니다. 옵티마이저가 적응할 여지를 없애 버리는 거라, 진단이 끝나기 전에는 쓰지 않는 게 맞아요. 진단 도구로는 유용합니다 — 강제로 켜 봤을 때 정말 빨라지는지 보면 판단의 근거가 되니까요.

처방

  1. EXPLAIN 이 아니라 EXPLAIN ANALYZE 를 보세요. 추정 행 수와 실제 행 수를 나란히 보여 줘요. 둘이 크게 다르면 통계 문제고, 비슷한데도 순차 스캔을 골랐다면 옵티마이저 판단이 맞을 가능성이 큽니다.
  2. 조건에서 컬럼을 벗기세요. 함수는 오른쪽으로 옮기고, 범위 조건으로 바꾸고, 타입을 맞추세요. 대부분의 "인덱스를 못 타는" 문제가 여기서 풀려요.
  3. 통계를 갱신해 보세요. ANALYZE 한 번으로 계획이 바뀐다면 원인이 확정된 거예요.
  4. 선택도를 확인하세요. 조건에 맞는 행이 전체의 몇 퍼센트인지 세어 보세요. 30% 를 넘으면 그 인덱스는 애초에 도움이 안 돼요. 복합 인덱스로 선택도를 높이거나 부분 인덱스를 검토하세요.
  5. 커버링 인덱스를 검토하세요. 필요한 컬럼을 인덱스에 다 담으면 테이블 본체를 안 읽어도 돼요. 두 번째 읽기가 사라지니 옵티마이저의 계산도 달라집니다.
  6. 힌트는 마지막에, 그리고 이유를 주석으로 남기고 쓰세요. 언제 다시 검토할지도 같이 적으세요.

지금 확인해 보세요

추정과 실제를 나란히 본다:

코드
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE status = 'paid';

· rows=1000 (추정)  actual rows=980000 ← 통계 문제
· rows=1000 (추정)  actual rows=1010   ← 판단은 맞았다

· 선택도를 직접 세어 본다
SELECT status, count(*),
       round(100.0*count(*)/sum(count(*)) over (), 1) AS pct
  FROM orders GROUP BY status;
 한 값이 30% 를 넘으면 그 값 조회엔 인덱스가 무의미하다

· 통계가 언제 갱신됐나
SELECT relname, last_analyze, last_autoanalyze
  FROM pg_stat_user_tables WHERE relname='orders';

· 진단용으로만 — 강제로 켜서 정말 빨라지는지 본다
 SET enable_seqscan = off;   -- 세션 한정, 운영 코드엔 넣지 않는다

핵심 한 줄인덱스는 있으면 타는 게 아니라 싸면 탄다.

자주 묻는 것

WHERE DATE(created_at) = ... 조건은 왜 인덱스를 못 타나요?

컬럼에 함수를 씌우면 인덱스에 저장된 값과 비교 대상이 달라져서 인덱스를 못 써요. 함수를 오른쪽으로 옮겨 범위 조건으로 바꾸면 풀려요.

브루니가 적어요

색을 바꾸며 숨는 카멜레온이에요. 운영에서 실제로 터지는 문제를 한 편씩 적어요. 같은 실수를 두 번 하지 않으려고 쓰는 기록이에요.

인덱스·쿼리 성능 글 20편 모두 보기 →