14.3 인덱스 안티패턴
인덱스는 자주 쓰이는 자리에 제대로 두었을 때만 힘을 냅니다. 엉뚱한 곳에 두면 쓰기 비용과 BLOAT만 남습니다.
1. 과다 인덱스
-- 안티패턴
CREATE INDEX ON orders(user_id);
CREATE INDEX ON orders(status);
CREATE INDEX ON orders(created_at);
CREATE INDEX ON orders(country);
CREATE INDEX ON orders(amount);
CREATE INDEX ON orders(payment_method);
...인덱스가 10개를 넘으면 거의 항상 과합니다. INSERT/UPDATE 비용이 N배로 늘고, HOT update가 막혀 BLOAT가 빠르게 자랍니다.
대안: WHERE 패턴 분석 후 꼭 필요한 인덱스만.
-- pg_stat_statements로 WHERE 패턴 추출
SELECT query FROM pg_stat_statements WHERE query ILIKE '%FROM orders%' ORDER BY total_exec_time DESC;각 패턴별로 진짜 필요한 인덱스 1~2개로 좁힘.
2. 미사용 인덱스 방치
-- idx_scan = 0이지만 drop 안 함읽기 부담은 없지만 쓰기 비용·BLOAT·디스크는 그대로. 정기 정리(6.7).
SELECT relname, indexrelname, idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;월 단위로 점검 + drop.
3. 중복 인덱스
CREATE INDEX ON orders(user_id);
CREATE INDEX ON orders(user_id, status);
-- 둘 다 user_id 룩업에 사용 가능 — 첫 번째 중복대안: 첫 번째 drop. composite로 충분합니다.
4. composite의 잘못된 컬럼 순서
-- 안티패턴 — selectivity 낮은 컬럼이 앞
CREATE INDEX ON orders(status, user_id);
-- status는 'paid'가 90% — selectivity 낮음
-- user_id로 시작했어야대안: 등호 + selectivity 높은 컬럼이 앞.
CREATE INDEX ON orders(user_id, status);5. FK 컬럼에 인덱스 없음
-- 안티패턴
CREATE TABLE order_items (
id bigserial PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders(id) -- 인덱스 없음!
);PostgreSQL은 FK 컬럼에 자동 인덱스 안 만듭니다. orders에서 row를 DELETE/UPDATE할 때 자식 테이블 전체 스캔으로 cascade 확인 — 매우 느려집니다.
대안: FK 컬럼에 인덱스 명시합니다.
CREATE INDEX ON order_items(order_id);6. 작은 테이블에 인덱스
-- 안티패턴 — 100 row 테이블
CREATE TABLE settings (...); -- 50 row
CREATE INDEX ON settings(key);옵티마이저가 seq scan을 더 빠르게 봅니다. 인덱스는 대용량 테이블에 효과입니다.
대안: 작은 테이블은 인덱스 만들지 말기. PK는 예외 (constraint 목적).
7. low-selectivity 컬럼에 full 인덱스
-- 안티패턴
CREATE INDEX ON orders(status); -- 'paid' 90%, 'pending' 8%, 'cancelled' 2%status = 'paid' 쿼리는 거의 모든 row에 매칭되므로 옵티마이저가 seq scan을 선호합니다. 인덱스를 거의 쓰지 않습니다.
대안: partial index for hot subset.
CREATE INDEX ON orders(user_id) WHERE status = 'pending';8. NULL이 많은 컬럼에 full 인덱스
-- 안티패턴
CREATE INDEX ON users(deleted_at);
-- 99% NULL대부분의 row가 인덱스에 들어가지만 쿼리는 NOT NULL만 찾습니다.
대안: partial.
CREATE INDEX ON users(deleted_at) WHERE deleted_at IS NOT NULL;9. unique 보장에 application 의존
-- 안티패턴
-- 코드에서 INSERT 전 SELECT로 중복 검사
SELECT count(*) FROM users WHERE email = 'a@b.com';
-- if 0: INSERTrace condition으로 동시에 두 row 들어갈 수 있습니다.
대안: DB-level UNIQUE.
CREATE UNIQUE INDEX ON users(email);
-- INSERT가 충돌 시 ERROR — application이 잡기10. partial 조건에 volatile 함수
-- 안티패턴
CREATE INDEX ON events(id)
WHERE created_at > now() - interval '1 day';
-- now()는 인덱스 생성 시점으로 고정됨한 달 뒤엔 전혀 다른 의미의 인덱스.
대안: 절대 시점 또는 파티셔닝.
11. expression index에 STABLE/VOLATILE 함수
-- 안티패턴
CREATE INDEX ON users(my_func(col));
-- my_func이 IMMUTABLE이 아니면 안전하지 않음대안: 함수를 IMMUTABLE로 명시합니다.
CREATE OR REPLACE FUNCTION my_func(t text) RETURNS text
AS $$ SELECT ... $$
LANGUAGE sql IMMUTABLE;12. 인덱스 만든 뒤 ANALYZE 안 함
CREATE INDEX CONCURRENTLY ON orders(user_id, status);
-- ANALYZE 안 함옵티마이저에 새 인덱스 통계가 없어 의외로 활용되지 않습니다.
대안: 항상 ANALYZE.
CREATE INDEX CONCURRENTLY ON orders(user_id, status);
ANALYZE orders;13. INSERT 직전에 인덱스 만들기
-- 안티패턴 — ETL에서
INSERT INTO orders ... (1000만 row);
CREATE INDEX ON orders(user_id);
-- 인덱스 빌드 매우 느림대안: 인덱스 먼저 drop → INSERT → 인덱스 재생성.
DROP INDEX idx_orders_user_id;
INSERT INTO orders ...;
CREATE INDEX CONCURRENTLY ON orders(user_id);큰 ETL은 인덱스 없는 상태에서 INSERT가 훨씬 빠릅니다.
14. CONCURRENTLY 안 함
운영 중:
-- 안티패턴
CREATE INDEX ON orders(user_id); -- SHARE lock, 트래픽 막힘대안: 운영 중은 항상 CONCURRENTLY.
CREATE INDEX CONCURRENTLY ON orders(user_id);15. 일부 컬럼만 인덱싱 (composite의 부분)
CREATE INDEX ON orders(user_id);
-- 쿼리는 항상 WHERE user_id = X AND status = 'Y'
-- composite (user_id, status)가 더 좋음EXPLAIN의 Filter에 status가 걸리면 인덱스 이후 걸러진다는 뜻이므로 composite로 줄입니다.
16. 인덱스에 너무 큰 컬럼
CREATE INDEX ON articles(body); -- body가 1MB인덱스 비대 → 캐시 효율 ↓.
대안: 짧은 키 (해시·접두사) 또는 GIN/trgm.
CREATE INDEX ON articles(md5(body));
-- 또는
CREATE INDEX ON articles USING gin (body gin_trgm_ops);17. 인덱스 hint로 buy plan
-- 안티패턴
SET enable_seqscan = off; -- 전역운영 전체에 영향. 다른 쿼리가 seq scan이 정답인데도 피하게 됩니다.
대안: 트랜잭션 단위로만.
BEGIN;
SET LOCAL enable_seqscan = off;
SELECT ...;
COMMIT;정리
과다 인덱스는 INSERT 비용과 BLOAT로 돌아오고, 미사용 인덱스는 정기적으로 drop합니다. FK 컬럼에는 인덱스가 필수이며, low-selectivity 컬럼이나 NULL이 많은 컬럼은 partial로 좁힙니다.
- composite 컬럼 순서는 등호 + selectivity 높은 것 먼저
- expression index 함수는 IMMUTABLE
- 인덱스는 만든 뒤 ANALYZE, 운영 중에는 CONCURRENTLY
- ETL은 인덱스 drop 후 재생성