PostgreSQL 18 스킵 스캔 — leftmost 규칙이 깨지는 예외
앞 장에서 남긴 예외 하나
앞 장에서 "leftmost 컬럼 조건이 없으면 복합 인덱스를 못 쓴다"고 정리했습니다. 이 규칙은 PostgreSQL 18부터 완전히는 맞지 않습니다. PostgreSQL 18에 B-Tree 스킵 스캔(skip scan)이 들어오면서, 특정 조건에서는 leftmost 컬럼 없이도 복합 인덱스를 활용할 수 있게 됐습니다. 이 책을 쓰는 시점 기준으로 아주 최근 변화라, 온라인에 떠도는 "복합 인덱스는 앞 컬럼이 없으면 무조건 못 쓴다"는 설명 다수가 PostgreSQL 17 이하 기준이라는 점을 감안하고 읽으세요. 17 이하에서는 이 장의 내용이 적용되지 않습니다.
스킵 스캔이 실제로 하는 일
(status, customer_id) 인덱스가 있고 status 의 고유값이 pending/shipped/cancelled 세 개뿐이라고 합시다. customer_id = 42 조건만 걸린 쿼리는 예전 같으면 이 인덱스를 전혀 못 썼습니다. PostgreSQL 18의 옵티마이저는 status 의 고유값이 적다는 걸 통계로 알고 있으면, 사실상 이런 쿼리로 바꿔치기합니다.
-- 사용자가 실제로 던진 쿼리
SELECT * FROM orders WHERE customer_id = 42;
-- 옵티마이저가 내부적으로 세 번 반복하는 것과 동등한 일을 한다
-- status = 'pending' AND customer_id = 42
-- status = 'shipped' AND customer_id = 42
-- status = 'cancelled' AND customer_id = 42leftmost 컬럼의 고유값 목록을 훑으면서, 각 값마다 인덱스 안에서 customer_id = 42 인 지점으로 건너뛰는(skip) 방식입니다. 그래서 이름이 스킵 스캔입니다.
언제 효과가 있고 언제 없는가
핵심 조건은 하나입니다 — 생략된 leftmost 컬럼의 고유값 개수가 적어야 합니다. status 처럼 서너 개면 세 번, 네 번 건너뛰는 정도라 매우 저렴합니다. 반대로 leftmost 컬럼이 customer_id 처럼 고유값이 수만 개인 컬럼이라면, 스킵 스캔은 수만 번 건너뛰어야 하므로 순차 스캔보다 나을 게 없고 옵티마이저도 이 경우엔 스킵 스캔을 고르지 않습니다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE customer_id = 42;Index Scan using idx_orders_status_customer on orders
(cost=0.42..15.51 rows=3 width=64) (actual time=0.028..0.041 rows=3 loops=1)
Index Cond: (customer_id = 42)실제로 이 계획을 만들어 보면 Index Cond 만 보이고 별도의 "Skip Scan" 이라는 노드 이름은 따로 나오지 않습니다. EXPLAIN (ANALYZE) 의 Index Searches 항목(버전에 따라 표기가 다를 수 있습니다)이 1보다 크게 늘어난 걸로 스킵 스캔이 동작했는지 유추하는 정도입니다. 완전히 투명한 최적화라 계획만 봐서는 "일반 인덱스 스캔"과 구분하기 까다롭습니다.
그래도 전용 인덱스를 대체하지는 않는다
여기서 흔히 하는 오해를 짚고 넘어가야 합니다. "이제 컬럼 순서 신경 안 써도 되는 거 아니야?" — 아닙니다. 스킵 스캔은 옵티마이저의 최후 수단이고, leftmost 컬럼 조건이 있는 일반적인 인덱스 스캔보다 항상 비쌉니다. 고유값 개수만큼 반복 탐색을 해야 하기 때문입니다. customer_id 조건이 훨씬 자주 오는 쿼리 패턴이라면, 스킵 스캔에 기대지 말고 그냥 (customer_id, status) 순서의 인덱스를 따로 만드는 편이 낫습니다. 스킵 스캔은 "가끔 들어오는 예외적인 쿼리 패턴 때문에 인덱스를 하나 더 만들 정도는 아닐 때" 안전망 역할을 하는 기능이지, 인덱스 설계를 대충 해도 되는 면죄부가 아닙니다.