5장. 인덱스 완전 정복
PostgreSQL엔 인덱스가 6종류나 있습니다. B-tree가 왜 기본이고, 언제 GIN·BRIN을 꺼내야 하며, 인덱스가 공짜가 아닌 이유까지 — 선택의 기준을 세웁니다.
① 인덱스가 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%'를 모두 처리합니다. 수백만 행에서 원하는 키를 찾는 데 페이지 몇 번이면 충분한 이유를 눈으로 봅시다.
WHERE id = 42 쿼리가 도착했습니다. B-tree 탐색은 항상 Root 페이지에서 시작합니다.
Root에서 "42는 100보다 작다" → 왼쪽 자식으로. 페이지 한 번 읽고 검색 범위가 확 줄었습니다.
Internal 노드에서 42가 속한 구간을 찾아 Leaf 페이지로 내려갑니다.
Leaf에서 42 → (7,3) 엔트리 발견! 이 TID(물리 주소)로 heap의 block 7만 콕 집어 읽습니다. 전체 스캔이 필요 없죠.
총 페이지 읽기 3~4번. 그리고 leaf들은 양방향 링크로 연결되어 있어 BETWEEN·ORDER BY … LIMIT은 진입점만 찾고 옆으로 순차 읽기 하면 됩니다.
-- 범위 + 정렬 모두 인덱스 하나로
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)에 있습니다. 같은 테이블, 두 가지 쿼리를 나란히 봅시다.
100만 행에서 단 1행을 찾는 쿼리입니다. 두 스캔 방식이 각각 어떻게 움직일까요?
Seq Scan은 처음부터 끝까지 모든 페이지를 순차로 읽으며 조건 검사 — 1행 찾자고 수만 페이지를 읽는 낭비입니다.
Index Scan은 B-tree를 3~4번 타고 내려가 TID를 얻고 heap 1페이지만 콕. 압승입니다.
그런데 조건이 "전체의 60%"라면? 인덱스를 쓰면 60만 번의 무작위 heap 접근이 발생합니다. 순차 I/O로 전체를 쭉 읽는 Seq Scan이 오히려 빠릅니다.
정리: 소량은 Index Scan, 애매한 중간(수천~수만 행)은 Bitmap Heap 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의 삽입 비용은 큽니다. 그래서 새 엔트리를 pending list에 모아뒀다 나중에 병합하는 fastupdate(기본 on)가 있는데, 이 리스트가 커지면 SELECT 지연이 튈 수 있습니다. 읽기 지연에 민감하면 fastupdate=off도 고려하세요.
5.5 BRIN — 초소형 요약 인덱스
BRIN(Block Range Index)은 개별 행이 아니라 페이지 묶음(기본 128페이지=1MB)의 min/max 요약만 저장합니다. 시계열 로그처럼 물리 순서와 값 순서가 일치하는 데이터에서, GB급 B-tree 대신 몇 MB로 비슷한 효과를 냅니다.
시간 순서로 쌓인 logs 테이블. BRIN은 1MB 단위 range마다 min/max 요약만 갖고 있습니다. 1월 7~8일 데이터를 찾아봅시다.
range 0(1/01~1/03), range 1(1/03~1/06), range 3(1/09~)은 min/max가 조건과 겹치지 않음 → 통째로 건너뜁니다.
조건과 겹치는 range 2만 실제로 스캔합니다. 몇 MB짜리 인덱스로 테이블의 대부분을 안 읽게 된 것이죠.
단, 데이터가 물리적으로 정렬돼 있을 때만 효과가 있습니다. 무작위 삽입 컬럼이면 모든 range의 min/max가 비슷해져 스킵할 게 없어집니다.
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;
① LIKE '%abc'를 B-tree에 기대 — 후방 일치는 안 됩니다. pg_trgm+GIN이 정답. ② 타입 불일치 — WHERE code = 12345(code가 text)는 캐스트 때문에 인덱스를 우회할 수 있습니다. ③ 함수 래핑 — WHERE to_char(created_at, …) = …은 Expression Index를 만들거나 범위 조건으로 바꾸세요.
✍️ 이해도 체크
random_page_cost로 합니다. 물론 통계가 낡아 "오판"하는 경우도 있으니 6장에서 감별법을 배웁니다.WHERE name ILIKE '%kim%' (중간 문자열 매칭)을 인덱스로 가속하는 올바른 방법은?'abc%')만 처리할 수 있습니다. 중간/후방 일치는 문자열을 3글자 조각(trigram)으로 쪼개 역색인하는 pg_trgm + GIN이 정답입니다. Hash는 완전 동등 비교 전용입니다.이 장의 원문 문서: chapters/ch05_indexes.md — opclass 상세, GIN vs GiST 선택표, 인덱스 선택 플로우차트, 운영 체크리스트 전체가 담겨 있습니다.