- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 느린 쿼리를 찾는 것부터 시작한다
- 인덱스 유형별 선택 기준
- EXPLAIN ANALYZE 읽는 법: 실전 시나리오
- Partial Index와 Covering Index 활용
- hypopg로 인덱스 가상 실험
- 사용하지 않는 인덱스 탐지와 정리
- VACUUM과 인덱스 성능의 관계
- 인덱스 Bloat 측정과 REINDEX
- PostgreSQL 17 인덱스 관련 변경사항
- 트러블슈팅: 실제 에러와 대응
- 인덱스 튜닝 점검 체크리스트
- 퀴즈
- 참고 자료

느린 쿼리를 찾는 것부터 시작한다
인덱스 튜닝의 첫 단계는 인덱스를 만드는 것이 아니라, 어떤 쿼리가 문제인지 정량적으로 파악하는 것이다. PostgreSQL 17/18 환경에서 가장 신뢰할 수 있는 도구는 pg_stat_statements다.
-- pg_stat_statements 확장 활성화 (postgresql.conf 또는 ALTER SYSTEM)
ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements';
-- 변경 후 PostgreSQL 재시작 필요
-- 상위 20개 느린 쿼리: 총 실행시간 기준 정렬
SELECT
queryid,
substring(query, 1, 80) AS query_preview,
calls,
round(total_exec_time::numeric, 2) AS total_ms,
round(mean_exec_time::numeric, 2) AS mean_ms,
round((stddev_exec_time)::numeric, 2) AS stddev_ms,
rows,
round((shared_blks_hit * 100.0 /
NULLIF(shared_blks_hit + shared_blks_read, 0))::numeric, 1) AS cache_hit_pct
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
이 쿼리에서 주목할 컬럼은 세 가지다.
- mean_ms: 평균 실행시간이 50ms를 넘으면 OLTP 워크로드에서 위험 신호다.
- stddev_ms: 표준편차가 평균보다 크면 실행 계획이 불안정하다는 의미다. 통계 갱신 누락이나 파라미터 스니핑을 의심할 수 있다.
- cache_hit_pct: 99% 미만이면 shared_buffers 크기를 점검하거나 인덱스가 테이블보다 넓지 않은지 확인한다.
인덱스 유형별 선택 기준
PostgreSQL은 B-tree, Hash, GiST, SP-GiST, GIN, BRIN 6가지 인덱스 유형을 제공한다. 실무에서 90% 이상은 B-tree와 GIN 사이에서 결정된다.
| 인덱스 유형 | 최적 사용 사례 | 쓰기 비용 | 크기 | 주의사항 |
|---|---|---|---|---|
| B-tree | 등호/범위 검색, ORDER BY, UNIQUE 제약 | 낮음 | 보통 | 기본값. 대부분의 경우 최선 |
| GIN | 배열, JSONB, 전문 검색(tsvector) | 높음 | 큼 | fastupdate=off 시 쓰기 지연 감소 |
| BRIN | 시계열 데이터, append-only 테이블 | 매우 낮음 | 매우 작음 | 물리 순서와 논리 순서가 일치해야 효과적 |
| GiST | 지리 데이터, 범위 타입, 근접 검색 | 보통 | 보통 | PostGIS와 함께 사용 |
| Hash | 순수 등호 검색만 사용하는 경우 | 낮음 | 작음 | PG 10 이후 WAL 지원. 범위 검색 불가 |
복합 인덱스 컬럼 순서 결정법
복합 인덱스의 컬럼 순서는 WHERE 절의 선택도(selectivity)에 따라 결정한다. 선택도가 높은 컬럼(고유 값이 많은 컬럼)을 앞에 배치하는 것이 일반적이지만, 쿼리 패턴에 따라 다르다.
-- 컬럼별 선택도 확인
SELECT
attname,
n_distinct,
most_common_vals,
most_common_freqs
FROM pg_stats
WHERE tablename = 'orders'
AND attname IN ('status', 'user_id', 'created_at');
-- 결과 예시:
-- status | n_distinct = 5 (선택도 낮음 - 뒤에 배치)
-- user_id | n_distinct = 50000 (선택도 높음 - 앞에 배치)
-- created_at | n_distinct = -0.95 (거의 유일 - 범위 검색이면 마지막)
이 통계를 기반으로 인덱스를 설계한다.
-- 패턴 1: user_id 등호 + created_at 범위 (가장 흔한 OLTP 패턴)
CREATE INDEX CONCURRENTLY idx_orders_user_created
ON orders (user_id, created_at DESC);
-- 패턴 2: status 필터 + user_id 검색 (status 카디널리티가 낮을 때)
-- Partial index가 복합 인덱스보다 효율적
CREATE INDEX CONCURRENTLY idx_orders_pending_user
ON orders (user_id)
WHERE status = 'pending';
-- 패턴 3: JSONB 필드 내부 검색
CREATE INDEX CONCURRENTLY idx_orders_metadata
ON orders USING gin (metadata jsonb_path_ops);
EXPLAIN ANALYZE 읽는 법: 실전 시나리오
EXPLAIN 출력을 읽을 때 가장 중요한 것은 "예상 비용"과 "실제 시간"의 괴리다.
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.id, o.total_amount, u.email
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.user_id = 42
AND o.created_at >= '2026-02-01'
AND o.created_at < '2026-03-01'
ORDER BY o.created_at DESC
LIMIT 50;
실행 계획 결과 읽기 예시:
Limit (cost=0.56..125.43 rows=50 width=52)
(actual time=0.089..0.342 rows=50 loops=1)
-> Nested Loop (cost=0.56..4521.12 rows=1812 width=52)
(actual time=0.087..0.334 rows=50 loops=1)
-> Index Scan using idx_orders_user_created on orders o
(cost=0.43..3890.21 rows=1812 width=28)
(actual time=0.071..0.205 rows=50 loops=1)
Index Cond: ((user_id = 42) AND (created_at >= ...))
Buffers: shared hit=12
-> Index Scan using users_pkey on users u
(cost=0.13..0.35 rows=1 width=24)
(actual time=0.002..0.002 rows=1 loops=50)
Index Cond: (id = o.user_id)
Buffers: shared hit=100
Planning Time: 0.245 ms
Execution Time: 0.398 ms
확인해야 할 포인트:
- Buffers: shared hit vs shared read:
shared hit는 캐시 적중,shared read는 디스크 접근이다. read가 많으면 shared_buffers 부족이나 인덱스 비효율을 의심한다. - rows 추정치 vs 실제:
rows=1812로 추정했지만 LIMIT 덕분에 50건만 실제 스캔했다. 추정치가 10배 이상 차이나면ANALYZE재실행이 필요하다. - loops 값: Nested Loop의 inner 측에서
loops=50이면 50번 반복 실행된 것이다. 표시된 시간에 loops를 곱해야 실제 총 시간이다.
Partial Index와 Covering Index 활용
Partial Index: 데이터의 일부만 인덱싱
전체 테이블이 1억 건이지만 status = 'pending'인 행이 0.1%만 있다면, 전체 인덱스를 만드는 것은 낭비다.
-- 나쁜 예: 전체 인덱스 (1억 건 모두 인덱싱)
CREATE INDEX idx_orders_status ON orders (status, created_at);
-- 인덱스 크기: ~2.1GB
-- 좋은 예: Partial index (10만 건만 인덱싱)
CREATE INDEX CONCURRENTLY idx_orders_pending
ON orders (created_at DESC)
WHERE status = 'pending';
-- 인덱스 크기: ~2.4MB (875배 작음)
Covering Index (INCLUDE): Index-Only Scan 유도
PostgreSQL 11부터 INCLUDE 절을 지원한다. 검색 조건이 아니지만 SELECT에서 필요한 컬럼을 인덱스에 포함시켜 테이블 힙 접근을 제거한다.
-- user_id로 검색하고 email, total_amount를 반환하는 쿼리가 빈번할 때
CREATE INDEX CONCURRENTLY idx_orders_user_covering
ON orders (user_id, created_at DESC)
INCLUDE (total_amount, status);
-- 이 인덱스로 Index Only Scan이 가능해진다
-- 단, visibility map이 갱신되어야 하므로 VACUUM이 중요
hypopg로 인덱스 가상 실험
프로덕션에 인덱스를 만들기 전에 hypopg 확장으로 가상 인덱스를 생성하고 EXPLAIN으로 효과를 검증할 수 있다. 실제 디스크 공간도 사용하지 않고, 테이블 잠금도 걸리지 않는다.
-- hypopg 설치
CREATE EXTENSION IF NOT EXISTS hypopg;
-- 가상 인덱스 생성
SELECT * FROM hypopg_create_index(
'CREATE INDEX ON orders (user_id, created_at DESC) INCLUDE (total_amount)'
);
-- 결과: indexrelid = 14356, indexname = '<14356>btree_orders_user_id_created_at'
-- 가상 인덱스가 적용된 EXPLAIN 확인
EXPLAIN SELECT user_id, created_at, total_amount
FROM orders
WHERE user_id = 42
AND created_at >= '2026-02-01'
ORDER BY created_at DESC;
-- Index Only Scan using <14356>btree_orders_user_id_created_at
-- 가상 인덱스 제거
SELECT hypopg_drop_index(14356);
-- 모든 가상 인덱스 한번에 정리
SELECT hypopg_reset();
실제 인덱스 생성은 가상 실험에서 비용이 줄어드는 것을 확인한 후에만 진행한다.
사용하지 않는 인덱스 탐지와 정리
인덱스는 읽기 성능을 올리지만 쓰기 성능을 떨어뜨린다. INSERT/UPDATE/DELETE 시마다 모든 관련 인덱스가 갱신되므로, 사용하지 않는 인덱스는 순수 비용이다.
-- 30일 이상 사용되지 않은 인덱스 탐지
-- (pg_stat_user_indexes는 마지막 통계 초기화 이후 누적)
SELECT
schemaname,
relname AS table_name,
indexrelname AS index_name,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan AS scan_count,
idx_tup_read AS tuples_read,
idx_tup_fetch AS tuples_fetched
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexrelid NOT IN (
SELECT indexrelid FROM pg_index WHERE indisunique
)
ORDER BY pg_relation_size(indexrelid) DESC;
주의사항:
- UNIQUE 인덱스는 제외한다: 제약 조건 역할을 하므로 scan이 0이어도 삭제하면 안 된다.
- 통계를 초기화한 직후에는 판단하지 않는다:
pg_stat_reset()이후 최소 4주는 데이터를 수집한다. - 월말/분기말 배치를 고려한다: 특정 시기에만 사용되는 인덱스가 있을 수 있다.
-- 인덱스 삭제 전 반드시 CONCURRENTLY로 시도
-- (일반 DROP INDEX는 테이블에 ACCESS EXCLUSIVE 잠금을 건다)
DROP INDEX CONCURRENTLY IF EXISTS idx_orders_old_status;
VACUUM과 인덱스 성능의 관계
Dead tuple이 쌓이면 인덱스 성능이 급격히 저하된다. Index-Only Scan은 visibility map에 의존하는데, VACUUM이 지연되면 visibility map이 갱신되지 않아 힙 접근이 발생한다.
-- 테이블별 dead tuple 비율과 마지막 vacuum 시점 확인
SELECT
schemaname,
relname,
n_live_tup,
n_dead_tup,
round(n_dead_tup * 100.0 / NULLIF(n_live_tup + n_dead_tup, 0), 2)
AS dead_pct,
last_vacuum,
last_autovacuum,
last_analyze,
last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;
dead tuple 비율이 20%를 넘으면 autovacuum 설정을 점검해야 한다.
-- 대용량 테이블에 대한 autovacuum 개별 튜닝
ALTER TABLE orders SET (
autovacuum_vacuum_threshold = 1000,
autovacuum_vacuum_scale_factor = 0.01, -- 기본값 0.2 대비 20배 민감
autovacuum_analyze_threshold = 500,
autovacuum_analyze_scale_factor = 0.005,
autovacuum_vacuum_cost_delay = 2 -- 기본 2ms, 0으로 하면 최대 속도
);
인덱스 Bloat 측정과 REINDEX
오랫동안 운영된 인덱스는 page split과 삭제로 인해 bloat가 발생한다. PostgreSQL 17에서는 B-tree 인덱스의 deduplication이 기본 활성화되어 bloat가 줄어들었지만, 정기 점검은 여전히 필요하다.
-- pgstattuple 확장으로 인덱스 bloat 측정
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT
indexrelid::regclass AS index_name,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
round(100 - (avg_leaf_density)::numeric, 2) AS bloat_pct
FROM pg_stat_user_indexes,
LATERAL pgstatindex(indexrelid::regclass::text) AS s
WHERE pg_relation_size(indexrelid) > 100 * 1024 * 1024 -- 100MB 이상만
ORDER BY bloat_pct DESC
LIMIT 10;
bloat가 30%를 초과하면 REINDEX를 고려한다.
-- PostgreSQL 14+ : REINDEX CONCURRENTLY (서비스 중단 없이)
REINDEX INDEX CONCURRENTLY idx_orders_user_created;
-- PostgreSQL 12+ : 전체 테이블의 인덱스를 한번에
REINDEX TABLE CONCURRENTLY orders;
PostgreSQL 17 인덱스 관련 변경사항
PostgreSQL 17에서 인덱스 튜닝에 영향을 주는 주요 변경사항:
- B-tree multi-value lookup 개선:
IN (val1, val2, ..., valN)쿼리에서 같은 leaf page에 있는 값들을 한번의 스캔으로 조회한다. 기존에는 N번 개별 탐색이 필요했다. - streaming I/O: Sequential scan과 VACUUM에서 Read Stream API를 사용하여 여러 버퍼를 한번에 읽는다.
effective_io_concurrency파라미터가 더 큰 영향을 미치게 되었다. - VACUUM 메모리 관리 개선: dead tuple 추적에 필요한 메모리가 최대 20배 감소하여
maintenance_work_mem을 더 효율적으로 사용한다. - WAL 동시 처리량 2배 향상: 높은 동시성 쓰기 환경에서 인덱스 유지 비용이 줄어들었다.
-- PostgreSQL 17에서 효과가 커진 설정
ALTER SYSTEM SET effective_io_concurrency = 200; -- NVMe SSD라면 200
ALTER SYSTEM SET maintenance_io_concurrency = 100; -- VACUUM/CREATE INDEX용
ALTER SYSTEM SET huge_pages = try; -- 대용량 shared_buffers 시 TLB miss 감소
SELECT pg_reload_conf();
트러블슈팅: 실제 에러와 대응
시나리오 1: 인덱스가 있는데 Seq Scan을 탄다
-- 문제 쿼리
EXPLAIN SELECT * FROM orders WHERE created_at::date = '2026-03-01';
-- 결과: Seq Scan on orders (함수 적용으로 인덱스 무력화)
-- 해결: 함수 사용 제거, 범위 조건으로 변경
EXPLAIN SELECT * FROM orders
WHERE created_at >= '2026-03-01'
AND created_at < '2026-03-02';
-- 결과: Index Scan using idx_orders_created_at
다른 원인들:
enable_indexscan = off설정 확인 (SHOW enable_indexscan;)- 통계가 오래됨:
ANALYZE orders;실행 - 테이블이 너무 작아 Seq Scan이 더 빠른 경우 (정상)
시나리오 2: CREATE INDEX CONCURRENTLY 실패
ERROR: deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 67890;
blocked by process 23456.
CONCURRENTLY 인덱스 생성은 2단계 테이블 스캔을 하는데, 그 사이에 long-running transaction이 있으면 실패한다.
-- long-running transaction 확인
SELECT pid, now() - xact_start AS duration, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND now() - xact_start > interval '5 minutes'
ORDER BY duration DESC;
-- 필요시 해당 세션 종료
SELECT pg_terminate_backend(12345);
-- 실패한 INVALID 인덱스 정리
SELECT indexrelid::regclass, indisvalid
FROM pg_index
WHERE NOT indisvalid;
DROP INDEX CONCURRENTLY idx_orders_failed;
시나리오 3: 업그레이드 후 쿼리 플랜 변경으로 성능 저하
PostgreSQL 메이저 버전 업그레이드 시 planner 통계가 초기화되고 cost 모델이 변경된다. PostgreSQL 18에서는 통계를 보존하는 기능이 추가되었지만, 17 이하에서는 수동 대응이 필요하다.
# 업그레이드 직후 전체 데이터베이스 통계 재수집
vacuumdb --analyze-in-stages --all --jobs=4
# 1단계: 최소 통계 수집 (default_statistics_target = 1)
# 2단계: 기본 통계 수집 (default_statistics_target = 10)
# 3단계: 전체 통계 수집 (default_statistics_target = 현재 설정값)
인덱스 튜닝 점검 체크리스트
프로덕션 인덱스 튜닝 시 반드시 확인할 항목:
-
pg_stat_statements로 상위 20개 느린 쿼리 식별 완료 - 각 대상 쿼리에
EXPLAIN (ANALYZE, BUFFERS)실행 및 병목 파악 - 신규 인덱스 후보를
hypopg로 가상 검증 - Partial index 적용 가능한 조건(status 필터 등) 확인
- 사용하지 않는 인덱스 목록 작성 및 삭제 계획 수립
- 인덱스 bloat 30% 이상인 항목에 REINDEX CONCURRENTLY 예약
- autovacuum 설정이 대용량 테이블에 적합한지 검토
-
CREATE INDEX CONCURRENTLY사용으로 서비스 무중단 보장 - 업그레이드 전 핵심 쿼리 실행 계획 스냅샷 저장
- 롤백 시나리오 문서화 (인덱스 삭제 -> 성능 저하 시 복구 계획)
퀴즈
Q1. pg_stat_statements에서 mean_exec_time과 stddev_exec_time이 각각 30ms, 150ms일 때 무엇을
의심해야 하는가?
정답: ||표준편차가 평균의 5배이므로 실행 계획이 불안정하다. 파라미터에 따라 다른 플랜이 선택되거나(generic plan vs custom plan), 통계가 stale해서 planner 추정치가 실제와 크게 다를 가능성이 높다.||
Q2. Partial index가 일반 복합 인덱스보다 유리한 조건은?
정답: ||WHERE 조건의 특정 값이 전체 데이터의 소수(예: 5% 미만)를 차지할 때 Partial index가
유리하다. 인덱스 크기가 극적으로 줄어들어 캐시 효율이 높아지고 쓰기 부하도 감소한다.||
Q3. INCLUDE 절을 사용한 Covering index가 Index-Only Scan을 보장하지 못하는 경우는?
정답: ||visibility map이 갱신되지 않은 경우다. VACUUM이 지연되어 해당 페이지가 all-visible로 표시되지 않으면 PostgreSQL은 힙을 직접 확인해야 하므로 Index-Only Scan이 아닌 Index Scan으로 전환된다.||
Q4. CREATE INDEX CONCURRENTLY가 일반 CREATE INDEX보다 느린 이유는?
정답: ||CONCURRENTLY는 테이블을 2번 스캔한다. 첫 번째 스캔으로 인덱스를 구축하고, 두 번째 스캔으로
첫 번째 스캔 이후 변경된 행들을 반영한다. 또한 ShareUpdateExclusiveLock만 잡으므로 다른
트랜잭션과의 충돌을 감시해야 한다.||
Q5. PostgreSQL 17에서 IN 절 쿼리의 B-tree 스캔이 개선된 원리는?
정답: ||같은 leaf page에 존재하는 여러 값을 한번의 페이지 접근으로 조회한다. 기존에는 IN 목록의 각
값마다 root부터 leaf까지 독립 탐색했으나, PG 17에서는 leaf page를 공유하는 값들을 묶어 처리한다.||
Q6. 인덱스 bloat를 측정할 때 pgstattuple의 avg_leaf_density가 60%라면 어떻게 해석하는가?
정답: ||leaf 페이지의 40%가 비어있다는 의미로, bloat가 40%인 상태다. 30%를 초과했으므로 REINDEX CONCURRENTLY를 검토해야 한다. 신규 인덱스의 정상 leaf density는 보통 90% 내외다.||
Q7. autovacuum_vacuum_scale_factor를 0.2에서 0.01로 변경하면 어떤 효과가 있는가?
정답: ||dead tuple이 전체 행의 1%만 쌓여도 autovacuum이 트리거된다(기본값 20% 대비 20배 민감). 대용량 테이블에서 dead tuple 누적을 조기에 정리하여 인덱스 성능 저하와 테이블 bloat를 방지한다.||