커버링 인덱스

SELECT 컬럼이 모두 인덱스에 포함되어 힙 접근을 없애는 커버링 인덱스(Covering Index)의 원리, INCLUDE 절로 리프에만 컬럼 추가하는 방법, 실전 설계 패턴을 설명합니다.

· 6 min read · PALDYN Team

지난 글에서 복합 인덱스 컬럼 순서를 정리했다. 이번에는 인덱스 성능 최적화의 끝판왕인 **커버링 인덱스(Covering Index)**를 다룬다.


커버링 인덱스란

쿼리가 필요로 하는 모든 컬럼이 인덱스 안에 있어서 힙(테이블 블록)에 전혀 접근하지 않아도 되는 인덱스다. B-Tree 리프 노드에서 데이터를 바로 반환하므로 랜덤 I/O가 사라진다.


힙 접근이 없어지면 왜 빠른가

커버링 인덱스 I/O 흐름

일반 Index Scan은 인덱스 리프에서 힙 포인터를 꺼내 매 행마다 테이블 블록을 읽는다. 테이블이 크고 조회 행이 많을수록 랜덤 I/O가 쌓여 느려진다.

커버링 인덱스가 있으면 B-Tree 리프에서 모든 컬럼 값을 읽고 끝난다. SELECT name FROM orders WHERE status = 'pending' 쿼리에서 INDEX(status, name)이 있으면:

인덱스 리프:  status='pending' → name='김길동'   ← 여기서 끝
             (테이블 블록 접근 없음)

기본 구현

-- 쿼리
SELECT id, name, email FROM users WHERE status = 'active';

-- 커버링 인덱스: WHERE 컬럼 + SELECT 컬럼 모두 포함
CREATE INDEX idx_users_covering ON users (status, id, name, email);

-- EXPLAIN 결과 (PostgreSQL)
-- Index Only Scan using idx_users_covering on users
--   Index Cond: (status = 'active')
-- Heap Fetches: 0  ← 힙 접근 0회

Heap Fetches: 0이 커버링 인덱스 효과를 보여준다.


INCLUDE 절 활용 (권장)

INCLUDE vs 키 컬럼 방식 비교

nameemail을 인덱스 로 추가하면 불필요하게 모든 레벨에서 정렬이 일어난다. INCLUDE 절을 사용하면 리프 노드에만 추가해서 힙 접근을 없애면서도 인덱스 크기와 DML 오버헤드를 최소화한다.

-- PostgreSQL 11+ / SQL Server: INCLUDE 절
CREATE INDEX idx_users_status ON users (status)
    INCLUDE (id, name, email);
-- status = 키 컬럼: 정렬·필터에 사용
-- id, name, email = INCLUDE 컬럼: 리프에만, 힙 접근 제거용

-- MySQL: 모든 컬럼이 키 컬럼 (INCLUDE 없음)
CREATE INDEX idx_users_status ON users (status, id, name, email);
-- name이 긴 VARCHAR면 인덱스 크기 주의

언제 INCLUDE를 쓰나

  • WHERE/ORDER BY에 없지만 SELECT에 있는 컬럼
  • 자주 변경되는 컬럼 (INCLUDE이면 재정렬 없음)
  • VARCHAR가 길어 키에 넣으면 인덱스가 커지는 컬럼

실전 패턴

패턴 1: 목록 조회 최적화

-- 주문 목록 API: 페이지당 20건
SELECT id, status, total_amount, created_at
  FROM orders
 WHERE customer_id = ?
 ORDER BY created_at DESC
 LIMIT 20;

-- 커버링 인덱스
CREATE INDEX idx_orders_customer_list ON orders
    (customer_id, created_at DESC)
    INCLUDE (id, status, total_amount);
-- customer_id + created_at = 필터+정렬 키
-- id, status, total_amount = SELECT용 INCLUDE

패턴 2: 집계 쿼리 최적화

-- 일별 주문 수 집계
SELECT DATE(created_at) AS dt, COUNT(*), SUM(total_amount)
  FROM orders
 WHERE customer_id = ? AND created_at >= '2026-01-01'
 GROUP BY DATE(created_at);

-- 모든 컬럼이 인덱스에 있으면 힙 미접근
CREATE INDEX idx_orders_agg ON orders
    (customer_id, created_at)
    INCLUDE (total_amount);

패턴 3: JOIN 최적화

-- order_items JOIN products: products 쪽 커버링
SELECT oi.qty, p.name, p.price
  FROM order_items oi
  JOIN products p ON p.id = oi.product_id
 WHERE oi.order_id = ?;

-- products.id로 JOIN, name·price만 필요
CREATE INDEX idx_products_covering ON products (id)
    INCLUDE (name, price);
-- PK가 이미 id이면 INCLUDE만 추가
-- ALTER INDEX ... 불가 → 재생성 또는 INCLUDE 포함 별도 인덱스

주의사항

-- 1. SELECT *는 커버링 불가
SELECT * FROM orders WHERE status = 'pending';
-- 모든 컬럼을 인덱스에 넣으면 테이블 복제 수준 → 의미 없음

-- 2. PostgreSQL: Visibility Map 체크
-- VACUUM 이후 충분히 정리돼야 진정한 Index Only Scan
-- Heap Fetches > 0이면 VM이 오래됐다는 신호
VACUUM orders;  -- 이후 재실행

-- 3. MySQL: InnoDB Clustered Index와 PK
-- 세컨더리 인덱스에 PK 컬럼이 묵시적으로 포함됨
-- INDEX(status) → 내부적으로 INDEX(status, id)처럼 동작
-- id가 SELECT에 있으면 추가 없이 커버링 가능

-- 4. 인덱스 크기 확인
SELECT pg_size_pretty(pg_relation_size('idx_orders_covering'));
-- 인덱스가 테이블보다 커지지 않도록 관리

EXPLAIN으로 커버링 확인

-- PostgreSQL
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, status FROM orders WHERE customer_id = 42;

-- 커버링 인덱스 있을 때:
-- Index Only Scan using idx_orders_customer_list on orders
--   Heap Fetches: 0  ← 핵심!
--   Buffers: shared hit=3  (인덱스 블록만)

-- MySQL
EXPLAIN SELECT id, status FROM orders WHERE customer_id = 42;
-- Extra: Using index  ← 커버링 인덱스 사용 표시

지난 글: 복합 인덱스 컬럼 순서


읽어주셔서 감사합니다. 😊