부분 인덱스
부분 인덱스 (Partial Index)
테이블 전체가 아니라 조건을 만족하는 행만 골라서 인덱스를 만들 수 있어요. 이렇게 특정 부분만 담는 인덱스를 부분 인덱스(partial index) 라고 부르는데요, 조건 표현식을 술어(predicate) 라고 해요. 술어를 만족하는 행에 대해서만 인덱스 항목이 생기기 때문에, 인덱스가 훨씬 가벼워지고 상황에 따라 큰 성능 이득을 볼 수 있어요. 이번에는 부분 인덱스가 어떤 상황에서 진짜 유용한지 예시를 하나씩 따라가면서 살펴볼게요.
대표값을 인덱스에서 빼는 용도
부분 인덱스를 쓰는 가장 큰 이유는 인덱스에 굳이 넣지 않아도 되는 흔한 값들을 제외하는 거예요. 전체 행의 몇 퍼센트 이상을 차지하는 흔한 값은 어차피 옵티마이저가 인덱스를 안 쓰고 그냥 테이블을 훑는 편이라, 그 값을 인덱스에 넣어둘 이유가 없죠. 그렇게 하면 인덱스 크기가 줄어서 실제로 인덱스를 쓰는 조회가 빨라지고, 모든 경우에 인덱스를 갱신할 필요가 없어져서 갱신 작업도 빨라져요.
웹 서버 접속 로그를 저장하는 테이블이 있다고 가정해 볼게요. 대부분의 접근이 우리 조직의 IP 대역에서 오고, 일부만 외부(예를 들어 재택 직원)에서 온다면, 검색이 주로 외부 접근을 대상으로 할 때 우리 조직 서브넷에 해당하는 IP 범위는 인덱스에 넣을 필요가 없어요.
CREATE TABLE access_log (
url varchar,
client_ip inet,
...
);
이런 테이블에 부분 인덱스를 만든다면 이렇게 쓰면 돼요.
CREATE INDEX access_log_client_ip_ix ON access_log (client_ip)
WHERE NOT (client_ip > inet '192.168.100.0' AND
client_ip < inet '192.168.100.255');
인덱스가 커버하는 IP 주소(예를 들어 외부 주소)를 쓰는 조회는 이 인덱스를 잘 활용해요.
SELECT *
FROM access_log
WHERE url = '/index.html' AND client_ip = inet '212.78.10.32';
반면 인덱스에서 제외한 주소를 쓰는 다음 질의는 이 인덱스를 쓸 수 없어요.
SELECT *
FROM access_log
WHERE url = '/index.html' AND client_ip = inet '192.168.100.23';
이런 방식은 흔한 값이 미리 정해져 있어야 하기 때문에, 데이터 분포가 자주 바뀌지 않는 경우에 가장 잘 맞아요. 분포가 변하면 인덱스를 다시 만들어 조정할 수 있지만, 그만큼 유지보수 비용이 붙는 점은 알아두세요.
흥미 없는 값만 걸러내는 용도
반대로 인덱스에 관심 없는 값을 아예 넣지 않는 용도로도 쓸 수 있어요. 예를 들어 테이블에 청구된 주문과 아직 청구되지 않은 주문이 섞여 있고, 청구 전 주문이 극히 일부인데 그 행들이 가장 많이 조회된다면, 청구 전 행만 인덱스로 만들어 성능을 높일 수 있어요.
CREATE INDEX orders_unbilled_index ON orders (order_nr)
WHERE billed is not true;
이 인덱스를 쓸 수 있는 질의는 이렇게 생겼어요.
SELECT * FROM orders WHERE billed is not true AND order_nr < 10000;
흥미롭게도 order_nr을 전혀 건드리지 않는 질의에서도 이 인덱스가 쓰일 수 있어요.
SELECT * FROM orders WHERE billed is not true AND amount > 5000.00;
이 경우 amount 열을 대상으로 한 부분 인덱스보다는 덜 효율적인데, 인덱스 전체를 훑어야 하니까요. 그래도 청구 전 주문이 충분히 적다면, 그 행들을 찾기 위해 이 인덱스를 쓰는 게 이득일 수 있어요. 물론 아래처럼 order_nr만 조건으로 주는 질의는, 그 주문이 청구 전인지 후인지 알 수 없으므로 이 인덱스를 쓸 수 없어요.
SELECT * FROM orders WHERE order_nr = 3501;
인덱스에 담긴 열과 술어에 쓰인 열이 같을 필요는 없다는 점도 예시에서 함께 확인할 수 있어요. 부분 인덱스는 인덱스 대상 테이블의 열만 쓰는 한 임의의 술어를 지원하죠. 다만 술어가 이득을 노리는 질의의 조건과 맞아떨어져야 해요. 부분 인덱스는 질의의 WHERE 조건이 인덱스 술어를 수학적으로 함의한다고 시스템이 인식할 수 있을 때만 사용돼요. PostgreSQL에는 서로 다른 형태로 쓰인 수학적으로 동등한 표현을 알아보는 정교한 정리 증명기가 없어서, "x < 1"이 "x < 2"를 함의하는 정도의 단순한 부등식 함의는 알아볼 수 있지만 그 외에는 술어가 질의 WHERE 조건의 일부와 정확히 일치해야 해요. 매칭은 실행 시점이 아니라 질의 계획 시점에 일어나기 때문에, 파라미터화된 질의 절은 부분 인덱스와 함께 동작하지 않아요. 예를 들어 파라미터를 쓰는 준비된 질의가 "x < ?"를 지정하면, 그 값에 대해 "x < 2"를 함의한다고 보장할 수 없거든요.
부분 고유 인덱스로 제약 걸기
부분 인덱스를 질의에 전혀 쓰지 않는 용도도 있어요. 바로 테이블의 일부 행에만 고유성 제약을 거는 거예요. 예를 들어 시험 결과를 담는 테이블에서, 특정 주제(subject)·대상(target) 조합에 대해 "성공" 항목은 하나만 두고 싶은데 "실패" 항목은 여러 개여도 괜찮다고 해볼게요.
CREATE TABLE tests (
subject text,
target text,
success boolean,
...
);
CREATE UNIQUE INDEX tests_success_constraint ON tests (subject, target)
WHERE success;
성공이 적고 실패가 많은 상황이라면 이 접근이 특히 효율적이에요. 비슷한 방법으로 IS NULL 조건을 쓴 부분 고유 인덱스를 만들면, 열에 NULL을 하나만 허용하도록 하는 것도 가능해요.
주의할 점
부분 인덱스는 시스템의 질의 계획 선택을 덮어쓰는 데 쓸 수도 있어요. 데이터 분포가 특이해서 시스템이 인덱스를 쓰면 안 되는 상황에서도 쓰려고 할 때, 해당 질의에는 그 인덱스가 안 보이게 설정하는 거죠. 다만 평소에는 PostgreSQL이 합리적으로 판단하니, 부분 인덱스를 만든다는 건 여러분이 쿼리 플래너만큼은 이해하고 있다는 뜻이 돼요. 대부분의 경우 일반 인덱스 대비 부분 인덱스의 이득은 작고, 오히려 역효과인 경우도 있으니 주의하세요.
가장 흔한 실수 하나는 파티셔닝 대신 겹치지 않는 부분 인덱스를 대량으로 만드는 거예요.
CREATE INDEX mytable_cat_1 ON mytable (data) WHERE category = 1;
CREATE INDEX mytable_cat_2 ON mytable (data) WHERE category = 2;
CREATE INDEX mytable_cat_3 ON mytable (data) WHERE category = 3;
...
CREATE INDEX mytable_cat_N ON mytable (data) WHERE category = N;
이건 좋지 않은 발상이에요. 거의 확실히 다음과 같은 단일 비부분 인덱스가 더 나아요.
CREATE INDEX mytable_cat_data ON mytable (category, data);
(category 열을 앞에 두는 이유는 다중 열 인덱스 섹션에서 다뤄요.) 큰 인덱스라도 몇 레벨 더 내려갈 뿐인데, 플래너가 여러 부분 인덱스 중 적절한 걸 고르느라 들이는 노력이 더 비싸기 때문이에요. 시스템은 부분 인덱스들 사이의 관계를 이해하지 못해서, 현재 질의에 적용 가능한지 하나씩 일일이 검사해요. 테이블이 커서 단일 인덱스가 정말 나쁜 선택이라면 부분 인덱스 대신 파티셔닝을 고려하세요. 파티셔닝은 시스템이 테이블과 인덱스가 겹치지 않음을 이해하므로 훨씬 좋은 성능을 낼 수 있어요.
부분 인덱스에 대한 더 자세한 내용은 [ston89b], [olson93], [seshadri95] 문헌에서 찾아볼 수 있어요.