DB Study

5장. 인덱스 완전 정복

PostgreSQL엔 인덱스가 6종류나 있습니다. B-tree가 왜 기본이고, 언제 GIN·BRIN을 꺼내야 하며, 인덱스가 공짜가 아닌 이유까지 — 선택의 기준을 세웁니다.

중급 ⏱ 약 30분 🎬 애니메이션 3개 선수 지식: 4장 Storage
🧭
이 장에서 배우는 것

① 인덱스가 heap 접근을 우회하는 원리 ② B-tree의 탐색 경로와 INCLUDE·opclass·복합 인덱스 ③ Seq Scan vs Index Scan vs Bitmap Scan의 선택 기준 ④ GIN·GiST·BRIN 등 특수 인덱스와 유지 비용(Bloat·Write Amplification)

5.1 인덱스는 "찾아가는 지도"다

PostgreSQL의 테이블(heap)은 정렬되지 않은 튜플 무더기입니다(4장). 조건에 맞는 행을 찾으려면 원칙적으로 모든 페이지를 읽어야 하죠(Seq Scan). 인덱스는 키 → TID((블록, 오프셋)) 매핑을 정렬·구조화해 유지함으로써, heap의 필요한 위치만 찍어 읽게 해줍니다.

공짜는 아닙니다. INSERT/UPDATE/DELETE마다 모든 관련 인덱스도 갱신해야 하고(write amplification), MVCC 특성상 인덱스에도 bloat가 쌓입니다. 그리고 많이 읽어야 하는 조건(관행적으로 5~10% 이상)은 Seq Scan이 오히려 빠릅니다 — 플래너가 이 판단을 대신해 줍니다(6장).

5.2 B-tree — 기본값이자 만능에 가까운 선택

USING 절 없이 CREATE INDEX를 하면 B-tree입니다. =·범위(<, >, BETWEEN)·정렬·전방 일치 LIKE 'abc%'를 모두 처리합니다. 수백만 행에서 원하는 키를 찾는 데 페이지 몇 번이면 충분한 이유를 눈으로 봅시다.

[100 | 500] Root [10 | 42 | 77] [500 | 800] Internal 10→(2,1) 23→(0,4) 31→(5,2) 42→(7,3) 55→(1,8) 61→(3,1) 77→(4,2) 90→(2,6) 95→(0,9) Leaf heap block 7 (7,3) = id 42의 행 WHERE id = 42 42 < 100 → 왼쪽 42 발견 구간 → leaf TID (7,3)로 heap 직행 📖 페이지 읽기 3~4번으로 끝 — 수백만 행이어도 트리 깊이는 3~4 수준 leaf끼리 양방향 링크 → 범위 스캔·ORDER BY는 옆으로 쭉
-- 범위 + 정렬 모두 인덱스 하나로
CREATE INDEX idx_orders_created ON orders(created_at);
SELECT * FROM orders ORDER BY created_at DESC LIMIT 100;  -- Sort 노드 불필요

-- INCLUDE(11+): 키가 아닌 컬럼을 leaf에 실어 Index-Only Scan 확장
CREATE INDEX idx_orders_user_inc
  ON orders(user_id) INCLUDE (amount, status);

-- LIKE 'abc%' 전방 일치를 안정적으로: pattern opclass
CREATE INDEX idx_users_email ON users(email text_pattern_ops);
💡
복합 인덱스의 좌측 접두사 원칙

CREATE INDEX ON t(a, b, c)WHERE a=?, a=? AND b=?, a=? AND b=? AND c=?에 효율적입니다. 반면 WHERE b=?만으로는 사실상 못 씁니다 — 정렬의 첫 기준(a)을 건너뛸 수 없기 때문입니다. 전화번호부에서 "이름은 모르고 번호 뒷자리만" 찾는 상황과 같습니다.

5.3 Seq Scan vs Index Scan — 언제나 인덱스가 이기는 건 아니다

"인덱스를 만들었는데 왜 안 써요?"의 답은 대부분 선택도(selectivity)에 있습니다. 같은 테이블, 두 가지 쿼리를 나란히 봅시다.

🚜 Seq Scan page 0 page 1 page 2 page 3 … 전부 🎯 Index Scan B-tree page 0 page 1 page 2 WHERE id = 42 (100만 행 중 1행, 선택도 0.0001%) 모든 페이지를 읽고 1행만 건짐 — 낭비 💸 트리 타고 내려가 딱 1페이지만 읽음 ✓ WHERE status = 'paid' (전체의 60% = 60만 행이면?) 인덱스로 60만 번 무작위 heap 접근 ≫ 순차로 전부 읽기 — 이젠 Seq Scan이 승자 핀셋(소량)은 Index Scan · 쓰레받기(중간)는 Bitmap Scan · 대량은 Seq Scan 이 판단을 플래너가 통계 기반으로 자동 수행 — 6장에서 계속
읽는 비율(선택도)플래너의 선택
~ 0.01% (수십 행)Index Scan
0.01 ~ 5%Bitmap Heap Scan (페이지 비트맵 → 순차 방문)
> 5~10%Seq Scan (필요 시 Parallel)

5.4 특수 인덱스 — Hash · GIN · GiST · SP-GiST

타입구조주 용도대표 연산자
Hash해시 버킷= 전용(초장문자열 등). PG10+부터 WAL 지원으로 실전 가능=
GIN역색인jsonb·배열·전문 검색·%abc%(pg_trgm)@> ? &&
GiST일반화 균형 트리공간(PostGIS)·범위 타입·KNN(<->)·배타 제약&& @> <->
SP-GiST비균형 분할 트리IP/CIDR, 접두사 많은 문자열, quadtree<< @>

GIN은 "하나의 값이 여러 요소로 쪼개지는" 컬럼의 해결사입니다. 각 요소(단어, 배열 원소, JSON 키)마다 "어느 행에 있는지" 목록(posting list)을 유지합니다.

-- jsonb 포함 검색
CREATE INDEX idx_events_payload ON events USING gin(payload);
SELECT * FROM events WHERE payload @> '{"level":"error"}';

-- 부분 문자열 매칭: B-tree로는 불가능한 %kim%
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_users_name_trgm ON users USING gin(name gin_trgm_ops);
SELECT * FROM users WHERE name ILIKE '%kim%';

-- GiST 배타 제약: 회의실 이중 예약 원천 차단
CREATE TABLE bookings (
    room_id int,
    period tstzrange,
    EXCLUDE USING gist (room_id WITH =, period WITH &&)
);
⚠️
GIN은 쓰기가 비쌉니다 — FASTUPDATE와 pending list

요소마다 인덱스 페이지를 건드려야 해서 GIN의 삽입 비용은 큽니다. 그래서 새 엔트리를 pending list에 모아뒀다 나중에 병합하는 fastupdate(기본 on)가 있는데, 이 리스트가 커지면 SELECT 지연이 튈 수 있습니다. 읽기 지연에 민감하면 fastupdate=off도 고려하세요.

5.5 BRIN — 초소형 요약 인덱스

BRIN(Block Range Index)은 개별 행이 아니라 페이지 묶음(기본 128페이지=1MB)의 min/max 요약만 저장합니다. 시계열 로그처럼 물리 순서와 값 순서가 일치하는 데이터에서, GB급 B-tree 대신 몇 MB로 비슷한 효과를 냅니다.

range 0 (1MB) min 1/01 max 1/03 range 1 min 1/03 max 1/06 range 2 min 1/06 max 1/09 range 3 min 1/09 max 1/12 logs 테이블 — ts 순서로 INSERT된 시계열 WHERE ts BETWEEN '1/07' AND '1/08' ✂ skip ✂ skip ✂ skip ✓ 이 1MB만 스캔 ⚠ 전제: 물리 순서 ≈ 값 순서. 무작위로 삽입된 컬럼이면 모든 range의 min/max가 겹쳐 무용지물 인덱스 크기: B-tree 수 GB vs BRIN 수 MB — 시계열·로그의 특효약
CREATE INDEX idx_logs_ts_brin ON logs USING brin(ts);

-- 정밀도 조절: 범위를 좁히면 스킵 정밀도↑, 인덱스 크기↑
CREATE INDEX idx_logs_ts_brin2
  ON logs USING brin(ts) WITH (pages_per_range = 32);

5.6 Partial · Expression · Unique — 인덱스를 더 얇고 똑똑하게

-- Partial: 전체의 3%인 활성 행만 인덱싱 → 크기·쓰기 비용 급감
CREATE INDEX idx_users_active_email
  ON users(email) WHERE is_active = true;

-- Expression: 함수 결과를 인덱싱 (쿼리 표현식과 문법적으로 일치해야 사용됨)
CREATE INDEX idx_users_email_lower ON users(lower(email));
SELECT * FROM users WHERE lower(email) = 'kim@example.com';

-- Partial Unique: 소프트 삭제 환경에서 "살아있는 행 중에만" 중복 방지
CREATE UNIQUE INDEX idx_users_email_alive
  ON users(email) WHERE deleted_at IS NULL;

5.7 유지 비용 — 인덱스는 만들 때보다 기를 때가 어렵다

인덱스가 N개면 쓰기 I/O도 대략 N배(write amplification). MVCC UPDATE는 인덱스에도 새 엔트리를 만들고, 페이지 분할(split)은 원상 복구되지 않아 index bloat가 됩니다. 관리 루틴:

-- 사용되지 않는 인덱스 찾기 (idx_scan=0이 오래 지속되면 삭제 후보)
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 i.indexrelid = s.indexrelid
WHERE NOT i.indisunique AND NOT i.indisprimary AND s.idx_scan < 50
ORDER BY pg_relation_size(s.indexrelid) DESC;

-- 12+: 쿼리를 막지 않는 재구축
REINDEX INDEX CONCURRENTLY idx_orders_created;
-- 실패 시 INVALID 고아 인덱스가 남을 수 있음: 확인 필수
SELECT * FROM pg_index WHERE NOT indisvalid;
🚨
자주 하는 실수 Top 3

LIKE '%abc'를 B-tree에 기대 — 후방 일치는 안 됩니다. pg_trgm+GIN이 정답. ② 타입 불일치 — WHERE code = 12345(code가 text)는 캐스트 때문에 인덱스를 우회할 수 있습니다. ③ 함수 래핑 — WHERE to_char(created_at, …) = …은 Expression Index를 만들거나 범위 조건으로 바꾸세요.

✍️ 이해도 체크

인덱스가 있는데도 플래너가 Seq Scan을 선택하는 가장 흔한 정당한 이유는?
✅ 선택도가 낮은(많이 읽는) 조건에서는 Index Scan의 무작위 heap 접근 비용이 순차 전체 읽기를 넘어섭니다. 관행적으로 5~10% 이상이면 Seq Scan이 유리하고, 그 판단은 플래너가 통계와 random_page_cost로 합니다. 물론 통계가 낡아 "오판"하는 경우도 있으니 6장에서 감별법을 배웁니다.
WHERE name ILIKE '%kim%' (중간 문자열 매칭)을 인덱스로 가속하는 올바른 방법은?
✅ B-tree는 정렬 기반이라 전방 일치('abc%')만 처리할 수 있습니다. 중간/후방 일치는 문자열을 3글자 조각(trigram)으로 쪼개 역색인하는 pg_trgm + GIN이 정답입니다. Hash는 완전 동등 비교 전용입니다.
📚
원문으로 더 깊이

이 장의 원문 문서: chapters/ch05_indexes.md — opclass 상세, GIN vs GiST 선택표, 인덱스 선택 플로우차트, 운영 체크리스트 전체가 담겨 있습니다.