부분 인덱스로 인덱스 크기를 줄이기
인덱스에도 다이어트가 필요하다
orders 테이블에 천만 행이 있고 그중 status = 'pending' 인 행은 몇백 개뿐이라고 합시다. "미처리 주문을 빨리 찾고 싶다"는 요구 때문에 status 컬럼에 일반 인덱스를 만들면, 이미 처리 완료된 천만 개에 가까운 shipped, cancelled 행까지 전부 인덱스에 들어갑니다. 정작 자주 조회하는 건 몇백 개짜리 pending 인데 말이죠.
CREATE INDEX idx_orders_pending ON orders (created_at)
WHERE status = 'pending';WHERE 절이 붙은 이 인덱스는 status = 'pending' 인 행만 담습니다. 나머지 천만 행에 가까운 데이터는 인덱스에 아예 존재하지 않습니다. 인덱스 크기가 원래의 몇 백분의 1로 줄어들고, VACUUM 이 인덱스를 훑는 시간도, 캐시에 인덱스를 올려두는 메모리도 그만큼 줄어듭니다.
조건과 인덱스 조건이 일치해야 옵티마이저가 알아챈다
부분 인덱스를 만들어놓고 왜 안 타냐고 묻는 경우 열에 아홉은 쿼리 조건과 인덱스 WHERE 절이 안 맞아서입니다.
-- 인덱스 정의: WHERE status = 'pending'
-- 탄다: 조건이 정확히 일치
SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at;
-- 안 탄다: 옵티마이저는 'pending' 이 인덱스가 커버하는 조건의 부분집합인지
-- 증명할 수 없다 (bind 파라미터라 플래닝 시점엔 값을 모름)
PREPARE q AS SELECT * FROM orders WHERE status = $1 ORDER BY created_at;
EXECUTE q('pending');
-- 안 탄다: status 조건 자체가 없음
SELECT * FROM orders ORDER BY created_at;두 번째 예시가 특히 자주 걸려 넘어지는 함정입니다. 애플리케이션 코드에서 파라미터 바인딩으로 쿼리를 날리면, 플래너가 실행 계획을 세우는 시점에는 $1 이 'pending' 인지 알 수 없어서 부분 인덱스를 안전하게 못 씁니다(PostgreSQL의 커스텀 플랜 재계획 기능으로 일부 완화되지만 항상 보장되진 않습니다). 리터럴 값으로 조건을 고정할 수 있는 배치 작업이나 관리자 화면 쿼리에는 부분 인덱스가 잘 맞고, 파라미터가 매번 바뀌는 범용 조회 API에는 기대만큼 안 먹힐 수 있다는 걸 미리 알아두세요.
NULL 을 걸러내는 용도로도 쓴다
또 하나 실전에서 자주 쓰는 패턴은 NULL 제외입니다.
CREATE INDEX idx_orders_cancel_reason ON orders (cancel_reason)
WHERE cancel_reason IS NOT NULL;cancel_reason 은 취소된 주문에만 값이 들어가는 컬럼이라, 전체 행의 대다수는 NULL 입니다. B-Tree 인덱스는 기본적으로 NULL 도 저장하기 때문에(정렬 순서상 맨 끝이나 맨 앞에), IS NOT NULL 조건 없이 인덱스를 만들면 쓸모없는 NULL 값들이 인덱스 공간을 차지합니다. 자주 NULL 인 컬럼에 인덱스가 필요하다면 이 패턴을 기본값으로 삼아도 됩니다.
어디까지 줄일 수 있는가
앞서 이 책 소개에서 언급했듯, 조건을 얼마나 좁히느냐에 따라 인덱스 크기는 수백 배까지 차이가 납니다. 실제로 100만 행 중 조건에 맞는 행이 1,000개(0.1%) 라면, 부분 인덱스는 일반 인덱스 대비 대략 그 비율만큼 작아집니다. 단, 이건 어디까지나 "그 조건으로 조회할 때"만 유효한 최적화라는 걸 명심하세요. 다른 조건의 조회는 이 인덱스를 전혀 쓸 수 없으니, 부분 인덱스 하나로 모든 쿼리를 해결하려 들면 안 됩니다. 자주 반복되는 좁은 조건이 있을 때만 꺼내는 도구입니다.