14.3 인덱스 안티패턴
인덱스의 강력함은 만들고 빠지지 않는 곳에 잘 두었을 때만. 인덱스 운영의 흔한 함정과 대안을 정리합니다.
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 후 재생성
다음 절(14.4)에서는 긴 트랜잭션·잊혀진 prepared 등 — 트랜잭션 함정을 봅니다.