
인덱스를 만들었는데 Seq Scan이 그대로 뜨는 상황 때문에 이 글을 검색하셨다면, 답은 대부분 “인덱스가 안 걸려서”가 아니라 “플래너가 비용 계산상 인덱스 쪽이 더 비싸다고 판단해서”입니다. PostgreSQL의 쿼리 플래너는 인덱스 존재 여부와 상관없이 통계 정보, 테이블 크기, 선택도를 종합해 더 저렴한 경로를 고릅니다. 그래서 인덱스를 지우는 것보다 EXPLAIN으로 플래너가 어떤 수치를 보고 있는지 먼저 확인하는 쪽이 훨씬 빠릅니다.
인덱스가 있는데도 Seq Scan이 뜨는 이유는 무엇인가요?
PostgreSQL은 규칙 기반이 아니라 비용 기반(cost-based) 옵티마이저를 씁니다. 인덱스가 있어도 그 인덱스를 타는 비용이 테이블 전체를 순차적으로 읽는 비용보다 높다고 판단되면 바로 Seq Scan을 선택합니다.
비용은 seq_page_cost, random_page_cost, cpu_tuple_cost 같은 설정값과 테이블 통계(행 수, 블록 수, 값 분포)를 조합해 계산됩니다. 조건에 걸리는 행이 테이블의 5~10%만 넘어가도 인덱스 스캔보다 순차 스캔이 더 싸게 나오는 경우가 흔합니다.
EXPLAIN ANALYZE로 플래너의 선택 근거 확인하기
가장 먼저 해야 할 일은 추측 대신 실제 실행 계획을 보는 것입니다. EXPLAIN (ANALYZE, BUFFERS)를 쓰면 추정치와 실제 수행 결과를 동시에 볼 수 있습니다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE customer_id = 1024;
PostgreSQL 15 환경에서 실제로 돌리면 다음과 비슷한 출력이 나옵니다.

Seq Scan on orders (cost=0.00..18334.00 rows=620 width=72)
(actual time=0.015..142.331 rows=615 loops=1)
Filter: (customer_id = 1024)
Rows Removed by Filter: 999385
Buffers: shared hit=18334
Planning Time: 0.112 ms
Execution Time: 142.389 ms
여기서 rows=620(추정)과 rows=615(실제)가 비슷하면 통계는 정확한데 비용 계산 자체가 Seq Scan을 더 싸다고 본 것입니다. 반대로 추정과 실제가 10배 이상 벌어지면 통계가 낡았다는 신호입니다. 이 실행 계획 읽는 법은 PostgreSQL 공식 문서에 각 필드 의미가 정리돼 있습니다.
통계 정보가 오래되면 플래너가 인덱스를 외면합니다
대량 insert나 업데이트 직후인데 ANALYZE를 돌리지 않았다면 pg_stats에 남아 있는 값 분포가 실제와 다릅니다. 이러면 플래너는 조건에 맞는 행이 실제보다 훨씬 많다고 추정해서 인덱스를 포기하고 Seq Scan을 고릅니다.
SELECT attname, n_distinct, most_common_vals
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'customer_id';
이 값이 현실과 다르면 ANALYZE orders;를 수동으로 실행한 뒤 같은 쿼리를 다시 EXPLAIN 해보시면 됩니다. autovacuum이 켜져 있어도 autovacuum_analyze_scale_factor(기본 0.1) 기준을 채우기 전에는 통계가 갱신되지 않으므로, 대량 작업 직후에는 통계가 한동안 낡아 있을 수 있습니다.
아래는 같은 조건에서 통계 상태에 따라 플래너가 어떤 경로를 고르는지 정리한 표입니다.
| 상황 | 추정 선택도 | 플래너의 선택 |
|---|---|---|
| 통계 최신, 조건에 맞는 행 비율 낮음(1% 이하) | 낮음 | Index Scan |
| 통계 최신, 조건에 맞는 행 비율 높음(10% 이상) | 높음 | Seq Scan |
| 통계 낡음(대량 변경 후 ANALYZE 안 함) | 부정확 | 예측 불가, 보통 Seq Scan |
| 테이블 자체가 작음(수백~수천 행) | 상관없음 | Seq Scan (정상) |
이 순서로 원인을 좁혀 보세요
EXPLAIN (ANALYZE, BUFFERS)로 추정 rows와 실제 rows 차이를 먼저 봅니다. 차이가 크면 통계 문제이고, 비슷한데도 Seq Scan이면 비용 모델의 정상 선택입니다.SELECT count(*) FROM 테이블명;과 조건에 맞는 행 수를 비교해 선택도를 직접 계산합니다. 조건이 전체의 상당 부분을 걸러낸다면 인덱스를 써도 느립니다.random_page_cost를 확인합니다. 기본값 4는 회전식 디스크 기준이고, SSD 환경이면 이 값이 너무 높게 잡혀 인덱스 스캔 비용이 과대평가됩니다.SET random_page_cost = 1.1;로 세션 단위 테스트가 가능합니다.- 일시적으로
SET enable_seqscan = off;를 걸고 같은 쿼리를 돌려 인덱스 스캔 비용과 실제 시간을 비교합니다. 이 값이 더 느리다면 플래너 판단이 맞는 것이므로 설정을 되돌리고 다른 원인을 찾아야 합니다.
이 과정에서 플래너가 비용을 매기는 방식 자체는 질의 최적화라는 데이터베이스 이론의 한 영역에 속하는데, 선택도와 비용 추정 개념을 더 깊이 보고 싶으시면 질의 최적화 쪽 설명이 도움이 됩니다.
작은 테이블인데 왜 인덱스를 안 쓰나요?
행이 몇백~몇천 개 수준인 테이블은 대부분 블록 하나에서 서너 블록 안에 다 들어갑니다. 이 경우 인덱스를 거쳐 힙을 다시 찾아가는 비용이 테이블을 통째로 읽는 비용보다 오히려 더 큽니다.
이건 버그가 아니라 정상적인 선택입니다. 억지로 인덱스를 타게 만들어도 실행 시간이 줄지 않거나 오히려 늘어나는 경우가 대부분이라, 이런 테이블에서는 Seq Scan을 그대로 두는 쪽이 맞습니다.
인덱스 설계보다 통계와 비용 모델을 먼저 보셔야 합니다
인덱스를 추가했는데도 Seq Scan이 나온다면 먼저 EXPLAIN (ANALYZE, BUFFERS)로 추정치와 실제 수행 결과의 차이를 확인하시고, 차이가 크면 ANALYZE로 통계를 갱신하시는 게 첫 단계입니다. 통계가 정확한데도 Seq Scan이 나온다면 조건의 선택도가 낮거나 random_page_cost가 환경(SSD/HDD)에 안 맞게 설정된 경우가 많습니다.
이 글의 내용은 PostgreSQL 15 기준이며, 버전에 따라 플래너 동작과 enable_* 파라미터 기본값이 다를 수 있으니 운영 중인 버전의 공식 문서로 다시 확인하시는 걸 권합니다. 다음 단계로는 pg_stat_user_tables에서 last_analyze 시점을 확인하고, 필요하면 해당 테이블의 default_statistics_target을 개별적으로 올려보시는 걸 추천합니다.
