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