본문 바로가기
TYLER SONGBlog
← Blog index
CS학습8 min read

Cardinality: 컬럼 값의 고유성 정도와 관계의 대응 수가 쿼리 계획을 좌우한다

카디널리티(Cardinality)는 데이터베이스에서 두 가지 의미로 쓰인다. 하나는 컬럼에 존재하는 고유 값(distinct value)의 수나 비율이고, 다른 하나는 테이블 간 관계에서 한 행이 대응하는 행의 수(1:1, 1:N, N:M)다. 옵티마이저(Optimizer)는 카디널리티 추정으로 인덱스 사용 여부와 조인 순서를 결정하므로, 통계 정보와 인덱

By 송민성

1. 개념

카디널리티(Cardinality)는 집합의 원소 개수를 뜻하는 수학 용어에서 왔으며, 데이터베이스에서는 문맥에 따라 세 가지로 쓰인다.

  1. 컬럼 카디널리티: 컬럼이 가진 고유 값(distinct value)의 개수. 값 종류가 많으면 고카디널리티(high cardinality), 적으면 저카디널리티(low cardinality)라고 한다.

- 고카디널리티: user_id, email, order_id - 저카디널리티: gender, is_deleted, status(몇 개 값만 존재)

  1. 관계 카디널리티: 두 엔티티(entity) 사이에서 한쪽 행 하나가 상대편 몇 개의 행과 연결되는지를 나타내는 대응 관계. 일대일(1:1), 일대다(1:N), 다대다(N:M)가 있다.
  2. 쿼리 카디널리티(추정 행 수): 실행 계획의 각 단계가 반환할 것으로 예상(또는 실제로 반환한)하는 행 수. 옵티마이저는 이 값을 추정하여 계획을 고른다.

컬럼 카디널리티와 선택도(selectivity)는 함께 쓰인다. 선택도는 조건이 전체 행 중 선택하는 비율이고, 흔히 고유 값 수 / 전체 행 수를 컬럼의 선택도 지표로 쓰기도 한다. 값이 1에 가까울수록 값이 거의 유일하다는 뜻이다.

2. 왜 사용하는가

  • 인덱스 설계 판단: 고카디널리티 컬럼의 조건은 적은 수의 행만 골라내므로 B-tree 인덱스의 효과가 크다. 저카디널리티 컬럼 단독 인덱스는 대부분의 행을 읽게 되어 효과가 작다.
  • 옵티마이저의 계획 선택: 인덱스 스캔(Index Scan)과 순차 스캔(Seq Scan) 중 무엇이 유리한지, 조인 순서와 방식(Nested Loop, Hash Join, Merge Join)을 정할 때 추정 행 수를 사용한다.
  • 스키마 설계: 관계 카디널리티에 따라 외래 키(Foreign Key) 위치, 연결 테이블(junction table) 필요 여부가 결정된다.
  • 성능 문제 진단: 실행 계획에서 추정 행 수와 실제 행 수가 크게 어긋나면 통계 문제나 상관관계 문제를 의심할 수 있다.

3. 동작 원리

통계 수집

PostgreSQL은 ANALYZE(또는 autovacuum의 자동 analyze)가 테이블을 샘플링해 컬럼별 통계를 pg_statistic에 저장하며, 읽기 쉬운 뷰가 pg_stats다. 주요 항목은 다음과 같다.

  • n_distinct: 고유 값 수의 추정치. 양수면 개수 자체, 음수면 -(고유 값 수 / 행 수) 비율을 뜻한다. 예를 들어 -1은 모든 값이 유일하다는 의미다.
  • most_common_vals, most_common_freqs: 가장 자주 나오는 값과 그 빈도.
  • histogram_bounds: 최빈값을 제외한 나머지 값의 분포를 구간으로 나눈 경계값.
  • correlation: 물리적 저장 순서와 값 순서의 상관 정도.

추정 행 수 계산

예를 들어 WHERE status = 'PAID' 조건이 있으면 옵티마이저는 most_common_vals에서 'PAID'의 빈도를 찾아 전체 행 수 × 빈도로 추정 행 수를 계산한다. 최빈값 목록에 없는 값은 남은 비율을 나머지 고유 값에 나누어 추정한다. 범위 조건은 히스토그램으로 추정한다.

여러 조건의 결합

기본적으로 PostgreSQL은 서로 다른 컬럼의 조건이 독립이라고 가정하고 선택도를 곱한다. 두 컬럼이 강하게 연관되어 있으면(예: city와 zipcode) 이 가정이 깨져 추정이 크게 틀릴 수 있다. 이 경우 확장 통계(CREATE STATISTICS)로 보완한다.

관계 카디널리티

  • 1:1: 한쪽 행이 상대편 최대 1개와 연결. 외래 키에 UNIQUE 제약을 건다.
  • 1:N: 'N' 쪽 테이블에 외래 키를 둔다.
  • N:M: 두 테이블의 기본 키를 외래 키로 가지는 연결 테이블을 둔다.

4. 코드 예제

sql
-- 실습용 테이블 구성 DROP TABLE IF EXISTS orders; DROP TABLE IF EXISTS users; CREATE TABLE users ( id bigint PRIMARY KEY, email text NOT NULL UNIQUE, city text NOT NULL ); CREATE TABLE orders ( id bigint PRIMARY KEY, user_id bigint NOT NULL REFERENCES users(id), status text NOT NULL ); -- 사용자 10만 명, 도시 10개 INSERT INTO users (id, email, city) SELECT g, 'user' || g || '@example.com', 'city_' || (g % 10) FROM generate_series(1, 100000) AS g; -- 주문 20만 건, 대부분 PAID이고 일부만 CANCELED INSERT INTO orders (id, user_id, status) SELECT g, (g % 100000) + 1, CASE WHEN g % 100 = 0 THEN 'CANCELED' ELSE 'PAID' END FROM generate_series(1, 200000) AS g; CREATE INDEX idx_orders_user_id ON orders(user_id); CREATE INDEX idx_orders_status ON orders(status); ANALYZE users; ANALYZE orders;
sql
-- 1) 컬럼 카디널리티 직접 계산 SELECT count(*) AS total_rows, count(DISTINCT status) AS status_distinct, count(DISTINCT user_id) AS user_id_distinct, round(count(DISTINCT user_id)::numeric / count(*), 4) AS user_id_ratio FROM orders; -- 2) 옵티마이저가 가진 통계 확인 SELECT attname, n_distinct, most_common_vals, most_common_freqs FROM pg_stats WHERE schemaname = 'public' AND tablename = 'orders' AND attname IN ('user_id', 'status');
sql
-- 3) 추정 행 수 vs 실제 행 수 비교 EXPLAIN (ANALYZE) SELECT * FROM orders WHERE status = 'CANCELED'; EXPLAIN (ANALYZE) SELECT * FROM orders WHERE status = 'PAID';

출력에서 rows=(추정)와 actual ... rows=(실제)를 비교한다. 소수의 행만 선택하는 'CANCELED' 조건은 인덱스 기반 스캔이, 대부분을 선택하는 'PAID' 조건은 순차 스캔이 선택되기 쉽다. 실제 계획은 데이터와 설정에 따라 달라질 수 있다.

sql
-- 4) 관계 카디널리티 검증: users 1 : N orders SELECT o_cnt AS orders_per_user, count(*) AS users FROM ( SELECT u.id, count(o.id) AS o_cnt FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id ) t GROUP BY o_cnt ORDER BY o_cnt; -- 5) 상관된 컬럼에 대한 확장 통계 CREATE TABLE addresses ( id bigint PRIMARY KEY, city text, zipcode text ); INSERT INTO addresses SELECT g, 'city_' || (g % 100), 'zip_' || (g % 100) FROM generate_series(1, 100000) AS g; CREATE STATISTICS st_addr_city_zip (dependencies, ndistinct) ON city, zipcode FROM addresses; ANALYZE addresses; EXPLAIN (ANALYZE) SELECT * FROM addresses WHERE city = 'city_1' AND zipcode = 'zip_1';

확장 통계가 없으면 두 조건의 선택도를 곱해 실제보다 훨씬 적은 행을 예상한다. CREATE STATISTICS 후 ANALYZE를 실행하면 추정이 실제에 가까워진다.

5. 시간 복잡도 또는 성능 특성

  • count(DISTINCT col)은 정확한 값을 구하기 위해 정렬이나 해시 집계가 필요해 큰 테이블에서 비용이 크다. 운영 환경에서는 pg_stats.n_distinct 같은 추정치를 먼저 확인하는 편이 낫다.
  • ANALYZE는 전체가 아닌 샘플을 읽는다. 샘플 크기는 default_statistics_target(기본값 100)에 비례하며, 값을 높이면 통계 정밀도는 올라가지만 ANALYZE 시간과 통계 저장 공간이 늘어난다. 컬럼 단위로 ALTER TABLE ... ALTER COLUMN ... SET STATISTICS n으로 조정할 수 있다.
  • 샘플링 기반이므로 n_distinct는 특히 고카디널리티 컬럼에서 오차가 생길 수 있다. 필요하면 ALTER TABLE ... ALTER COLUMN ... SET (n_distinct = 값)으로 직접 지정할 수 있다.
  • 일반적인 B-tree 인덱스 조회는 O(log n)이지만, 조건이 선택하는 행이 많으면(저선택도) 읽어야 할 힙(heap) 페이지가 늘어 순차 스캔이 더 빠를 수 있다. 이 경계는 고정 비율이 아니라 비용 모델(random_page_cost, seq_page_cost 등)과 데이터 배치에 따라 달라진다.

6. 실무 사용 사례

  • 복합 인덱스 컬럼 순서 결정: 등치 조건이 있는 컬럼 중 선택도가 높은 컬럼을 앞에 두는 것이 일반적인 출발점이다. 다만 범위 조건 여부, 정렬 요구, 쿼리 패턴이 우선이므로 카디널리티만으로 결정하지 않는다.
  • 부분 인덱스(Partial Index): 저카디널리티 컬럼에서 소수 값만 자주 조회한다면 해당 값에 한정한 인덱스가 효율적이다.
sql
CREATE INDEX idx_orders_canceled ON orders(id) WHERE status = 'CANCELED';
  • 조인 성능 진단: 실행 계획에서 추정 행 수가 실제와 수십 배 이상 어긋나는 노드를 찾고, 통계 갱신이나 확장 통계로 개선한다.
  • 모니터링 시스템: 시계열/메트릭 시스템에서는 레이블(label) 값 조합의 수(series 카디널리티)가 저장소 비용과 쿼리 성능을 좌우한다. user_id처럼 값이 무한히 늘어나는 항목을 레이블로 쓰는 설계는 피한다.
  • 스키마 설계 검증: 1:N으로 가정한 관계에 실제로는 N:M 데이터가 들어오는지 위 4번 쿼리처럼 분포를 조회해 확인한다.

7. 주의할 점

  • 저카디널리티 컬럼에 인덱스가 무조건 쓸모없는 것은 아니다. 값 분포가 치우쳐 있고 희귀 값을 자주 조회하면 유용하다(위 CANCELED 사례).
  • 고카디널리티라고 항상 좋은 인덱스 후보는 아니다. 해당 컬럼을 조건으로 쓰지 않으면 쓰기 비용과 저장 공간만 늘어난다.
  • 통계는 ANALYZE 시점의 스냅샷이다. 대량 적재나 삭제 직후에는 통계가 낡아 잘못된 계획이 선택될 수 있으므로 수동 ANALYZE를 고려한다.
  • 컬럼 간 상관관계가 있으면 독립 가정으로 추정이 크게 틀린다. 확장 통계를 검토한다.
  • 함수나 표현식을 적용한 조건(lower(email) = ...)은 컬럼 통계를 그대로 쓰지 못한다. 표현식 인덱스를 만들면 해당 표현식에 대한 통계도 수집된다.
  • 'Cardinality'는 맥락에 따라 의미가 달라진다. ERD의 관계 표기(1:N)와 인덱스/통계의 고유 값 수를 구분해서 소통해야 한다.
  • 파라미터화된 쿼리(prepared statement)에서는 값에 따라 최적 계획이 달라질 수 있다. 분포가 심하게 치우친 컬럼에서 범용(generic) 계획이 비효율적인지 확인한다.

8. 핵심 정리

  • 카디널리티는 컬럼의 고유 값 수, 테이블 관계의 대응 수(1:1, 1:N, N:M), 쿼리 단계의 행 수 추정이라는 세 맥락에서 쓰인다.
  • 옵티마이저는 pg_stats의 n_distinct, 최빈값, 히스토그램으로 선택도와 추정 행 수를 계산해 스캔 방식과 조인 순서를 정한다.
  • 인덱스 효과는 컬럼 카디널리티만이 아니라 "조건이 실제로 선택하는 행의 비율"에 좌우된다.
  • EXPLAIN (ANALYZE)로 추정 행 수와 실제 행 수를 비교하고, 차이가 크면 ANALYZE, 통계 목표치 조정, CREATE STATISTICS로 대응한다.
  • 관계 카디널리티는 외래 키, UNIQUE 제약, 연결 테이블 같은 스키마 구조로 구현하며, 실제 데이터 분포를 쿼리로 검증한다.