커버링 인덱스와 INCLUDE — 테이블을 아예 안 읽게 만들기
Index Scan 뒤에 숨은 한 번 더의 접근
Index Scan 이라는 이름 때문에 오해하기 쉬운데, 인덱스만 읽고 끝나는 게 아닙니다. 인덱스 리프에는 조건에 맞는 행의 위치(TID) 만 들어 있고, SELECT * 처럼 인덱스에 없는 컬럼을 요청하면 그 위치로 테이블(힙)을 한 번 더 찾아가야 합니다. 이걸 힙 접근(heap fetch)이라고 부릅니다. 인덱스에서 8건을 찾았으면 최악의 경우 힙 페이지도 8번 흩어져서 읽어야 한다는 뜻이고, 이 랜덤 접근이 인덱스 스캔 비용의 상당 부분을 차지합니다.
Index Only Scan — 힙에 안 가는 경우
조회하려는 컬럼이 전부 인덱스 안에 있으면, PostgreSQL은 힙에 가지 않고 Index Only Scan 으로 처리합니다.
CREATE INDEX idx_orders_customer_created ON orders (customer_id, created_at);
-- Index Only Scan: SELECT 절이 인덱스 컬럼만 요구
EXPLAIN SELECT customer_id, created_at FROM orders WHERE customer_id = 42;
-- Index Scan: status 는 인덱스에 없어서 힙까지 가야 한다
EXPLAIN SELECT customer_id, created_at, status FROM orders WHERE customer_id = 42;Index Only Scan using idx_orders_customer_created on orders
(cost=0.42..8.45 rows=3 width=12) (actual time=0.020..0.023 rows=3 loops=1)
Index Cond: (customer_id = 42)
Heap Fetches: 0Heap Fetches: 0 이 핵심입니다. 이게 0이 아니면 인덱스만으로 커버되지 않아서 결국 힙에 다녀왔다는 뜻이고, 그 경우 Index Only Scan 이라는 이름값을 못한 겁니다. 참고로 Heap Fetches 가 0이 아니게 되는 흔한 원인 중 하나는 visibility map이 최신이 아닌 경우입니다 — VACUUM 이 오래 안 돈 테이블에서는 Index Only Scan 을 써도 힙 접근이 섞여 들어옵니다.
조건에는 안 쓰지만 결과엔 필요한 컬럼 — INCLUDE
status 를 조회 결과에는 넣고 싶은데, WHERE 나 정렬 조건으로는 안 쓴다면 그냥 복합 인덱스에 끼워 넣는 게 항상 정답은 아닙니다. B-Tree 인덱스는 키 컬럼을 기준으로 정렬 상태를 유지해야 하는데, 조건절에 안 쓰이는 컬럼까지 키에 넣으면 트리 내부 노드 크기만 불필요하게 커집니다. INCLUDE 절은 이런 컬럼을 리프 노드에만 얹어두고 정렬 키에서는 뺍니다.
CREATE INDEX idx_orders_customer_covering
ON orders (customer_id, created_at) INCLUDE (status);-- 여전히 Index Only Scan, 힙에 안 간다
EXPLAIN SELECT customer_id, created_at, status
FROM orders WHERE customer_id = 42;(customer_id, created_at, status) 로 세 컬럼을 전부 키로 넣는 것과 결과적으로 커버하는 쿼리는 비슷해 보이지만, 내부 노드가 status 값을 정렬 판단에 쓰지 않아 트리가 더 얕고 조밀해집니다. 정렬이나 조건 비교에는 안 쓰지만 SELECT 목록에는 자주 등장하는 컬럼이 있다면 INCLUDE 가 정확히 그 상황을 위한 기능입니다.
커버링 인덱스를 만능으로 쓰면 안 되는 이유
여기서 과욕을 부리면 안 됩니다. SELECT * 로 조회하는 쿼리를 전부 Index Only Scan 으로 만들겠다고 테이블의 모든 컬럼을 하나의 인덱스에 INCLUDE 로 우겨넣는 경우를 종종 봅니다. 이렇게 하면 인덱스가 사실상 테이블을 통째로 복제한 꼴이 되어 디스크 용량과 쓰기 비용만 두 배로 듭니다. 커버링 인덱스는 자주 실행되고, 조회 컬럼이 적고 고정적인 쿼리에 선별적으로 적용하는 최적화입니다. 조회 패턴이 다양한 범용 API 엔드포인트에는 잘 안 맞습니다.