- Published on
인덱스를 만들었는데 안 타는 이유 — 옵티마이저가 맞고 내가 틀린 경우들
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — 인덱스를 만들었는데 왜 Seq Scan인가
- 전제 — 인덱스를 쓰지 않는 것이 대개 옳은 판단이다
- 선택도 — 인덱스가 오히려 손해가 되는 지점
- sargable하지 않은 조건 — 함수, 암묵적 캐스팅, 선행 와일드카드
- 복합 인덱스의 선두 컬럼 규칙, OR, NULL
- 통계가 낡으면 옵티마이저는 정확하게 틀린다
- 부분 인덱스가 답인 경우, 그리고 인덱스의 반대편 비용
- 마치며 — 인덱스를 의심하기 전에 계획을 읽으세요
들어가며 — 인덱스를 만들었는데 왜 Seq Scan인가
느린 쿼리를 찾아 인덱스를 만들고, 다시 EXPLAIN을 돌렸는데 계획이 그대로입니다. CREATE INDEX는 분명히 성공했고 pg_indexes에도 보입니다. 그런데 옵티마이저는 여전히 테이블 전체를 훑습니다.
이 상황에서 대부분 검색해서 찾는 조언은 두 가지입니다. SET enable_seqscan = off로 강제하라, 혹은 힌트를 붙여라. 둘 다 진단이 아니라 증상 억제입니다. 강제로 인덱스를 태우면 쿼리가 더 느려지는 경우가 실제로 흔합니다.
전제부터 바로잡겠습니다. 옵티마이저가 인덱스를 쓰지 않는 것은 대개 옳은 판단입니다. 옳지 않은 소수의 경우는 원인이 정해져 있고, 그 원인들은 실행 계획에 흔적을 남깁니다. 이 글은 그 흔적을 원인별로 읽는 법을 정리합니다.
전제 — 인덱스를 쓰지 않는 것이 대개 옳은 판단이다
비용 기반 옵티마이저는 가능한 계획들의 비용을 계산해서 가장 싼 것을 고릅니다. 인덱스 스캔의 비용에는 인덱스 페이지 읽기와, 매칭된 행마다 힙 페이지를 랜덤하게 방문하는 비용이 포함됩니다. 순차 스캔은 페이지를 물리적 순서대로 읽으므로 프리페치와 읽기 병합의 이득을 봅니다.
그래서 강제로 인덱스를 태워 보면 옵티마이저가 옳았음을 확인하게 되는 일이 많습니다.
-- 옵티마이저의 선택
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE status = 'completed';
Seq Scan on orders (cost=0.00..20117.00 rows=612430 width=88)
(actual time=0.014..184.221 rows=611884 loops=1)
Filter: (status = 'completed')
Rows Removed by Filter: 188116
Buffers: shared hit=1211 read=10906
Execution Time: 231.774 ms
-- 인덱스를 강제한 결과
SET enable_seqscan = off;
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE status = 'completed';
RESET enable_seqscan;
Index Scan using idx_orders_status on orders
(cost=0.42..68914.33 rows=612430 width=88)
(actual time=0.061..1042.883 rows=611884 loops=1)
Index Cond: (status = 'completed')
Buffers: shared hit=8842 read=598112
Execution Time: 1094.201 ms
231ms에서 1094ms로 네 배 이상 느려졌습니다. 읽은 블록 수는 12117에서 606954로 50배가 되었습니다. 76퍼센트의 행을 반환하는 조건에서 인덱스는 손해라는 것을 옵티마이저는 이미 알고 있었습니다.
enable_seqscan = off는 순차 스캔을 금지하는 것이 아니라 비용에 큰 상수를 더할 뿐입니다. 진단 도구로는 훌륭하지만 운영 설정으로는 쓰면 안 됩니다.
선택도 — 인덱스가 오히려 손해가 되는 지점
경계는 어디쯤일까요. 흔히 인용되는 숫자는 5에서 10퍼센트입니다. 하지만 이 숫자만 외우면 판단을 그르칩니다. 실제 경계를 결정하는 변수는 세 가지입니다.
첫째, random_page_cost입니다. 기본값 4.0은 회전 디스크 기준입니다. SSD나 NVMe에서 이 값을 그대로 두면 옵티마이저가 랜덤 접근을 실제보다 네 배 비싸게 평가해서 인덱스를 과소평가합니다.
-- SSD 환경의 현실적인 값
ALTER SYSTEM SET random_page_cost = 1.1;
SELECT pg_reload_conf();
이 한 줄로 계획이 바뀌는 경우가 생각보다 많습니다. 인덱스를 안 탄다는 문의의 상당수는 이 설정 하나로 해결됩니다.
둘째, 물리적 정렬도입니다. 인덱스 순서와 힙 순서가 일치하면 랜덤 접근이 사실상 순차 접근이 됩니다.
SELECT attname, n_distinct, correlation
FROM pg_stats
WHERE tablename = 'orders'
AND attname IN ('created_at', 'user_id', 'status');
attname | n_distinct | correlation
------------+------------+-------------
created_at | -1 | 0.998304
user_id | 48210 | 0.003911
status | 6 | 0.412008
created_at은 상관도가 1에 가까우니 넓은 범위를 조회해도 인덱스가 유리합니다. user_id는 0에 가까우니 같은 선택도라도 훨씬 불리합니다. 같은 5퍼센트라도 컬럼에 따라 답이 다른 이유가 여기 있습니다.
셋째, 반환 컬럼입니다. SELECT *는 반드시 힙을 방문해야 하지만, 필요한 컬럼이 인덱스에 모두 들어 있으면 Index Only Scan이 가능해집니다.
-- INCLUDE로 커버링 인덱스를 만든다 (PostgreSQL 11 이상)
CREATE INDEX CONCURRENTLY idx_orders_status_covering
ON orders (status) INCLUDE (id, total, created_at);
Index Only Scan using idx_orders_status_covering on orders
(actual time=0.038..142.118 rows=611884 loops=1)
Index Cond: (status = 'completed')
Heap Fetches: 1204
Heap Fetches가 작아야 의미가 있습니다. 이 값이 크다면 가시성 맵이 최신이 아니라는 뜻이고, VACUUM이 필요합니다.
sargable하지 않은 조건 — 함수, 암묵적 캐스팅, 선행 와일드카드
인덱스는 컬럼의 값 자체로 정렬되어 있습니다. 컬럼에 무언가를 씌우는 순간 그 정렬은 쓸모없어집니다.
-- 안 탄다: 컬럼에 함수가 걸렸다
SELECT * FROM orders WHERE date(created_at) = '2026-07-26';
-- 탄다: 범위 조건으로 바꿔 컬럼을 그대로 둔다
SELECT * FROM orders
WHERE created_at >= '2026-07-26'
AND created_at < '2026-07-27';
-- 안 탄다: 컬럼 쪽에 연산
SELECT * FROM order_items WHERE price * quantity > 100000;
-- 탄다: 생성 컬럼이나 표현식 인덱스로 옮긴다
CREATE INDEX idx_items_amount ON order_items ((price * quantity));
LOWER(email)도 같은 부류입니다. 다만 여기에는 흔한 오해가 하나 붙습니다. 표현식 인덱스를 만들면 쿼리도 정확히 같은 표현식을 써야 합니다.
CREATE INDEX idx_users_email_lower ON users (lower(email));
-- 탄다
SELECT * FROM users WHERE lower(email) = 'a@example.com';
-- 안 탄다: 표현식이 다르다
SELECT * FROM users WHERE lower(trim(email)) = 'a@example.com';
암묵적 캐스팅은 더 조용히 실패합니다. PostgreSQL은 타입이 안 맞으면 대체로 에러를 내지만, 캐스팅이 가능한 조합에서는 조용히 변환합니다.
-- code 컬럼이 varchar, 인덱스도 varchar 기준
EXPLAIN SELECT * FROM products WHERE code = 12345;
ERROR: operator does not exist: character varying = integer
PostgreSQL은 여기서 에러를 냅니다. 문제는 반대 방향입니다.
-- id는 bigint인데 파라미터가 numeric으로 들어온 경우
EXPLAIN SELECT * FROM orders WHERE id = 1001::numeric;
Seq Scan on orders (cost=0.00..24117.00 rows=4001 width=88)
Filter: ((id)::numeric = '1001'::numeric)
컬럼 쪽이 (id)::numeric으로 변환되었고, 그 순간 bigint 인덱스는 쓸 수 없게 됩니다. 계획에서 컬럼 이름에 :: 캐스팅이 붙어 있다면 이 문제입니다.
MySQL에서는 훨씬 자주 발생합니다. 문자열 컬럼과 숫자를 비교하면 에러 없이 양쪽을 숫자로 변환하고, 컬럼 쪽이 변환되므로 인덱스가 죽습니다. 게다가 조인하는 두 컬럼의 콜레이션이 다르면 같은 일이 벌어집니다. utf8mb4_general_ci와 utf8mb4_unicode_ci 컬럼을 조인해 놓고 왜 느린지 몰라 헤매는 사례가 아주 흔합니다.
LIKE의 선행 와일드카드도 같은 원리입니다.
-- 탄다: 접두사가 고정되어 인덱스 범위 탐색이 가능하다
SELECT * FROM users WHERE name LIKE 'kim%';
-- 안 탄다: 시작점을 모르므로 전체를 봐야 한다
SELECT * FROM users WHERE name LIKE '%kim%';
여기서 흔한 오답이 "그러니 전문 검색 엔진을 도입하자"입니다. 그 전에 PostgreSQL 안에서 해결되는 경우가 많습니다.
-- pg_trgm: 부분 문자열 검색을 인덱스로 처리한다
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_users_name_trgm
ON users USING gin (name gin_trgm_ops);
EXPLAIN ANALYZE SELECT * FROM users WHERE name LIKE '%kim%';
Bitmap Heap Scan on users (actual time=2.114..8.902 rows=412 loops=1)
Recheck Cond: (name ~~ '%kim%'::text)
-> Bitmap Index Scan on idx_users_name_trgm (actual time=1.884..1.884 rows=498 loops=1)
Index Cond: (name ~~ '%kim%'::text)
C 이외의 로케일에서는 접두사 LIKE조차 기본 B-Tree 인덱스를 못 쓰는 경우가 있습니다. 이때는 text_pattern_ops 연산자 클래스로 보조 인덱스를 만듭니다.
CREATE INDEX idx_users_name_prefix
ON users (name text_pattern_ops);
복합 인덱스의 선두 컬럼 규칙, OR, NULL
복합 인덱스 (a, b, c)는 a로 정렬하고, a가 같은 것끼리 b로, b도 같으면 c로 정렬한 구조입니다. 전화번호부를 성으로 정렬하고 같은 성 안에서 이름으로 정렬한 것과 같습니다. 성을 모르는 채로 이름만 가지고는 찾을 수 없습니다.
CREATE INDEX idx_orders_multi ON orders (user_id, status, created_at);
WHERE user_id = 1— 탐색 가능WHERE user_id = 1 AND status = 'paid'— 탐색 가능WHERE user_id = 1 AND created_at >= '2026-07-01'— user_id로만 좁히고 created_at은 필터로 처리WHERE status = 'paid'— 선두 컬럼이 없어 탐색 불가
마지막 경우에도 PostgreSQL은 인덱스를 아예 못 쓰는 것은 아닙니다. 테이블보다 인덱스가 훨씬 작다면 Index Only Scan으로 인덱스 전체를 훑는 계획을 고를 수 있습니다. 다만 이것은 탐색이 아니라 전체 스캔이므로 기대하는 성능은 나오지 않습니다.
컬럼 순서를 정하는 원칙은 명확합니다. 등호 조건 컬럼을 앞에, 범위 조건 컬럼을 뒤에 놓습니다. 범위 조건이 걸린 컬럼 뒤의 컬럼들은 탐색에 쓰이지 못하고 필터로만 동작하기 때문입니다.
OR 조건은 조금 다릅니다. 각 항에 인덱스가 있으면 PostgreSQL은 비트맵으로 결합할 수 있습니다.
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'a@b.com' OR phone = '01012345678';
Bitmap Heap Scan on users (actual time=0.061..0.064 rows=2 loops=1)
Recheck Cond: ((email = 'a@b.com') OR (phone = '01012345678'))
-> BitmapOr (actual time=0.052..0.052 rows=0 loops=1)
-> Bitmap Index Scan on idx_users_email (actual time=0.031..0.031 rows=1 loops=1)
-> Bitmap Index Scan on idx_users_phone (actual time=0.019..0.019 rows=1 loops=1)
BitmapOr 노드가 보인다면 잘 처리된 것입니다. 문제는 OR의 한쪽 항에만 인덱스가 없을 때입니다. 그 순간 전체가 순차 스캔으로 떨어집니다. 한 항이라도 인덱스가 없으면 나머지 인덱스는 무용지물이라는 점을 기억하십시오. 이럴 때는 UNION ALL로 쪼개는 것이 실제로 도움이 됩니다.
NULL도 자주 오해받습니다. "인덱스는 NULL을 담지 않는다"는 말이 널리 퍼져 있는데, 이건 Oracle의 B-Tree 인덱스 이야기입니다. PostgreSQL의 B-Tree는 NULL을 저장하므로 IS NULL 조건도 인덱스로 처리됩니다.
EXPLAIN ANALYZE
SELECT * FROM orders WHERE cancelled_at IS NULL;
Index Scan using idx_orders_cancelled_at on orders
(actual time=0.022..1.884 rows=304 loops=1)
Index Cond: (cancelled_at IS NULL)
다만 IS NOT NULL이 테이블의 대부분을 반환한다면 앞서 다룬 선택도 문제로 돌아갑니다.
통계가 낡으면 옵티마이저는 정확하게 틀린다
대량 적재나 대량 삭제 직후에 계획이 이상해지는 것은 거의 항상 통계 문제입니다. 옵티마이저는 통계를 근거로 정확하게 계산하지만, 그 통계가 현실과 다르면 결과도 현실과 다릅니다.
-- 마지막 ANALYZE 시점과 이후 변경량 확인
SELECT relname,
n_live_tup,
n_mod_since_analyze,
last_analyze,
last_autoanalyze
FROM pg_stat_user_tables
WHERE relname = 'orders';
relname | n_live_tup | n_mod_since_analyze | last_analyze | last_autoanalyze
---------+------------+---------------------+---------------------+------------------
orders | 804112 | 611903 | 2026-07-19 03:12:44 | 2026-07-19 03:12:44
80만 행 테이블에 61만 건이 변경되었는데 통계는 일주일 전 것입니다. autovacuum의 기본 analyze_scale_factor는 0.1, 즉 10퍼센트가 바뀌어야 도는데 대형 테이블에서는 이 임계값이 너무 큽니다.
-- 큰 테이블은 테이블 단위로 임계값을 낮춘다
ALTER TABLE orders SET (
autovacuum_analyze_scale_factor = 0.01,
autovacuum_analyze_threshold = 5000
);
-- 배치 적재 직후에는 명시적으로 갱신한다
ANALYZE orders;
주의할 점 하나. 대량 적재를 하는 배치 파이프라인이라면 적재가 끝난 뒤 ANALYZE를 호출하는 것이 autovacuum을 기다리는 것보다 항상 낫습니다. autovacuum은 적재 직후 몇 분 동안 돌지 않을 수 있고, 그 사이에 들어온 조회 쿼리가 잘못된 계획으로 실행됩니다.
부분 인덱스가 답인 경우, 그리고 인덱스의 반대편 비용
지금까지의 원인들을 한 장으로 정리하면 이렇습니다.
| 증상 (계획에서 보이는 것) | 원인 | 해법 |
|---|---|---|
| Seq Scan에 큰 Rows Removed by Filter | 선택도가 낮음 | 부분 인덱스, 커버링 인덱스, random_page_cost 조정 |
| Filter에 함수 호출이 보임 | sargable하지 않은 조건 | 범위 조건으로 재작성 또는 표현식 인덱스 |
| 컬럼에 캐스팅 표기가 붙어 있음 | 타입 또는 콜레이션 불일치 | 파라미터 타입을 컬럼에 맞춤 |
| Filter에 LIKE 선행 와일드카드 | 접두사 고정 불가 | pg_trgm GIN 인덱스 |
| 선두 컬럼 없는 복합 인덱스 조건 | 선두 컬럼 규칙 | 컬럼 순서 재설계 또는 별도 인덱스 |
| 예상 rows와 실제 rows가 크게 다름 | 통계 노후화 또는 컬럼 상관관계 | ANALYZE, STATISTICS 상향, 확장 통계 |
부분 인덱스는 이 중 여러 문제를 한 번에 푸는 도구입니다. 특히 전체의 몇 퍼센트만 조회 대상인 상태 컬럼에 강력합니다.
-- 미처리 주문만 조회하는 워크로드
CREATE INDEX CONCURRENTLY idx_orders_pending
ON orders (created_at)
WHERE status = 'pending';
-- 크기 비교
SELECT indexrelname,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE relname = 'orders';
indexrelname | size
-------------------------+---------
idx_orders_status | 42 MB
idx_orders_pending | 312 kB
42MB짜리 인덱스가 312KB가 되었습니다. 크기만 줄어드는 것이 아니라 쓰기 비용도 줄어듭니다. 조건에 맞지 않는 행의 INSERT와 UPDATE는 이 인덱스를 건드리지 않기 때문입니다.
여기서 인덱스의 반대편 비용을 짚고 넘어가야 합니다. 인덱스는 공짜가 아닙니다.
- 모든 INSERT와 DELETE는 해당 테이블의 모든 인덱스를 갱신합니다.
- UPDATE는 변경 컬럼이 인덱스에 없으면 HOT 갱신으로 인덱스를 건너뛸 수 있지만, 인덱스가 많아 페이지에 여유 공간이 없으면 HOT이 깨집니다.
fillfactor를 낮춰 여유를 두는 것이 도움이 됩니다. - 인덱스가 늘면 계획 후보가 늘어 계획 수립 시간도 늘어납니다.
그래서 주기적으로 쓰지 않는 인덱스를 걷어내야 합니다.
SELECT s.relname AS table_name,
s.indexrelname AS index_name,
s.idx_scan,
pg_size_pretty(pg_relation_size(s.indexrelid)) AS size
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE s.idx_scan = 0
AND NOT i.indisunique
AND NOT i.indisprimary
ORDER BY pg_relation_size(s.indexrelid) DESC;
table_name | index_name | idx_scan | size
------------+----------------------------+----------+--------
orders | idx_orders_updated_at | 0 | 88 MB
events | idx_events_legacy_type | 0 | 41 MB
두 가지를 확인하고 지우십시오. 첫째, pg_stat_user_indexes는 마지막 통계 초기화 이후의 누적값이므로 언제 초기화되었는지 확인해야 합니다. 월말 배치에서만 쓰는 인덱스를 월초에 판단하면 안 됩니다. 둘째, 레플리카에서 쓰이는 인덱스는 프라이머리 통계에 잡히지 않습니다. 레플리카에서도 같은 쿼리를 돌려 확인해야 합니다.
-- 통계 수집 시작 시점 확인
SELECT stats_reset FROM pg_stat_database WHERE datname = current_database();
-- 삭제 대신 계획에서만 빼고 관찰하고 싶다면 (카탈로그 직접 수정, 운영 권장하지 않음)
UPDATE pg_index SET indisvalid = false
WHERE indexrelid = 'idx_orders_updated_at'::regclass;
PostgreSQL에는 인덱스를 안전하게 비활성화하는 공식 명령이 없습니다. 위 방법은 카탈로그를 직접 건드리는 것이라 운영에서는 피해야 합니다. 현실적인 절차는 지우기 전에 인덱스 정의를 텍스트로 남겨 두고, 문제가 생기면 그대로 다시 만드는 것입니다.
SELECT indexdef FROM pg_indexes WHERE indexname = 'idx_orders_updated_at';
DROP INDEX CONCURRENTLY idx_orders_updated_at;
마치며 — 인덱스를 의심하기 전에 계획을 읽으세요
인덱스가 안 탄다는 문제는 사실 두 종류입니다. 옵티마이저가 옳아서 안 타는 경우와, 내가 쓴 쿼리가 인덱스를 쓸 수 없는 형태여서 못 타는 경우입니다. 이 둘은 대응이 정반대입니다. 전자는 인덱스를 바꾸거나 포기해야 하고, 후자는 쿼리를 고쳐야 합니다.
구분하는 방법은 간단합니다. 실행 계획의 Filter 줄을 보십시오. 조건이 원문 그대로 적혀 있다면 선택도 문제입니다. 조건에 함수나 캐스팅이 씌워져 있다면 쿼리 문제입니다. 그리고 예상 rows와 실제 rows가 크게 다르다면 통계 문제입니다.
세 가지 중 어느 것도 enable_seqscan = off로는 고쳐지지 않습니다. 강제 옵션은 가설을 검증할 때만 쓰고, 검증이 끝나면 원래 원인으로 돌아가십시오.