인덱스 전용 스캔과 커버링 인덱스
인덱스 전용 스캔과 커버링 인덱스 (Index-Only Scans and Covering Indexes)
이번 페이지에서는 PostgreSQL의 **인덱스 전용 스캔(index-only scan)**과 **커버링 인덱스(covering index)**라는 성능 기법을 같이 살펴볼게요. 이름이 좀 무섭게 들리지만, 요점은 하나예요 — "인덱스만으로 쿼리를 해결해서 테이블(heap)까지 안 가도 되는 방법". 옆에서 차근차근 설명해 드릴게요.
인덱스 전용 스캔이 필요한 이유 (Why index-only scans)
PostgreSQL의 모든 인덱스는 **보조 인덱스(secondary index)**예요. 즉 각 인덱스는 테이블의 본체 데이터 영역(PostgreSQL 용어로는 heap)과는 별도로 저장돼요. 그래서 일반적인 인덱스 스캔에서는 행 하나를 가져올 때마다 인덱스와 heap를 둘 다 읽어야 합니다.
게다가 특정 WHERE 조건에 맞는 인덱스 항목들은 서로 가까이 모여 있는 경우가 많지만, 그 항목들이 가리키는 테이블 행은 heap 어디에나 있을 수 있어요. 그래서 인덱스 스캔에서 heap에 접근하는 부분은 랜덤 액세스가 많이 발생하고, 특히 회전하는 디스크에서는 느릴 수밖에 없죠. (비트맵 스캔은 heap 접근을 정렬된 순서로 해서 이 비용을 줄이려 하지만, 그걸로는 한계가 있어요.)
이 성능 문제를 해결하기 위해 PostgreSQL은 **인덱스 전용 스캔(index-only scan)**을 지원해요. heap에 전혀 접근하지 않고 인덱스만으로 쿼리에 답할 수 있는 방식이죠. 기본 아이디어는 heap 항목을 확인하는 대신, 각 인덱스 항목에서 값을 바로 꺼내오는 거예요.
이 방식을 쓸 수 있는 조건에는 다음과 같은 두 가지 근본적인 제약이 있어요.
-
인덱스 유형이 인덱스 전용 스캔을 지원해야 해요. B-tree 인덱스는 항상 지원하고요, GiST와 SP-GiST 인덱스는 일부 연산자 클래스에서 지원해요. 다른 인덱스 유형은 지원하지 않죠. 근본 요건은 인덱스가 각 항목의 원래 데이터 값을 물리적으로 저장하거나 재구성할 수 있어야 한다는 거예요. 반례를 들자면 GIN 인덱스는 각 항목이 보통 원래 값의 일부만 담고 있어서 인덱스 전용 스캔을 지원하지 못해요.
-
쿼리가 인덱스에 저장된 컬럼만 참조해야 해요. 예를 들어
x와y컬럼에 인덱스가 있고, 테이블에는z컬럼도 있다고 해 볼게요. 그러면 아래 쿼리들은 인덱스 전용 스캔을 쓸 수 있어요.
SELECT x, y FROM tab WHERE x = 'key';
SELECT x FROM tab WHERE x = 'key' AND y < 42;
하지만 아래 쿼리들은 쓸 수 없어요.
SELECT x, z FROM tab WHERE x = 'key';
SELECT x FROM tab WHERE x = 'key' AND z < 42;
(표현식 인덱스와 부분 인덱스는 이 규칙을 조금 복잡하게 만들긴 하는데, 이건 아래에서 다룰게요.)
가시성 문제와 visibility map (Visibility and the visibility map)
위 두 가지 기본 요건이 충족되면, 쿼리에 필요한 모든 값이 인덱스에서 얻어지므로 인덱스 전용 스캔이 물리적으로 가능해져요. 하지만 PostgreSQL의 모든 테이블 스캔에는 추가 요건이 하나 있어요 — 가져온 각 행이 쿼리의 MVCC 스냅샷에 "보이는(visible)" 행인지 확인해야 하죠.
가시성 정보는 인덱스 항목에는 저장되지 않고 heap 항목에만 저장돼요. 그래서 겉보기에는 모든 행 검색이 어쨌든 heap에 접근해야 할 것 같죠. 실제로 행이 최근에 수정됐다면 그렇습니다. 그런데 거의 안 바뀌는 데이터에서는 이 문제를 우회할 방법이 있어요.
PostgreSQL은 테이블 heap의 각 페이지에 대해, 그 페이지에 있는 모든 행이 현재 및 미래의 모든 트랜잭션에 보이기에 충분히 오래됐는지를 추적해요. 이 정보는 테이블의 visibility map이라는 곳에 비트 하나로 저장돼요. 인덱스 전용 스캔은 후보 인덱스 항목을 찾은 뒤, 해당 heap 페이지의 visibility map 비트를 확인해요.
- 비트가 설정돼 있으면 → 행이 보이는 게 확실하므로, 추가 작업 없이 데이터를 바로 반환해요.
- 비트가 설정돼 있지 않으면 → heap 항목을 방문해서 그 행이 보이는지 확인해야 하므로, 일반 인덱스 스캔에 비해 성능 이점이 없어요.
성공하는 경우에도 이 방식은 heap 접근을 visibility map 접근으로 바꿔치기하는 셈이에요. 그런데 visibility map은 그것이 설명하는 heap보다 약 4자릿수(10000배) 작아서, 접근에 필요한 물리 I/O가 훨씬 적어요. 대부분의 상황에서 visibility map은 계속 메모리에 캐시돼 있죠.
요약하자면 — 두 가지 기본 요건만으로 인덱스 전용 스캔이 가능하지만, 실제로 이득이 되려면 테이블 heap 페이지 중 상당수가 all-visible 비트가 설정돼 있어야 해요. 그런데 행의 상당 부분이 변하지 않는 테이블은 실무에서 아주 흔하므로, 이 스캔 방식은 실제로 매우 유용해요.
커버링 인덱스 (Covering indexes)
인덱스 전용 스캔을 효과적으로 쓰려면, 자주 실행하는 특정 유형의 쿼리가 필요로 하는 컬럼을 포함하도록 설계된 인덱스 — 바로 커버링 인덱스(covering index) — 를 만들면 돼요.
쿼리는 보통 검색 조건에 쓰는 컬럼보다 더 많은 컬럼을 가져와야 하죠. 그래서 PostgreSQL은 일부 컬럼은 "페이로드(payload)" 역할만 하고 검색 키에는 포함되지 않는 인덱스를 만들 수 있게 해줘요. 이건 추가 컬럼을 나열하는 INCLUDE 절을 추가해서 하면 돼요.
예를 들어 이런 쿼리를 자주 실행한다고 해 볼게요.
SELECT y FROM tab WHERE x = 'key';
이런 쿼리를 빠르게 하는 전통적인 방법은 x에만 인덱스를 거는 거예요. 하지만 아래처럼 인덱스를 정의하면,
CREATE INDEX tab_x_y ON tab(x) INCLUDE (y);
y를 heap을 방문하지 않고 인덱스에서 얻을 수 있으므로, 이 쿼리들을 인덱스 전용 스캔으로 처리할 수 있어요.
컬럼 y는 인덱스의 검색 키가 아니므로, 인덱스가 처리할 수 있는 데이터 타입일 필요가 없어요. 그냥 인덱스에 저장만 되고, 인덱스 메커니즘이 해석하지 않죠. 또한 인덱스가 유니크 인덱스라면, 예를 들어
CREATE UNIQUE INDEX tab_x_y ON tab(x) INCLUDE (y);
유니크 조건은 x와 y의 조합이 아니라 x 컬럼에만 적용돼요. (INCLUDE 절은 UNIQUE와 PRIMARY KEY 제약 조건 안에도 쓸 수 있어서, 이런 인덱스를 만드는 다른 문법을 제공해요.)
주의사항 (Caveats)
인덱스에 비키(non-key) 페이로드 컬럼을 추가하는 것은, 특히 넓은(wide) 컬럼이라면 보수적으로 접근하는 게 현명해요.
- 인덱스 튜플이 인덱스 유형이 허용하는 최대 크기를 초과하면 데이터 삽입이 실패해요.
- 어쨌든 비키 컬럼은 인덱스 테이블의 데이터를 중복 저장하므로 인덱스 크기를 부풀려서 검색을 느리게 만들 수 있어요.
- 테이블이 충분히 천천히 변해서 인덱스 전용 스캔이 heap에 접근할 필요가 없을 때만 페이로드 컬럼을 넣는 게 의미가 있어요. 어차피 heap 튜플을 방문해야 한다면, 그 컬럼 값을 거기서 얻는 게 비용이 더 들지 않으니까요.
다른 제약으로는, 표현식은 현재 포함 컬럼(included column)으로 지원되지 않고, B-tree, GiST, SP-GiST 인덱스만 포함 컬럼을 지원해요.
INCLUDE 기능 이전의 방식 (Before INCLUDE)
PostgreSQL에 INCLUDE 기능이 생기기 전에는, 사람들이 페이로드 컬럼을 일반 인덱스 컬럼으로 써서 커버링 인덱스를 만들기도 했어요. 즉 이렇게요.
CREATE INDEX tab_x_y ON tab(x, y);
y를 WHERE 절에 쓸 생각이 전혀 없는데도요. 이 방식은 추가 컬럼이 끝(trailing) 컬럼이면 잘 동작해요. 앞쪽(leading) 컬럼으로 만드는 건 권장하지 않아요. 그리고 이 방식은 인덱스가 키 컬럼에 유니크를 강제하게 하려는 경우는 지원하지 못해요.
서픽스 절단 (Suffix truncation)
서픽스 절단(suffix truncation)은 항상 비키 컬럼을 상위 B-Tree 레벨에서 제거해요. 페이로드 컬럼은 인덱스 스캔을 안내하는 데 쓰이지 않으니까요. 절단 과정은 또, 남아 있는 키 컬럼의 접두사가 최하위 B-Tree 레벨의 튜플을 설명하기에 충분하다면 하나 이상의 끝 키 컬럼도 제거해요. 실제로 INCLUDE 절이 없는 커버링 인덱스는 상위 레벨에 사실상 페이로드인 컬럼을 저장하지 않는 경우가 많아요. 하지만 페이로드 컬럼을 명시적으로 비키 컬럼으로 정의하면, 상위 레벨의 튜플을 안정적으로 작게 유지해 줘요.
표현식 인덱스와의 상호작용 (Index-only scans with expression indexes)
원칙적으로 인덱스 전용 스캔은 표현식 인덱스와 함께 쓸 수 있어요. 예를 들어 x가 테이블 컬럼이고 f(x)에 인덱스가 있다면, 아래 쿼리를
SELECT f(x) FROM tab WHERE f(x) > 1;
인덱스 전용 스캔으로 실행할 수 있어야 하죠. 그리고 f()가 계산 비용이 비싼 함수라면 이건 매우 매력적이에요.
하지만 PostgreSQL의 플래너는 현재 이런 경우에 그렇게 영리하지 않아요. 플래너는 쿼리에 필요한 모든 컬럼이 인덱스에서 얻어질 때만 그 쿼리가 인덱스 전용 스캔으로 실행될 수 있다고 간주해요. 이 예시에서 x는 f(x)라는 문맥에서만 필요하지만, 플래너는 그걸 눈치채지 못하고 인덱스 전용 스캔이 불가능하다고 결론 내려요. 인덱스 전용 스캔이 충분히 가치 있다고 판단되면, x를 포함 컬럼으로 추가해서 우회할 수 있어요. 예를 들어
CREATE INDEX tab_f_x ON tab (f(x)) INCLUDE (x);
한 가지 더 주의할 점이 있어요 — f(x) 재계산을 피하는 게 목적이라면, 플래너가 인덱스 가능한 WHERE 절에 없는 f(x) 사용을 인덱스 컬럼과 매칭시키지 않을 수도 있어요. 위에 보여준 것 같은 단순한 쿼리에서는 보통 잘 맞추지만, 조인이 포함된 쿼리에서는 그렇지 않을 수 있죠. 이런 결함은 PostgreSQL의 미래 버전에서 해결될 수도 있어요.
부분 인덱스와의 상호작용 (Index-only scans with partial indexes)
부분 인덱스(partial index)도 인덱스 전용 스캔과 흥미로운 상호작용이 있어요. 다음 예시의 부분 인덱스를 생각해 볼게요.
CREATE UNIQUE INDEX tests_success_constraint ON tests (subject, target)
WHERE success;
원칙적으로 이 인덱스로 이런 쿼리를 만족시키는 인덱스 전용 스캔을 할 수 있어요.
SELECT target FROM tests WHERE subject = 'some-subject' AND success;
여기에 문제가 있어요: WHERE 절이 success를 참조하는데, success는 인덱스의 결과 컬럼으로는 얻을 수 없어요. 그래도 인덱스 전용 스캔은 가능해요. 왜냐하면 실행 시점에 WHERE 절의 그 부분을 다시 확인(recheck)할 필요가 없기 때문이죠. 인덱스에 있는 모든 항목은 필연적으로 success = true이므로, 플랜에서 그걸 명시적으로 확인할 필요가 없어요. PostgreSQL 9.6 이상에서는 이런 경우를 인식해서 인덱스 전용 스캔을 허용하지만, 그보다 오래된 버전은 허용하지 않아요.