PostgreSQL Index Only Scan이 테이블을 다시 읽는 이유: 가시성 맵과 Heap Fetches
PostgreSQL 인덱스 전용 스캔이 성립하는 두 조건, MVCC 가시성 확인을 줄이는 가시성 맵, INCLUDE 커버링 인덱스의 장단점을 PostgreSQL 18 문서로 정리하고 Heap Fetches와 relallvisible로 원인을 보는 법을 다룹니다.
EXPLAIN ANALYZE 결과에 Index Only Scan이 찍혔는데 바로 아래 Heap Fetches 값이 작지 않은 경우가 있습니다. 이름만 보면 인덱스만 읽어야 할 것 같지만, PostgreSQL의 인덱스 전용 스캔(index-only scan)은 필요하면 테이블 본체인 힙(heap)을 찾아갑니다. 언제 힙을 건너뛸 수 있는지는 인덱스 정의보다 테이블의 가시성 맵(visibility map) 상태에 달려 있습니다.
이 글은 2026년 10월 27일 기준 최신 정식 버전인 PostgreSQL 18(마이너 18.6) 문서를 기준으로 했습니다. 같은 날 PostgreSQL 19는 RC 1 단계였고 정식 릴리스 표에는 아직 없었습니다.
먼저 필요한 두 조건
문서는 인덱스 전용 스캔의 기본 조건을 두 가지로 적습니다.
- 인덱스 종류가 인덱스 전용 스캔을 지원해야 합니다. B-tree는 항상 지원하고, GiST와 SP-GiST는 일부 연산자 클래스만 지원합니다. 나머지 인덱스 종류는 지원하지 않습니다.
- 쿼리가 인덱스에 저장된 열만 참조해야 합니다.
두 번째 조건에서 “참조”는 출력 열만이 아니라 WHERE 절도 포함합니다. 문서의 예를 그대로 옮기면, 열 x, y, z가 있는 테이블에 (x, y) 인덱스가 있을 때 아래 두 쿼리는 인덱스 전용 스캔이 가능합니다.
SELECT x, y FROM tab WHERE x = 'key';
SELECT x FROM tab WHERE x = 'key' AND y < 42;
반면 아래 두 쿼리는 z가 인덱스에 없으므로 불가능합니다.
SELECT x, z FROM tab WHERE x = 'key';
SELECT x FROM tab WHERE x = 'key' AND z < 42;
인덱스에는 가시성 정보가 없다
두 조건을 갖춰도 일이 끝나지 않습니다. PostgreSQL에서 테이블을 읽는 모든 스캔은 가져온 행이 현재 쿼리의 MVCC 스냅샷에서 보이는 행인지 확인해야 합니다. 그런데 이 가시성 정보는 인덱스 항목에는 없고 힙 항목에만 있습니다. 문서도 이 점을 짚으며, 언뜻 보면 모든 행마다 힙 접근이 필요할 것 같고 최근에 수정된 행이라면 실제로 그렇다고 설명합니다. 스냅샷이 어떤 행 버전을 보는지는 READ COMMITTED와 스냅샷 글에서 다뤘습니다.
이 확인을 줄이는 장치가 가시성 맵입니다. PostgreSQL은 힙 페이지마다 그 페이지의 모든 행이 현재와 미래의 모든 트랜잭션에 보일 만큼 오래되었는지를 비트 하나로 기록합니다. 인덱스 전용 스캔은 후보 인덱스 항목을 찾은 뒤 해당 힙 페이지의 비트를 확인합니다.
| 가시성 맵 비트 | 인덱스 전용 스캔의 동작 |
|---|---|
| 설정됨 (all-visible) | 행이 보인다는 것이 확정되므로 힙을 읽지 않고 인덱스 값으로 결과를 돌려준다 |
| 설정 안 됨 | 가시성을 확인하려고 힙 항목을 방문한다. 문서 표현으로는 이 경우 일반 인덱스 스캔보다 나을 것이 없다 |
비트가 설정된 경우에도 공짜는 아닙니다. 힙 접근 대신 가시성 맵 접근을 하는 것인데, 문서에 따르면 가시성 맵은 그것이 설명하는 힙보다 네 자릿수(10⁴배 정도) 작아서 물리 I/O가 훨씬 적고 대부분 메모리에 캐시된 상태로 남습니다. 문서의 결론은 이렇습니다. 인덱스 전용 스캔은 두 기본 조건만 갖추면 가능하지만, 이득이 나는 것은 테이블 힙 페이지 중 상당한 비율이 all-visible 비트를 갖고 있을 때뿐입니다.
비트는 VACUUM이 켜고, 데이터 변경이 끈다
가시성 맵은 힙 페이지마다 비트 두 개를 둡니다. 첫 번째는 페이지가 all-visible이라는 뜻, 즉 vacuum할 튜플이 없다는 뜻이고 인덱스 전용 스캔이 쓰는 비트가 이것입니다. 두 번째는 페이지의 모든 튜플이 동결(frozen)되었다는 뜻입니다. 가시성 맵은 테이블 파일 옆에 _vm 접미사가 붙은 별도 포크로 저장되며, 인덱스에는 가시성 맵이 없습니다.
비트를 바꾸는 규칙에 대해 문서(66.4절)는 한 문장으로 정리합니다. 가시성 맵 비트는 vacuum만 설정하고, 페이지를 바꾸는 모든 데이터 변경 작업이 지웁니다. 또 이 맵은 보수적입니다. 비트가 켜져 있으면 조건이 참이라는 것이 보장되지만, 꺼져 있다고 해서 조건이 거짓이라는 뜻은 아닙니다.
여기서 몇 가지를 추론할 수 있습니다. 아래는 문서의 위 규칙에서 끌어낸 것이며 문서에 그대로 적힌 문장은 아닙니다.
- 대량으로 막 적재한 테이블은 vacuum이 한 번 지나가기 전까지 all-visible 페이지가 적을 수 있고, 그동안 인덱스 전용 스캔은 힙을 자주 방문할 수 있습니다.
- 갱신이 테이블 곳곳에 흩어져 일어나면 비트가 지워진 페이지도 곳곳에 생깁니다. 페이지 안의 행 하나만 바뀌어도 그 페이지 전체가 대상입니다.
- HOT 업데이트로 새 인덱스 항목을 피하더라도 힙 페이지를 고치는 작업이므로, 문서 문구대로라면 해당 페이지의 비트는 지워집니다.
삽입만 일어나는 테이블도 vacuum 대상이 됩니다. 루틴 vacuum 문서(24.1.6절)에 따르면 마지막 vacuum 이후 삽입된 튜플 수가 삽입 임계값을 넘으면 autovacuum이 돌며, 임계값은 다음과 같이 정의됩니다.
vacuum insert threshold = vacuum base insert threshold
+ vacuum insert scale factor * number of tuples * percent of table not frozen
기본값 부분은 autovacuum_vacuum_insert_threshold, 비율 부분은 autovacuum_vacuum_insert_scale_factor이고, 튜플 수는 pg_class.reltuples, 동결되지 않은 비율은 1 - pg_class.relallfrozen / pg_class.relpages입니다. 문서는 이런 vacuum이 테이블 일부를 all-visible로 표시할 수 있다고 적습니다. 다만 이 설정이 인덱스 전용 스캔을 위해 존재한다고 쓰지는 않으니, 둘을 잇는 것은 위의 두 규칙을 합친 해석입니다.
INCLUDE로 만드는 커버링 인덱스
자주 실행하는 쿼리가 필요로 하는 열을 모두 담도록 설계한 인덱스를 커버링 인덱스라고 합니다. 검색 키가 아닌 열을 “실어 나르기만” 하려면 INCLUDE 절을 씁니다. 주문 테이블을 예로 들면 이렇습니다.
-- 자주 실행하는 쿼리
SELECT created_at, total FROM orders WHERE customer_id = 42;
-- customer_id만 검색 키이고 created_at, total은 페이로드
CREATE INDEX orders_customer_id_inc
ON orders (customer_id) INCLUDE (created_at, total);
CREATE INDEX 문서에 적힌 INCLUDE 열(비키 열)의 성질은 다음과 같습니다.
- 인덱스 스캔의 검색 조건으로 쓸 수 없습니다. 위 인덱스는
WHERE created_at > ...만으로 검색할 때 도움이 되지 않습니다. - 인덱스가 강제하는 유니크 제약이나 배제 제약에서 무시됩니다.
CREATE UNIQUE INDEX ... (x) INCLUDE (y)라면 유일성은x에만 적용되고(x, y)조합에는 적용되지 않습니다.UNIQUE와PRIMARY KEY제약에도INCLUDE를 붙일 수 있습니다. - 검색 키가 아니므로 해당 인덱스가 다룰 수 없는 데이터 타입이어도 됩니다.
- 현재 B-tree, GiST, SP-GiST만 지원합니다.
- 표현식은 넣을 수 없습니다. 인덱스 전용 스캔에서 쓸 수 없기 때문입니다.
- 인덱스 열은
INCLUDE열을 포함해 최대 32개입니다(빌드 시 변경 가능).
INCLUDE가 생기기 전에는 (x, y)처럼 페이로드 열을 일반 키 열 뒤에 붙였습니다. 이 방식으로는 키 열만의 유일성을 강제할 수 없습니다. 문서는 B-tree의 접미사 절단(suffix truncation)이 상위 레벨에서 비키 열을 제거한다는 점도 언급하면서, 페이로드 열을 명시적으로 비키 열로 정의하는 편이 상위 레벨 튜플을 확실히 작게 유지한다고 설명합니다.
문서가 말하는 비용
문서는 비키 페이로드 열, 특히 넓은 열을 추가하는 데 보수적이라고 권합니다. 이유는 두 가지입니다.
- 인덱스 튜플이 인덱스 종류의 최대 크기를 넘으면 데이터 삽입이 실패합니다.
- 비키 열은 테이블 데이터를 복제하므로 인덱스 크기가 커지고, 그 결과 검색이 느려질 수 있습니다.
그리고 앞의 가시성 맵 이야기와 이어지는 문장이 있습니다. 인덱스 전용 스캔이 힙에 접근할 필요가 없을 만큼 테이블이 천천히 변하지 않는다면 페이로드 열을 넣을 이유가 별로 없다는 것입니다. 행이 수정되면 새 행 버전이 생기고 인덱스 항목도 다시 써야 할 수 있으니, 자주 바뀌는 테이블에 넓은 INCLUDE를 붙이면 비용만 늘 수 있다는 뜻으로 읽을 수 있습니다.
운영 중인 테이블에 이런 인덱스를 새로 만든다면 쓰기를 막지 않는 방법과 그 제약을 CREATE INDEX CONCURRENTLY 글에서 확인할 수 있습니다.
표현식 인덱스와 부분 인덱스
표현식 인덱스도 원리상 인덱스 전용 스캔에 쓸 수 있지만, 문서는 현재 플래너가 이 경우에 그리 영리하지 않다고 적습니다. 플래너는 쿼리에 필요한 열이 모두 인덱스에 있을 때만 인덱스 전용 스캔 후보로 봅니다. SELECT f(x) FROM tab WHERE f(x) < 1;에서 x 자체는 인덱스에 없으므로 후보가 되지 못합니다. 문서가 제시하는 우회 방법은 원래 열을 INCLUDE로 넣는 것입니다.
CREATE INDEX tab_f_x ON tab (f(x)) INCLUDE (x);
부분 인덱스는 반대 방향의 예외가 있습니다. WHERE success 조건으로 만든 부분 인덱스라면 success 열이 인덱스에 없어도, 인덱스에 들어 있는 항목은 모두 success = true이므로 WHERE subject = ... AND success 쿼리에 인덱스 전용 스캔을 쓸 수 있습니다. 문서에 따르면 PostgreSQL 9.6부터 이 경우를 인식합니다.
진단: 플래너의 선택과 실행 중 힙 방문은 따로 본다
여기서 두 가지 질문을 섞지 않는 것이 중요합니다. 플래너가 인덱스 전용 스캔을 고르는지와, 실제 실행에서 힙을 얼마나 방문했는지는 다른 증거로 확인합니다.
1) 플래너가 골랐는가: EXPLAIN
EXPLAIN만 실행하면 쿼리는 실행되지 않고 계획만 나옵니다. 계획에 Index Only Scan using ... 노드가 있으면 플래너가 이 방식을 골랐다는 뜻일 뿐, 실행 중 힙을 건너뛰었다는 증거는 아닙니다. 플래너는 pg_class.relallvisible 같은 추정치를 씁니다. 커버링 인덱스가 있어도 플래너가 다른 계획을 고를 수 있습니다.
2) 실행 중 힙을 방문했는가: EXPLAIN ANALYZE
EXPLAIN ANALYZE는 쿼리를 실제로 실행합니다. 문서는 출력은 버려도 문장의 다른 부작용은 그대로 일어난다고 경고하므로, 데이터를 바꾸는 문장이라면 BEGIN과 ROLLBACK 사이에서 실행하라고 안내합니다. PostgreSQL 18에서는 ANALYZE가 BUFFERS를 자동으로 켭니다. 아래는 EXPLAIN 사용법 문서(14.1절)에 실린 출력입니다.
Index Only Scan using tenk1_four_unique1_idx on tenk1 (cost=0.29..6.90 rows=1 width=8) (actual time=0.006..0.007 rows=1.00 loops=1)
Index Cond: ((four >= 1) AND (four <= 3) AND (unique1 = 42))
Heap Fetches: 0
Index Searches: 3
Buffers: shared hit=7
Heap Fetches는 이번 실행에서 인덱스 전용 스캔이 힙을 방문한 횟수로 읽는 것이 자연스럽습니다. 다만 이 글에서 확인한 EXPLAIN 관련 문서 두 쪽은 이 줄을 예시로 보여 줄 뿐 정의를 적지 않았으므로, 이 해석은 노드 이름과 가시성 맵 설명에서 끌어낸 추론입니다. 반환 행 수에 비해 이 값이 크다면 all-visible이 아닌 페이지를 많이 만났다고 볼 수 있습니다. 실행할 때마다 값이 달라질 수 있으므로 한 번의 결과로 결론을 내리지 않는 편이 안전합니다.
3) 테이블 상태: pg_class와 통계 뷰
SELECT relname,
relpages,
relallvisible,
round(100.0 * relallvisible / nullif(relpages, 0), 1) AS all_visible_pct
FROM pg_class
WHERE oid = 'orders'::regclass;
relpages와 relallvisible은 문서 표현으로 “플래너가 쓰는 추정치일 뿐”이며 VACUUM, ANALYZE, CREATE INDEX 같은 일부 DDL이 갱신합니다. 마지막 갱신 이후의 변경은 반영되지 않습니다.
SELECT relname,
n_ins_since_vacuum,
n_dead_tup,
last_vacuum,
last_autovacuum
FROM pg_stat_all_tables
WHERE relid = 'orders'::regclass;
n_ins_since_vacuum은 마지막 vacuum 이후 삽입된 행 수 추정치, last_autovacuum은 autovacuum 데몬이 이 테이블을 마지막으로 vacuum한 시각입니다. 인덱스 단위 통계에서는 pg_stat_all_indexes의 idx_tup_read(인덱스에서 꺼낸 항목 수)와 idx_tup_fetch(단순 인덱스 스캔이 테이블에서 가져온 살아 있는 행 수)를 볼 수 있습니다. 문서는 인덱스 전용 스캔으로 힙 방문을 피하면 후자가 더 작아질 수 있다고 설명합니다. 다만 죽은 행이나 커밋되지 않은 행, 비트맵 스캔도 이 차이에 영향을 주므로 이 숫자만으로 원인을 단정할 수는 없습니다.
4) 현재 가시성 맵 그대로: pg_visibility
추정치가 아니라 지금의 가시성 맵을 직접 보려면 부가 모듈 pg_visibility를 씁니다.
CREATE EXTENSION pg_visibility;
SELECT * FROM pg_visibility_map_summary('orders');
pg_visibility_map_summary는 가시성 맵 기준 all-visible 페이지 수와 all-frozen 페이지 수를 돌려줍니다. 블록별로 보려면 pg_visibility_map('orders')를 씁니다. 기본적으로 이 함수들은 슈퍼유저와 pg_stat_scan_tables 역할의 권한을 가진 역할만 실행할 수 있습니다. 같은 모듈의 pg_truncate_visibility_map은 손상이 의심될 때 맵을 비우는 함수이고, 다음 VACUUM이 모든 페이지를 다시 스캔할 때까지 쿼리는 맵을 전부 0으로 취급합니다. 진단 목적이라면 쓸 일이 없습니다.
한계와 주의할 점
- 커버링 인덱스가 있다고 인덱스 전용 스캔이 보장되지는 않습니다. 플래너는 다른 계획을 고를 수 있고, 골랐더라도 all-visible이 아닌 페이지에서는 힙을 읽습니다.
- 결과는 테이블의 변경 패턴과 vacuum 시점에 따라 달라집니다. 같은 쿼리가 vacuum 직후와 대량 갱신 직후에 다른
Heap Fetches를 보일 수 있습니다. relallvisible과 통계 뷰 값은 추정치이고,pg_visibility는 실행 시점의 상태만 보여 줍니다.- 수동
VACUUM, autovacuum 설정 변경, 넓은INCLUDE열 추가는 모두 I/O와 저장 공간, 쓰기 비용에 영향을 줍니다. 이 글은 무엇을 바꿔야 한다고 권하지 않으며, 바꾸기 전에 실제 워크로드와 해당 버전의 문서로 확인해야 합니다. - 이 글은 PostgreSQL 18 문서를 기준으로 합니다. 예를 들어
ANALYZE가BUFFERS를 자동으로 켜는 동작과relallfrozen열은 이 버전 문서에서 확인한 내용이므로, 이전 버전에서는 해당 버전 문서를 따로 확인하세요.
참고한 문서
- PostgreSQL 18 — 11.9 Index-Only Scans and Covering Indexes: 두 기본 조건, 가시성 맵 확인, 커버링 인덱스,
INCLUDE의 비용, 표현식·부분 인덱스 - PostgreSQL 18 — 66.4 Visibility Map: 페이지당 두 비트, 비트를 설정·해제하는 규칙
- PostgreSQL 18 — 24.1 Routine Vacuuming: 24.1.4 가시성 맵 갱신, 24.1.6 삽입 임계값
- PostgreSQL 18 — CREATE INDEX:
INCLUDE절, 열 개수 제한 - PostgreSQL 18 — 14.1 Using EXPLAIN:
Heap Fetches출력 예,EXPLAIN ANALYZE의 부작용 - PostgreSQL 18 — EXPLAIN:
ANALYZE,BUFFERS옵션 - PostgreSQL 18 — 52.11 pg_class:
relpages,reltuples,relallvisible,relallfrozen - PostgreSQL 18 — 27.2 The Cumulative Statistics System:
pg_stat_all_tables,pg_stat_all_indexes - PostgreSQL 18 — F.36 pg_visibility: 가시성 맵 조회 함수와 권한
- PostgreSQL Versioning Policy: 지원 버전과 최신 마이너 버전
링크와 내용은 2026년 10월 27일에 확인했습니다.