복합 인덱스 칸 순서 정하는 법
여러 회사가 함께 쓰는 업무 시스템에 전표 테이블이 있고, 인덱스가 이렇게 걸려 있다고 해 보겠습니다.
CREATE INDEX ON "Voucher" (date, "companyId");화면의 조회는 늘 「이 회사의 이번 달 전표」입니다.
SELECT * FROM "Voucher" WHERE "companyId" = $1 AND date >= '2026-09-01' AND date < '2026-10-01';조건에 쓰는 두 칸이 모두 인덱스에 들어 있습니다. 그런데 칸 순서를 ("companyId", date) 로 바꾼 인덱스가 이 조회에는 더 잘 맞습니다. 같은 두 칸인데 순서가 결과를 가릅니다.
느리면 인덱스를 더하면 된다는 생각
인덱스는 흔히 「조회를 빠르게 하는 장치, 조건에 쓰는 칸에 걸면 되는 것」으로 이해합니다. 그러면 조회가 느릴 때마다 조건 칸에 인덱스를 하나씩 더하게 됩니다.
이 이해로는 두 가지가 설명되지 않습니다. 같은 칸들로 만든 인덱스가 순서에 따라 효과가 다른 이유, 그리고 인덱스를 늘릴수록 저장이 느려지는 이유입니다. 둘 다 인덱스가 정렬된 사본이라는 점에서 나옵니다.
정렬된 사본이라는 정체
PostgreSQL 의 기본 인덱스인 B-tree 는 지정한 칸의 값을 정렬해서 행의 위치와 함께 따로 보관합니다. 전화번호부가 좋은 비유입니다. 성으로 정렬하고, 성이 같으면 이름으로 정렬한 책입니다.
이 책으로 「김씨」를 찾기는 쉽습니다. 「김씨 중 이름이 민」으로 시작하는 사람도 쉽습니다. 「이름이 민」으로 시작하는 사람은 어렵습니다. 이름순으로 정렬돼 있지 않아서 책 전체를 넘겨야 합니다.
복합 인덱스의 칸 순서가 중요한 이유가 이것입니다. PostgreSQL 문서는 규칙을 이렇게 적습니다.
The exact rule is that equality constraints on leading columns, plus any inequality constraints on the first column that does not have an equality constraint, will always be used to limit the portion of the index that is scanned.
앞쪽 칸들의 같음 조건과, 같음 조건이 없는 첫 칸의 범위 조건까지가 인덱스에서 읽을 범위를 좁힙니다. 그 뒤 칸의 조건은 좁혀진 범위 안에서 하나씩 확인하는 데 쓰입니다.
같음 조건 칸을 앞에, 범위 조건 칸을 뒤에
처음의 두 인덱스를 이 규칙에 대 보겠습니다.
(date, "companyId") 에서 첫 칸 date 는 범위 조건입니다. 범위로 좁히면 거기서 멈춥니다. 9월 한 달의 모든 회사 전표를 인덱스에서 읽고, 그중 이 회사 것을 골라냅니다. 회사가 백 곳이면 백 곳 분량을 읽습니다.
("companyId", date) 에서 첫 칸은 같음 조건이고, 둘째 칸이 범위 조건입니다. 이 회사의 구역으로 바로 가서, 그 안에서 9월 범위만 읽습니다. 읽는 양이 이 회사 한 곳 분량입니다.
그래서 여러 회사가 함께 쓰는 시스템에서는 거의 모든 인덱스가 회사 칸으로 시작합니다. 거의 모든 조회에 회사 조건이 같음으로 들어가기 때문입니다. 인덱스 칸 순서를 정하는 질문은 하나입니다. 자주 쓰는 조회에서 같음으로 거는 칸이 무엇이고, 범위로 거는 칸이 무엇인가. 같음을 앞에, 범위를 뒤에 둡니다.
외래 키가 인덱스를 만들어 주지 않는다는 점
인덱스가 있는 줄 알았는데 없는 흔한 자리가 외래 키입니다. 기본 키와 유일 제약에는 인덱스가 자동으로 생기지만, 외래 키를 선언하는 쪽은 그렇지 않습니다. PostgreSQL 문서도 이 점을 적어 둡니다.
the declaration of a foreign key constraint does not automatically create an index on the referencing columns.
이게 문제 되는 곳은 조회보다 삭제입니다. 거래처 한 곳을 지우려 하면, 데이터베이스는 그 거래처를 가리키는 전표가 있는지 확인해야 합니다. 전표의 거래처 칸에 인덱스가 없으면 그 확인이 전표 테이블 전체를 읽는 일이 됩니다. 문서가 참조하는 쪽 칸에도 인덱스를 거는 것이 좋은 경우가 많다고 덧붙이는 이유입니다.
인덱스마다 붙는 쓰기 비용
인덱스는 정렬된 사본이라서, 원본 행이 바뀌면 사본도 같이 바뀌어야 합니다. 문서의 표현은 이렇습니다.
After an index is created, the system has to keep it synchronized with the table. This adds overhead to data manipulation operations.
전표 한 장을 저장하면 테이블에 한 번, 인덱스마다 한 번씩 더 씁니다. 인덱스가 다섯 개면 다섯 번입니다. 게다가 인덱스가 걸린 칸을 고치는 UPDATE 는 행을 제자리에서 고치는 최적화(heap-only tuple)를 쓰지 못하게 됩니다. 문서가 거의 쓰이지 않는 인덱스는 지우라고 권하는 이유입니다.
그래서 저는 「조건에 쓰는 칸마다 인덱스」가 아니라 「자주 쓰는 조회마다 인덱스 하나」로 생각하는 편이 맞다고 봅니다. 조회 셋이 ("companyId", date), ("companyId", "partnerId"), ("companyId", kind, date) 를 필요로 한다면 그 셋을 만들고, date 하나짜리나 kind 하나짜리를 따로 더하지 않습니다.
부분 인덱스로 필요한 행만
모든 행이 조회 대상은 아닐 때가 있습니다. 미수 전표만 자주 보고, 수금이 끝난 전표는 거의 보지 않는다면 인덱스에 미수 전표만 넣을 수 있습니다.
CREATE INDEX ON "Voucher" ("companyId", date) WHERE settled = false;인덱스가 작아지고, 수금이 끝난 전표를 저장할 때는 이 인덱스를 고칠 일이 줄어듭니다. 부분 인덱스는 유일 제약에도 쓰입니다. 「사용 중인 것들 사이에서만 코드가 겹치면 안 된다」 같은 조건을 이것으로 표현합니다.
추측하지 말고 실행 계획으로 확인하기
어떤 인덱스가 쓰이는지는 데이터베이스가 정합니다. 인덱스가 있어도 테이블이 작거나, 조건에 맞는 행이 너무 많으면 테이블을 통째로 읽는 쪽을 고르기도 합니다. 그 판단은 추측할 필요 없이 EXPLAIN 으로 볼 수 있습니다.
EXPLAIN ANALYZE
SELECT * FROM "Voucher" WHERE "companyId" = $1 AND date >= '2026-09-01' AND date < '2026-10-01';결과에 Index Scan 이나 Bitmap Index Scan 과 인덱스 이름이 보이면 쓰이고 있고, Seq Scan 이면 테이블 전체를 읽고 있습니다. 인덱스를 더하기 전과 후에 이것을 한 번씩 보는 것이 순서라고 생각합니다. 개발 환경의 작은 데이터에서는 인덱스가 있어도 Seq Scan 이 나오는 것이 정상일 수 있어서, 실제에 가까운 양의 데이터로 봐야 합니다.
여기까지가 확실한 부분
복합 인덱스의 규칙, 외래 키와 인덱스의 관계, 쓰기 비용은 PostgreSQL 문서의 B-tree 인덱스 기준입니다. 다른 인덱스 종류(GIN, BRIN 등)나 다른 데이터베이스는 규칙이 다릅니다. 테이블 전체를 읽을지 인덱스를 쓸지는 통계와 설정에 따라 데이터베이스가 판단하므로, 같은 인덱스라도 데이터 양이 바뀌면 실행 계획이 달라질 수 있습니다.
함께 읽기
- 감사 로그와 변경 이력 설계거래처의 결제 조건이 「월말 마감 30일」에서 「월말 마감 60일」로 바뀌어 있습니다. 거래처 테이블에는 updatedAt 이 있어서 어제 오후 3시에 바뀌었다는 것은 압니다.
- 금액·수량 자료형 선택: 정수, numeric, 부가세 끝수 처리견적서의 품목 세 줄에 부가세를 줄마다 계산해서 더한다고 해 보겠습니다. 같은 견적을 합계에서 부가세를 한 번 계산한 화면과 나란히 놓으면 부가세가 1원 다릅니다.
- 소프트 삭제(deletedAt)의 비용과 대안품목 코드 RM-001 을 잘못 만들어서 지웠다고 해 보겠습니다. 소프트 삭제라 행은 남고 deletedAt 에 시각이 찍힙니다. 이제 같은 코드로 품목을 다시 만들려고 하면 오류가 납니다.
- 트랜잭션 경계는 어디서 나눠야 할까?출하 등록 버튼 하나가 서버에서 하는 일을 적어 보면 이렇습니다.
- 동시 수정 처리: 트랜잭션 격리 수준과 낙관적 잠금두 사람이 같은 거래처 화면을 열었다고 해 보겠습니다. 한 사람은 전화번호를 고치고 저장합니다. 1분 뒤 다른 사람이 주소를 고치고 저장합니다. 두 번째 저장이 끝나면 전화번호는 옛 번호로 돌아가 있습니다.