커버링 인덱스는 쿼리가 필요로 하는 모든 컬럼을 인덱스 자체에 담아두어, 실행 시 테이블(힙)을 전혀 읽지 않고 인덱스만으로 결과를 만들어내게 하는 인덱스다. PostgreSQL의 실행계획에서 Index Only Scan으로 찍히는 그 상태를 노리는 것이다.
말은 거창하지만 아이디어는 단순하다. 인덱스에는 이미 정렬된 키가 들어 있다. 여기에 조회하려는 값까지 얹어두면 데이터베이스는 "어디에 있는지"만 찾고 끝내는 게 아니라 "그래서 값이 뭔지"까지 인덱스 안에서 해결해버린다. 디스크를 한 번 덜 왕복하는 셈이다.
왜 테이블을 다시 읽는가
보통의 인덱스 스캔은 두 단계다. 먼저 B-트리를 타고 내려가 조건에 맞는 행의 위치(튜플 포인터)를 찾는다. 그다음 그 포인터를 들고 실제 테이블 페이지로 가서 나머지 컬럼을 읽어온다. 이 두 번째 단계가 힙 페치(heap fetch)인데, 찾은 행이 수천 개면 수천 번의 랜덤 접근이 벌어진다.
커버링 인덱스는 이 두 번째 단계를 생략한다. 필요한 컬럼이 인덱스에 다 있으니 굳이 테이블까지 갈 이유가 없다. 개인적으로 이걸 처음 체감한 건 대시보드 집계 쿼리였다. 인덱스는 잘 타는데 이상하게 느려서 계획을 뜯어봤더니 힙 페치가 전체 시간의 8할을 먹고 있었다.
INCLUDE로 만드는 커버링 인덱스
PostgreSQL 11부터는 INCLUDE 절이 생겨서, 정렬·검색에 쓰지 않는 컬럼을 인덱스의 리프에만 얹을 수 있다. 이게 핵심이다.
-- 주문 상태로 필터하고, 금액과 생성시각을 보여주는 화면
-- 나쁜 인덱스: status만 → 힙 페치 발생
CREATE INDEX idx_orders_status ON orders (status);
-- 커버링 인덱스: 조회 컬럼을 INCLUDE로 얹는다
CREATE INDEX idx_orders_status_covering
ON orders (status)
INCLUDE (amount, created_at);
-- 이 쿼리는 Index Only Scan이 된다
SELECT amount, created_at
FROM orders
WHERE status = 'paid';
INCLUDE에 넣은 컬럼은 B-트리의 중간 노드에는 저장되지 않고 리프에만 들어간다. 그래서 인덱스가 덜 무거워지고, 정렬 순서에도 영향을 주지 않는다. 키 컬럼으로 넣는 것과의 결정적 차이다.
키 컬럼 vs INCLUDE 컬럼
| 구분 | 키 컬럼 | INCLUDE 컬럼 |
|---|---|---|
| 저장 위치 | 모든 노드 | 리프 노드만 |
| WHERE 조건 활용 | 가능 | 불가(값만 제공) |
| ORDER BY 활용 | 가능 | 불가 |
| 인덱스 크기 영향 | 큼 | 상대적으로 작음 |
정리하면, 필터·정렬에 쓰는 컬럼은 키로, 화면에 뿌리기만 하는 컬럼은 INCLUDE로 넣는 게 원칙이다. MySQL(InnoDB)에는 INCLUDE가 없지만, 어차피 세컨더리 인덱스가 기본키를 항상 품고 있어서 복합 인덱스에 조회 컬럼을 뒤쪽에 붙이는 식으로 비슷한 효과를 낸다.
확인하는 법과 흔한 함정
실제로 Index Only Scan이 됐는지는 EXPLAIN (ANALYZE, BUFFERS)로 본다. Heap Fetches: 0이면 성공이다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT amount, created_at FROM orders WHERE status = 'paid';
-- Index Only Scan using idx_orders_status_covering ...
-- Heap Fetches: 0 ← 이 줄이 목표
그런데 여기서 몇 번 데인 적이 있다. Heap Fetches가 0이 아니라 계속 올라가는 경우다. 원인은 대개 비저빌리티 맵(visibility map)이다. 최근에 UPDATE·INSERT가 많이 일어난 페이지는 "모든 트랜잭션에서 보이는 상태"로 표시되지 않아, PostgreSQL이 안전을 위해 힙을 확인하러 간다. VACUUM이 돌아 비저빌리티 맵이 갱신되면 그제서야 진짜 Index Only Scan이 된다.
- 커버링 인덱스를 만들었는데 여전히 느리면, 먼저
VACUUM (ANALYZE) 테이블명을 돌려보라. - INCLUDE에 너무 많은 컬럼을 욱여넣지 마라. 인덱스가 커지면 쓰기 비용과 캐시 압박이 늘어 배보다 배꼽이 커진다.
SELECT *는 커버링과 상극이다. 필요한 컬럼만 명시해야 인덱스만으로 끝낼 수 있다.
솔직히 커버링 인덱스는 만병통치약이 아니다. 하지만 좁은 컬럼 몇 개를 반복해서 읽는 읽기 위주 쿼리라면, 인덱스 하나 바꾸는 것만으로 응답 시간이 눈에 띄게 떨어진다. 나는 조회가 잦은 목록·집계 화면부터 이 관점으로 인덱스를 다시 본다.
자주 묻는 질문
커버링 인덱스와 복합 인덱스는 같은 건가요?
겹치지만 같지는 않습니다. 복합 인덱스는 여러 키 컬럼을 묶은 것이고, 커버링 인덱스는 "쿼리가 필요한 컬럼을 모두 담아 테이블을 안 읽게 한다"는 목적에 대한 이름입니다. 복합 인덱스가 결과적으로 커버링이 되기도 하고, INCLUDE로 조회 컬럼만 얹어 커버링을 만들기도 합니다.
INCLUDE 컬럼에는 아무 타입이나 넣어도 되나요?
대부분 됩니다. INCLUDE 컬럼은 검색·정렬에 쓰이지 않으므로 해당 타입에 연산자 클래스가 없어도 상관없습니다. 다만 매우 큰 텍스트나 배열을 넣으면 인덱스가 비대해지니 크기를 늘 염두에 두세요.
Heap Fetches가 0이 안 나오는데 왜죠?
대개 비저빌리티 맵이 최신이 아니기 때문입니다. 쓰기가 잦은 테이블은 autovacuum이 따라잡기 전까지 힙 확인이 발생합니다. 수동으로 VACUUM을 돌리거나 autovacuum을 더 공격적으로 튜닝하면 개선됩니다.
MySQL에서도 커버링 인덱스를 쓸 수 있나요?
있습니다. InnoDB는 INCLUDE 문법은 없지만 복합 인덱스에 조회 컬럼을 포함시키면 됩니다. EXPLAIN의 Extra 컬럼에 Using index가 뜨면 커버링이 적용된 것입니다.

댓글 0