쿼리가 필요로 하는 모든 칼럼이 인덱스 안에 다 있어서, DB가 테이블에 가지 않고 인덱스만으로 답을 만드는 것. PostgreSQL 실행계획에서는 index-only scan으로 표시된다.
무엇을 생략하는가
[일반 인덱스 스캔]
- ① 인덱스에서 조건에 맞는 항목을 찾는다
- ② 그 항목이 가리키는 테이블 위치로 가서 행을 읽는다 ← 이 단계가 비싸다
[커버링]
- ① 인덱스에서 찾는다
- (끝)
②가 비싼 이유는 인덱스는 정렬돼 있지만 테이블은 흩어져 있어서다. 인덱스를 순서대로 훑어도 테이블 접근은 디스크 여기저기를 랜덤하게 오간다. 조건에 맞는 행이 많을수록 이 비용이 폭증한다.
만드는 법
-- PostgreSQL: INCLUDE 로 '검색에는 안 쓰지만 값은 필요한' 칼럼을 리프에 얹는다
CREATE INDEX idx_member_email ON member (email) INCLUDE (created_at);
-- 이 쿼리는 힙(테이블) 접근 0회
SELECT created_at FROM member WHERE email = ?;
INCLUDE 칼럼은 정렬 키가 아니라 리프에 얹히기만 한다. 그래서 인덱스 크기를 덜 키우면서 커버링을 얻는다.
엔진별 차이
-
InnoDB — 보조 인덱스 리프에 PK가 이미 들어 있다
- SELECT 하는 게 PK뿐이면 자연스럽게 커버링
-
PostgreSQL — index-only scan 은 '가시성 맵'까지 확인해야 한다
- VACUUM 이 잘 돈 테이블에서 잘 통하고,
- 죽은 튜플이 많으면 결국 힙을 보러 간다
- VACUUM 이 잘 돈 테이블에서 잘 통하고,
두 번째가 중요하다 — PostgreSQL에서 커버링이 기대만큼 안 나오면 VACUUM 상태를 먼저 의심한다.
면접 함정
- ❌
SELECT *인데 커버링 → 모든 칼럼이 인덱스에 없으면 성립하지 않는다. 커버링을 노린다면 select 목록부터 좁혀야 한다. - ❌ "칼럼을 다 넣으면 항상 이득" → 인덱스가 커지면 캐시 효율과 쓰기 성능이 나빠진다. 자주 도는 쿼리에만 적용한다.
계획에서 확인하기
-- PostgreSQL
EXPLAIN (ANALYZE, BUFFERS)
SELECT created_at FROM member WHERE email = 'a@b.com';
-- Index Only Scan using idx_member_email on member
-- Heap Fetches: 0 ← 0 이면 완전한 커버링
-- Heap Fetches: 1523 ← 0이 아니면 가시성 맵이 낡은 것 → VACUUM 필요
Heap Fetches가 핵심 지표다. index-only scan으로 잡혔는데도 이 값이 크면, 인덱스 문제가 아니라 VACUUM이 안 돌아 가시성 맵이 낡은 것이다. 인덱스를 고치기 전에 이걸 먼저 본다.
VACUUM (ANALYZE) member; -- 가시성 맵 갱신 → Heap Fetches 가 0으로 떨어진다
어디까지 넣을 것인가
자주 도는 목록 쿼리의 SELECT 칼럼이 3~4개로 고정 → 커버링을 노릴 만하다
- SELECT 칼럼이 매번 다르거나 10개가 넘는다 — → 인덱스만 커지고 이득이 없다
판단 기준은 인덱스가 커져서 잃는 것(캐시 효율·쓰기 비용)보다 힙 접근을 없애서 얻는 것이 큰가이다. 조건에 맞는 행이 많은 쿼리(수백~수천 행)일수록 이득이 크다 — 힙 랜덤 접근이 그만큼 줄기 때문이다.