PostgreSQL 실전 강좌 #4 인덱스 전략 심화: 부분·복합·커버링, 그리고 GIN·BRIN
기초 4편에서 B-tree의 기본 규칙(선행 컬럼, 컬럼 가공 금지, 선택도)을 세웠고, 지난 편에서 느린 쿼리를 찾는 루틴을 만들었습니다. 이번 편은 그 루틴이 물어온 쿼리에 “기본 인덱스로는 부족할 때” 꺼내는 전략들입니다.
부분 인덱스: 조건까지 인덱스에 #
인덱스 정의에 WHERE를 넣으면 그 조건에 맞는 행만 색인됩니다.
-- 미처리 주문만 색인: 전체의 1%라면 인덱스도 1% 크기
CREATE INDEX idx_orders_pending ON orders (created_at)
WHERE status = 'pending';전형적인 자리는 “전체는 크지만 쿼리는 항상 일부만 보는” 테이블입니다. 주문 테이블은 수억 행이어도 애플리케이션이 반복해서 보는 것은 미처리 건뿐입니다. 부분 인덱스는 작아서 빠르고, 색인 밖의 행(완료된 주문)이 갱신될 때 이 인덱스는 건드릴 필요가 없으므로 쓰기 세금도 줄어듭니다. 조건이 WHERE status = 'pending'인 쿼리만 이 인덱스를 탈 수 있다는 것, 즉 쿼리의 조건이 인덱스의 조건을 포함해야 한다는 것만 기억하면 됩니다. 같은 구조로 WHERE deleted_at IS NULL(소프트 삭제 테이블의 살아 있는 행만), UNIQUE 부분 인덱스(활성 행에만 유일성)도 실무 단골입니다.
커버링 인덱스: 테이블에 안 가기 #
기초 5편의 표에서 가장 빠른 스캔은 Index Only Scan, 즉 테이블 접근 없이 인덱스만으로 응답하는 형태였습니다. 조건은 쿼리가 요구하는 모든 컬럼이 인덱스 안에 있을 것입니다. 정렬 키로는 필요 없지만 응답에는 필요한 컬럼을 INCLUDE로 실어 두는 것이 커버링 인덱스입니다.
-- SELECT email, name FROM users WHERE email = ? 를 인덱스만으로
CREATE INDEX idx_users_email_covering ON users (email) INCLUDE (name);주의할 점이 둘 있습니다. 첫째, INCLUDE 컬럼만큼 인덱스가 커지고 그 컬럼의 갱신마다 인덱스도 갱신되므로, 실측된 핫 쿼리에만 씁니다. 둘째, Index Only Scan이 실제로 성사되는지는 EXPLAIN으로 확인해야 합니다. MVCC 구조상 “이 행이 모든 트랜잭션에 보이는가"를 담은 가시성 지도(visibility map)가 낡아 있으면 결국 테이블을 확인하러 가는데, 이 지도를 갱신하는 것이 다음 편의 VACUUM입니다. 인덱스 전략과 VACUUM이 여기서 연결됩니다.
B-tree 밖: GIN, GiST, BRIN #
| 타입 | 자리 | 예 |
|---|---|---|
| GIN | 하나의 값 안에 요소가 여럿(문서·배열) | JSONB, 배열, 전문 검색 |
| GiST | “겹침·근접” 질의 | 지리 데이터(PostGIS), 범위 타입, 최근접 탐색 |
| BRIN | 물리 순서와 값 순서가 비례하는 거대 테이블 | 시계열의 created_at |
GIN은 기초 7편에서 JSONB와 함께 이미 만났습니다. 실전에서 추가로 알아 둘 것은 BRIN입니다. 블록 구간별로 최솟값·최댓값만 저장하는 극단적으로 작은 인덱스로, 시간순으로만 쌓이는 로그·이벤트 테이블의 시각 컬럼처럼 “저장 순서와 값 순서가 거의 일치"할 때 B-tree의 수백분의 일 크기로 범위 검색을 받쳐 줍니다. 수억 행 시계열에 B-tree가 부담스러울 때 기억해 낼 이름입니다.
정리: 안 쓰는 인덱스 찾기 #
기초 4편의 결론이 “인덱스는 쓰기 세금"이었으니, 전략에는 정리도 포함됩니다. 판단 재료는 통계 뷰입니다.
SELECT indexrelname, idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
JOIN pg_index USING (indexrelid)
WHERE idx_scan = 0 AND NOT indisunique
ORDER BY pg_relation_size(indexrelid) DESC;idx_scan = 0, 즉 통계 수집 이후 한 번도 안 읽힌 인덱스가 후보입니다. 두 가지만 조심합니다. UNIQUE 인덱스는 조회가 아니라 제약을 위해 존재하므로 제외하고(위 쿼리의 NOT indisunique), 월말 배치처럼 드물게 쓰는 쿼리용일 수 있으니 충분히 긴 기간의 통계로 판단합니다. 복제 구성이라면 읽기 부하가 스탠바이에 있을 수 있다는 점도 염두에 둡니다(실전 8편).
마지막으로 블로트입니다. 갱신이 잦은 테이블의 인덱스는 MVCC의 dead tuple 흔적으로 점점 부풀어, 같은 일에 더 많은 읽기가 들게 됩니다. 재구축은 서비스를 세우지 않는 REINDEX INDEX CONCURRENTLY idx_name;으로 합니다(실전 1편의 CREATE INDEX CONCURRENTLY와 같은 계열입니다). 블로트가 왜 생기고 평소에 어떻게 억제하는지, 그 본체가 다음 편 VACUUM입니다.
정리 #
- 부분 인덱스는 “항상 일부만 보는” 큰 테이블의 정답입니다. 작고, 빠르고, 쓰기 세금까지 줄입니다.
- 커버링 인덱스(INCLUDE)는 Index Only Scan을 노립니다. 성사 여부는 EXPLAIN과 가시성 지도(VACUUM)에 달려 있습니다.
- B-tree 밖의 선택지는 자리로 기억합니다. 문서·배열은 GIN, 겹침·근접은 GiST, 거대 시계열은 BRIN입니다.
- 안 쓰는 인덱스는 pg_stat_user_indexes의 idx_scan으로 찾아 정리합니다. UNIQUE와 드문 배치용만 조심합니다.
- 부푼 인덱스는 REINDEX CONCURRENTLY로 재구축합니다. 부풀음의 원리와 예방이 다음 편의 주제입니다.