Published on

PostgreSQL 인덱스 안 타는 이유 9가지와 해결

Authors

서버를 튜닝하다 보면 가장 흔한 질문이 있습니다. “인덱스 만들었는데 왜 안 타죠?”입니다. 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 Scan
  • WHERE status != 'DELETED' 처럼 부정 조건 중심

해결 1: ORUNION 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분 안에 보는 것들

  1. EXPLAIN (ANALYZE, BUFFERS) 로 실제 플랜/버퍼 확인
  2. rows 추정치와 actual rows 차이가 큰가
  3. WHERE 절에 함수/캐스팅이 숨어 있나
  4. 컬럼 타입과 바인딩 타입이 같은가
  5. 패턴 매칭이면 pg_trgm 같은 올바른 인덱스 타입을 썼나
  6. 복합 인덱스의 선두 컬럼이 쿼리 조건과 맞나
  7. OR/부정 조건을 쿼리 형태로 개선할 수 있나
  8. 조인/정렬/리밋까지 포함한 “최종 쿼리 형태” 기준으로 인덱스를 설계했나
  9. Index Only ScanHeap Fetches 가 과도하지 않은가(청소/가시성 문제)

부록: 인덱스 강제는 최후의 수단

PostgreSQL은 MySQL의 FORCE INDEX 같은 직접 힌트가 기본적으로 없습니다(확장으로 흉내는 가능). 운영에서 플랜을 “강제로” 고정하는 접근은 데이터 분포가 바뀌거나 버전 업 시 더 큰 장애로 돌아올 수 있습니다. 대신 다음을 권장합니다.

  • 쿼리를 인덱스 친화적으로 리라이트
  • 통계를 정확히 만들기(확장 통계 포함)
  • 인덱스를 쿼리 패턴에 맞게 재설계(부분/표현식/GIN 등)
  • EXPLAIN (ANALYZE, BUFFERS) 로 변경 전후를 수치로 검증

인덱스가 “안 타는” 문제는 대부분 플래너가 멍청해서가 아니라, 우리가 전달한 정보(통계/쿼리 형태/인덱스 설계)가 부족하거나 비용 모델상 합리적인 선택인 경우가 많습니다. 위 9가지를 순서대로 대입하면, 대개 원인은 빠르게 좁혀지고 해결도 명확해집니다.