한 칼럼이 가진 서로 다른 값의 개수. 인덱스가 실제로 쓰일지를 결정하는 핵심 지표다.
왜 낮으면 인덱스가 무의미한가
- 회원 100만 명 테이블
| gender | 값이 2개 | → 인덱스를 타면 50만 행을 가리킨다 |
|---|---|---|
| 값이 100만 개 | → 인덱스를 타면 1행을 가리킨다 |
gender = 'M'에 인덱스를 쓰면 인덱스를 읽고 → 50만 번 테이블을 랜덤 접근한다. 그냥 테이블을 처음부터 순차로 읽는 편(풀 스캔)이 빠르다. 순차 I/O가 랜덤 I/O보다 훨씬 싸기 때문이다.
그래서 옵티마이저는 일부러 인덱스를 무시한다. "인덱스를 걸었는데 왜 안 타지?"의 가장 흔한 답이 이것이다.
선택도 — 비율로 보는 같은 이야기
-
선택도 = 조건에 맞는 행 수 / 전체 행 수
-
선택도 0.001 (0.1%) — → 인덱스가 압도적으로 유리
-
선택도 0.3 — (30%) → 대개 풀 스캔이 낫다
경계는 엔진과 데이터 분포에 따라 다르지만, 수 %를 넘어가면 인덱스 이점이 급격히 준다는 감각이 중요하다.
낮은 카디널리티를 살리는 법
-- ① 복합 인덱스의 앞 칼럼으로 쓰고, 뒤에 선택도 높은 칼럼을 붙인다
CREATE INDEX idx ON orders (status, created_at);
-- ② 부분 인덱스 — 소수인 값만 골라 색인한다 (PostgreSQL)
CREATE INDEX idx_pending ON orders (created_at) WHERE status = 'PENDING';
②는 전체의 1%뿐인 PENDING만 담으므로 인덱스가 작고 빠르다. "값 종류는 적지만 분포가 치우친" 경우의 정석이다.
면접 함정
- ❌ "인덱스를 걸었으니 무조건 빠르다" → 카디널리티가 낮으면 옵티마이저가 안 쓴다.
- ⚠️ 통계가 낡으면 옵티마이저가 카디널리티를 잘못 추정해 엉뚱한 계획을 고른다. 대량 적재 뒤에는 통계를 갱신한다.
실제로 확인하는 쿼리
-- 직접 세어 본다
SELECT count(DISTINCT status) AS distinct_values, count(*) AS total,
count(DISTINCT status)::float / count(*) AS selectivity
FROM orders;
-- PostgreSQL: 옵티마이저가 보는 값 (n_distinct 가 음수면 '전체 대비 비율')
SELECT attname, n_distinct, most_common_vals, most_common_freqs
FROM pg_stats WHERE tablename = 'orders' AND attname = 'status';
-- MySQL: 인덱스 통계상의 카디널리티
SHOW INDEX FROM orders; -- Cardinality 칼럼
most_common_vals와 most_common_freqs가 함께 보이면 분포가 얼마나 치우쳤는지까지 알 수 있다. 값 종류는 3개인데 하나가 99%라면, 그 흔한 값은 풀 스캔이 낫고 나머지 1%는 인덱스가 유리하다 — 옵티마이저는 이 히스토그램을 보고 같은 칼럼에서도 값에 따라 다른 계획을 고른다.
치우친 분포를 다루는 법
-- 부분 인덱스 — 소수인 값만 색인 (PostgreSQL)
CREATE INDEX idx_pending ON orders (created_at) WHERE status = 'PENDING';
-- 통계 정밀도를 올린다 — 히스토그램 구간 수를 늘려 추정을 정확하게
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;
두 번째는 분포가 복잡한 칼럼에서 옵티마이저의 오추정을 줄이는 표준 처방이다.