13.2 인덱스 전략
Part VI에서 인덱스의 타입을 봤다면, 어떤 인덱스를 언제 만들고 언제 안 만들지의 운영 의사결정 가이드. 잘못된 인덱스는 만들지 않은 것보다 나쁜 결과를 낸다 — INSERT 비용·BLOAT·플래너 부담입니다.
인덱스 추가의 비용
인덱스 N개를 추가하면:
| 비용 | 영향 |
|---|---|
| 디스크 | N배 row 데이터 |
| INSERT/UPDATE | N개 인덱스 갱신 |
| HOT update 가능성 | 변경 컬럼에 인덱스 있으면 HOT 안 됨 |
| autovacuum | N개 인덱스 정리 |
| 플래너 | 후보 plan 폭증 |
| BLOAT | 별도 관리 |
이래서 필요한 만큼만, 좁게 가 황금률.
인덱스 추가 의사결정
flowchart TD
Q["WHERE에 자주 등장하는 컬럼?"]
Q --> S{selectivity 좋은가?}
S -- "yes (적은 row 매칭)" --> ADD["인덱스 후보"]
S -- "no (대부분 row 매칭)" --> NO["인덱스 효과 적음 → seq scan이 더 빠름"]
ADD --> COMBO{여러 컬럼 결합?}
COMBO -- "yes" --> COMP["composite 인덱스"]
COMBO -- "no" --> SINGLE["단일 컬럼"]
COMP --> PART{일부 row만?}
SINGLE --> PART
PART -- "yes" --> PARTIAL["partial index 검토"]
PART -- "no" --> FULL["full index"]
classDef ok fill:#d1fae5,stroke:#047857,color:#064e3b
classDef no fill:#fee2e2,stroke:#b91c1c,color:#7f1d1d
classDef q fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
class ADD,COMP,SINGLE,PARTIAL,FULL ok
class NO no
class Q,S,COMBO,PART q
selectivity 기준
| selectivity | 인덱스 효과 |
|---|---|
| < 1% (적은 row) | 매우 좋음 |
| 1~10% | 좋음 |
| 10~30% | 보통 (Bitmap scan이 도와줌) |
| > 30% | seq scan이 더 빠를 가능성 |
status 컬럼처럼 대부분 row가 같은 값은 인덱스 효과 적습니다. partial index가 답입니다.
-- 효과 적음
CREATE INDEX ON orders(status); -- 90%가 'paid'
-- 효과 좋음
CREATE INDEX ON orders(user_id) WHERE status = 'pending';composite index — 컬럼 순서
CREATE INDEX ON orders(user_id, status, created_at);가장 좌측 prefix 기준:
| WHERE | 활용 |
|---|---|
user_id = 42 | 가능 |
user_id = 42 AND status = 'paid' | 가능 |
user_id = 42 AND status = 'paid' AND created_at > ... | 가능 |
status = 'paid' 만 | 부분 활용 또는 skip scan(PG 18+ 일부) |
created_at > ... 만 | 활용 안 됨 |
선행 컬럼 선택 원칙:
- WHERE에 항상 등장 (등호 위주)
- selectivity 높음
- 마지막 컬럼은 정렬·범위 조건용
INCLUDE — covering index
CREATE INDEX ON orders(user_id, status)
INCLUDE (created_at, amount);INCLUDE 컬럼은 정렬·검색에 안 쓰이지만 index-only scan(4.3)에 답을 제공합니다.
| 사용 | 메모 |
|---|---|
| 자주 SELECT하는 컬럼 묶음 | 크지 않으면 INCLUDE 권장 |
| 큰 텍스트·JSONB | 인덱스 비대 — 안 좋음 |
| 정렬에 쓰일 컬럼 | INCLUDE 대신 키 컬럼으로 |
자주 보는 안티패턴 인덱스
1. 중복 인덱스
CREATE INDEX ON orders(user_id);
CREATE INDEX ON orders(user_id, status);
-- 첫 번째는 두 번째에 포함됨 — 중복후자가 있으면 전자는 대부분 불필요. 6.7의 중복 점검 SQL로 정리합니다.
2. 변경 자주 되는 컬럼에 인덱스
CREATE INDEX ON orders(updated_at);
-- updated_at은 매 UPDATE마다 갱신 — 인덱스도 갱신HOT update 차단 효과 + UPDATE 부하 ↑. 정말 필요한가 검토합니다.
3. NULL이 많은 컬럼에 full 인덱스
CREATE INDEX ON users(deleted_at);
-- 99%가 NULL이면 partial이 답
CREATE INDEX ON users(deleted_at) WHERE deleted_at IS NOT NULL;PostgreSQL은 NULL도 인덱싱하지만 대부분 NULL이면 partial이 99% 작습니다.
4. 미사용 인덱스
SELECT relname, indexrelname,
pg_size_pretty(pg_relation_size(indexrelid)) AS size,
idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0 ORDER BY pg_relation_size(indexrelid) DESC;idx_scan = 0이고 최근 만든 게 아닌 인덱스 = drop 후보입니다.
5. 함수 호출 WHERE인데 일반 인덱스
CREATE INDEX ON users(email);
SELECT * FROM users WHERE LOWER(email) = 'a@b.com'; -- 인덱스 못 씀
-- 답
CREATE INDEX ON users(LOWER(email)); -- expression index (6.6)JSONB 인덱스 전략
-- 전체 트리에 GIN — containment 다양한 쿼리
CREATE INDEX ON events USING gin (payload jsonb_path_ops);
-- 특정 경로에 B-tree — 정확 등호·범위
CREATE INDEX ON events ((payload->>'user_id'));
CREATE INDEX ON events (((payload->'meta'->>'priority')::int));운영 표준 = GIN + 자주 쓰는 경로의 B-tree 조합. JSONB 컬럼 전체에 GIN 하나만 두는 패턴은 쿼리 패턴이 다양할 때.
부하 분리 — read replica용 인덱스
OLTP에는 가벼운 인덱스, 분석 standby에 별도 무거운 인덱스 두는 패턴입니다.
-- 분석 standby에서만
CREATE INDEX CONCURRENTLY ON orders (created_at) WHERE status = 'paid';standby는 promote 시 옛 primary의 모든 인덱스도 함께 가지므로 기본은 같음. 다른 패턴은 logical replication이나 ETL 분리가 답입니다.
정기 점검 SQL
-- 사용량 낮은 인덱스
SELECT s.relname, s.indexrelname, s.idx_scan,
pg_size_pretty(pg_relation_size(s.indexrelid)) AS size
FROM pg_stat_user_indexes s
JOIN pg_index i ON s.indexrelid = i.indexrelid
WHERE s.idx_scan < 100 -- 운영 시작 후 100회 미만
AND NOT i.indisunique
AND pg_relation_size(s.indexrelid) > 10*1024*1024
ORDER BY s.idx_scan, pg_relation_size(s.indexrelid) DESC;
-- 중복 후보
SELECT indrelid::regclass, array_agg(indexrelid::regclass)
FROM pg_index
GROUP BY indrelid, indkey
HAVING count(*) > 1;월 점검 항목.
인덱스 만들 때 — CONCURRENTLY
운영 중에는 항상 CONCURRENTLY (6.1).
CREATE INDEX CONCURRENTLY ON orders (user_id, status);진행률:
SELECT * FROM pg_stat_progress_create_index;안티패턴 요약
| 패턴 | 문제 | 답 |
|---|---|---|
| 모든 컬럼에 인덱스 | INSERT·BLOAT 폭증 | WHERE 패턴 분석 |
| 함수 호출 WHERE인데 일반 인덱스 | 활용 안 됨 | expression index |
| status 같은 low-selectivity에 full | 효과 적음 | partial |
| INCLUDE에 큰 텍스트 | 인덱스 비대 | 좁은 컬럼만 |
| 중복 인덱스 | 비용 두 배 | 정기 점검 |
idx_scan = 0이지만 drop 안 함 | 의미 없는 비용 | 정리 |
정리
- 인덱스 추가는 필요한 만큼만, 좁게
- selectivity 1~10% 컬럼이 인덱스 효과 가장 큼
- composite는 가장 왼쪽부터 활용합니다. 순서가 핵심
- partial·expression·INCLUDE로 정밀하게
- 미사용·중복 인덱스 정기 정리
- JSONB는 GIN + 자주 쓰는 경로 B-tree 조합
- 운영 중 인덱스는 항상
CONCURRENTLY
다음 절(13.3)에서는 큰 테이블의 성능을 결정하는 파티셔닝 튜닝을 봅니다.