- Published on
PostgreSQL 인덱스 안 타는 이유 9가지와 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버를 튜닝하다 보면 가장 흔한 질문이 있습니다. “인덱스 만들었는데 왜 안 타죠?”입니다. PostgreSQL은 단순히 인덱스가 존재한다고 해서 항상 인덱스를 선택하지 않습니다. 통계, 비용 모델, 연산자/타입, 함수 사용, 정렬/조인 방식, 데이터 분포, 파티션, 그리고 MVCC로 인한 가시성까지 다양한 요소가 얽혀서 플래너가 Seq Scan을 더 싸다고 판단할 수 있습니다.
이 글은 “인덱스가 안 타는 것처럼 보이는” 상황을 9가지로 분류하고, 각 원인별로 재현 가능한 점검 방법과 해결책을 정리합니다. 글의 모든 예시는 EXPLAIN (ANALYZE, BUFFERS) 를 기준으로 설명합니다.
VACUUM/통계 이슈가 의심된다면 아래 글도 함께 보면 좋습니다. PostgreSQL VACUUM 안됨? bloat·wraparound 7분 해결
먼저: “정말 인덱스가 안 타는지” 확인하는 기본 루틴
인덱스가 안 탄다고 느끼는 많은 케이스는 실제로는 “인덱스는 타지만 느리다” 또는 “다른 인덱스를 탄다”인 경우도 많습니다. 아래 순서로 확인하면 원인 분류가 빨라집니다.
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT *
FROM orders
WHERE user_id = 42
AND created_at >= now() - interval '7 days';
Seq Scan인지,Index Scan/Index Only Scan인지 확인rows=예상과actual rows=실제차이가 큰지 확인(통계 문제 신호)Buffers: shared hit/read를 보고 I/O 병목인지 확인Filter:로 남는 조건이 많은지(인덱스 조건으로 못 밀어넣는 조건이 있는지) 확인
추가로 인덱스 정의도 같이 봅니다.
\d+ orders
-- 또는
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'orders';
1) 선택도(Selectivity)가 낮아 Seq Scan이 더 싼 경우
증상
- 조건이 너무 많은 행을 반환합니다. 예:
status = 'PAID'같이 전체의 60%가 매칭 - 플래너가 “인덱스 타고 랜덤 I/O 하는 비용”이 “순차 스캔”보다 크다고 판단
점검
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM orders
WHERE status = 'PAID';
actual rows 가 테이블의 큰 비율이면 정상적으로 Seq Scan이 나올 수 있습니다.
해결
- 더 선택적인 조건을 함께 묶는 복합 인덱스
- 자주 쓰는 조건 조합이면 부분 인덱스(partial index)
-- 예: 최근 90일의 PAID만 자주 조회한다면
CREATE INDEX CONCURRENTLY idx_orders_paid_recent
ON orders (created_at DESC)
WHERE status = 'PAID' AND created_at >= now() - interval '90 days';
부분 인덱스는 크기를 줄이고 선택도를 높여 “인덱스가 이득인 상황”을 만들기 좋습니다.
2) 통계가 낡았거나 분포를 못 담아 플래너가 오판하는 경우
증상
rows=예상과actual rows=실제차이가 매우 큼- 인덱스가 유리한데도
Seq Scan이 선택됨 - 대량 업데이트/삭제 이후에 갑자기 플랜이 나빠짐
점검
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM events
WHERE tenant_id = 7 AND event_type = 'CLICK';
-- 통계 갱신
ANALYZE events;
해결
ANALYZE또는VACUUM (ANALYZE)수행- 특정 컬럼 분포가 복잡하면 통계 타깃 상향
ALTER TABLE events ALTER COLUMN tenant_id SET STATISTICS 1000;
ALTER TABLE events ALTER COLUMN event_type SET STATISTICS 1000;
ANALYZE events;
- 컬럼 간 상관관계가 강하면 확장 통계(extended statistics) 고려
CREATE STATISTICS st_events_tenant_type (dependencies)
ON tenant_id, event_type
FROM events;
ANALYZE events;
3) WHERE 조건에 함수/연산을 걸어 인덱스가 무력화되는 경우
증상
WHERE date(created_at) = '2026-02-24'같은 형태- 컬럼에
lower(),trim(),cast()등을 적용 - 결과적으로 인덱스 조건으로 내려가지 못하고
Filter로 처리
나쁜 예
SELECT *
FROM orders
WHERE date(created_at) = date '2026-02-24';
해결 1: 범위 조건으로 바꾸기
SELECT *
FROM orders
WHERE created_at >= timestamp '2026-02-24 00:00:00'
AND created_at < timestamp '2026-02-25 00:00:00';
해결 2: 표현식 인덱스(expression index)
정말 date(created_at) 형태가 필수라면:
CREATE INDEX CONCURRENTLY idx_orders_created_date
ON orders ((date(created_at)));
4) 타입 불일치/암묵적 캐스팅 때문에 인덱스를 못 쓰는 경우
증상
- 컬럼은
uuid인데 파라미터는 문자열 - 컬럼은
integer인데 바인딩이text WHERE id = '123'처럼 문자열 리터럴이 들어옴
PostgreSQL은 상황에 따라 암묵 캐스팅을 수행하며, 이때 인덱스 사용이 제한되거나(혹은 비용이 커져) 플랜이 바뀔 수 있습니다.
점검
EXPLAIN (ANALYZE, VERBOSE)
SELECT *
FROM users
WHERE id = '550e8400-e29b-41d4-a716-446655440000';
::uuid 같은 캐스팅이 어디에 붙는지 확인합니다.
해결
- 애플리케이션 바인딩 타입을 컬럼과 일치시키기
- 리터럴이라면 명시 캐스팅
SELECT *
FROM users
WHERE id = '550e8400-e29b-41d4-a716-446655440000'::uuid;
5) LIKE / ILIKE 패턴이 인덱스 친화적이지 않은 경우
증상
LIKE '%keyword%'처럼 앞에 와일드카드가 있음ILIKE대소문자 무시 검색이 잦음- B-Tree 인덱스로는 도움을 못 받음
해결 1: 접두(prefix) 검색이면 B-Tree + 패턴 연산자 클래스
CREATE INDEX CONCURRENTLY idx_users_email_prefix
ON users (email text_pattern_ops);
EXPLAIN (ANALYZE)
SELECT *
FROM users
WHERE email LIKE 'admin%';
해결 2: 포함(contains) 검색이면 pg_trgm + GIN
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX CONCURRENTLY idx_users_email_trgm
ON users USING gin (email gin_trgm_ops);
EXPLAIN (ANALYZE)
SELECT *
FROM users
WHERE email ILIKE '%example.com%';
6) 복합 인덱스의 컬럼 순서가 쿼리와 안 맞는 경우
증상
- 인덱스는
(a, b)인데 쿼리는WHERE b = ...만 있음 - 또는
WHERE a조건이 범위(range)로 걸리면 그 뒤 컬럼 활용이 제한됨
예시
-- 인덱스
CREATE INDEX CONCURRENTLY idx_orders_user_created
ON orders (user_id, created_at);
-- 쿼리: created_at만으로 조회
SELECT *
FROM orders
WHERE created_at >= now() - interval '1 day';
이 경우 created_at 단독 인덱스가 없으면 인덱스가 비효율적일 수 있습니다.
해결
- 쿼리 패턴에 맞춰 인덱스 컬럼 순서를 재설계
- 자주 쓰는 조건을 선두로 두기(일반적으로
=조건이 선두, 그 다음 범위)
CREATE INDEX CONCURRENTLY idx_orders_created_at
ON orders (created_at);
또는 정렬까지 고려한다면:
CREATE INDEX CONCURRENTLY idx_orders_user_created_desc
ON orders (user_id, created_at DESC);
7) OR 조건, 부정 조건(NOT, !=), IS NULL 처리로 플랜이 꼬이는 경우
증상
WHERE a = 1 OR b = 2에서 인덱스가 있음에도Seq ScanWHERE status != 'DELETED'처럼 부정 조건 중심
해결 1: OR 를 UNION ALL 로 분해
EXPLAIN (ANALYZE)
(
SELECT * FROM items WHERE a = 1
)
UNION ALL
(
SELECT * FROM items WHERE b = 2 AND a <> 1
);
중복 제거가 필요 없으면 UNION ALL 이 대개 더 싸게 나옵니다.
해결 2: 부정 조건은 부분 인덱스로 “긍정 조건”화
CREATE INDEX CONCURRENTLY idx_items_not_deleted
ON items (updated_at)
WHERE status <> 'DELETED';
단, status <> 'DELETED' 는 데이터 분포에 따라 효과가 달라질 수 있어 EXPLAIN (ANALYZE) 로 검증이 필요합니다.
8) 조인/정렬 때문에 인덱스가 있어도 다른 경로가 선택되는 경우
증상
- 단일 테이블에서는 인덱스를 타는데, 조인하면
Hash Join+Seq Scan으로 바뀜 ORDER BY를 만족시키는 인덱스가 없어서 대량 정렬이 발생
점검
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.*
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE u.email = 'a@b.com'
ORDER BY o.created_at DESC
LIMIT 50;
해결
- 조인 키에 맞는 인덱스 보장(양쪽)
ORDER BY + LIMIT패턴이면 정렬을 인덱스로 대체하도록 인덱스 설계
CREATE INDEX CONCURRENTLY idx_users_email
ON users (email);
CREATE INDEX CONCURRENTLY idx_orders_user_created_desc
ON orders (user_id, created_at DESC);
이 조합이면 users.email 로 먼저 좁히고, 해당 user_id 에 대해 created_at DESC 인덱스에서 상위 N개를 빠르게 뽑을 가능성이 큽니다.
9) Index Only Scan이 안 되고 힙(Heap) 방문이 많아 “인덱스가 느린” 경우
증상
- 플랜은
Index Scan또는Index Only Scan인데도 느림 Index Only Scan이 떠도Heap Fetches가 매우 큼- 테이블 bloat, autovacuum 지연, visibility map이 잘 안 채워짐
점검
EXPLAIN (ANALYZE, BUFFERS)
SELECT user_id
FROM orders
WHERE user_id = 42;
Index Only Scan 인데 Heap Fetches 가 높으면, “거의 매번 테이블 페이지를 다시 확인”하는 상태라 이득이 줄어듭니다.
해결
VACUUM (ANALYZE)로 가시성 맵 개선(단, 트래픽/락 정책 고려)- autovacuum 튜닝(테이블 단위로 scale factor 조정)
- 필요한 컬럼만 조회하도록 쿼리 축소(커버링 인덱스 유도)
-- 커버링 인덱스: INCLUDE로 테이블 접근을 줄일 수 있음
CREATE INDEX CONCURRENTLY idx_orders_user_include_created
ON orders (user_id)
INCLUDE (created_at, status);
이 이슈는 bloat/청소 정책과도 강하게 연결됩니다. 원인과 해결 흐름은 아래 글이 특히 도움이 됩니다.
실전 체크리스트: 인덱스가 안 탈 때 10분 안에 보는 것들
EXPLAIN (ANALYZE, BUFFERS)로 실제 플랜/버퍼 확인rows추정치와actual rows차이가 큰가- WHERE 절에 함수/캐스팅이 숨어 있나
- 컬럼 타입과 바인딩 타입이 같은가
- 패턴 매칭이면
pg_trgm같은 올바른 인덱스 타입을 썼나 - 복합 인덱스의 선두 컬럼이 쿼리 조건과 맞나
OR/부정 조건을 쿼리 형태로 개선할 수 있나- 조인/정렬/리밋까지 포함한 “최종 쿼리 형태” 기준으로 인덱스를 설계했나
Index Only Scan의Heap Fetches가 과도하지 않은가(청소/가시성 문제)
부록: 인덱스 강제는 최후의 수단
PostgreSQL은 MySQL의 FORCE INDEX 같은 직접 힌트가 기본적으로 없습니다(확장으로 흉내는 가능). 운영에서 플랜을 “강제로” 고정하는 접근은 데이터 분포가 바뀌거나 버전 업 시 더 큰 장애로 돌아올 수 있습니다. 대신 다음을 권장합니다.
- 쿼리를 인덱스 친화적으로 리라이트
- 통계를 정확히 만들기(확장 통계 포함)
- 인덱스를 쿼리 패턴에 맞게 재설계(부분/표현식/GIN 등)
EXPLAIN (ANALYZE, BUFFERS)로 변경 전후를 수치로 검증
인덱스가 “안 타는” 문제는 대부분 플래너가 멍청해서가 아니라, 우리가 전달한 정보(통계/쿼리 형태/인덱스 설계)가 부족하거나 비용 모델상 합리적인 선택인 경우가 많습니다. 위 9가지를 순서대로 대입하면, 대개 원인은 빠르게 좁혀지고 해결도 명확해집니다.