- Published on
PostgreSQL 고급 인덱싱 완전 가이드: GIN·GiST·BRIN·Partial Index 실전 활용
- Authors
- Name
- 들어가며
- PostgreSQL 인덱스 유형 개요
- B-tree의 한계와 고급 인덱스의 필요성
- GIN 인덱스 심층 분석
- GiST 인덱스 - 공간 데이터와 범위 타입
- BRIN 인덱스 - 시계열과 대용량 테이블
- Partial Index와 Expression Index
- EXPLAIN ANALYZE를 활용한 인덱스 성능 분석
- 인덱스 블로트 관리와 REINDEX
- 인덱스 유형별 비교표
- 운영 체크리스트
- 마무리

들어가며
PostgreSQL은 B-tree, Hash, GIN, GiST, SP-GiST, BRIN 등 6가지 인덱스 유형을 제공한다. 대부분의 튜토리얼은 B-tree에서 멈추지만, 실무에서는 JSONB 검색, 전문 검색, 공간 쿼리, 시계열 대용량 테이블 등 B-tree만으로는 해결하기 어려운 문제가 빈번하게 발생한다.
이 글에서는 B-tree를 넘어서 GIN, GiST, BRIN, Partial Index, Expression Index의 내부 구조를 이해하고, 각각 어떤 시나리오에서 어떻게 적용해야 하는지를 EXPLAIN ANALYZE 기반의 실제 성능 데이터와 함께 다룬다.
PostgreSQL 인덱스 유형 개요
PostgreSQL이 제공하는 인덱스 유형을 한눈에 정리하면 아래와 같다.
| 인덱스 유형 | 내부 구조 | 최적 사용 사례 | 크기 | 쓰기 비용 |
|---|---|---|---|---|
| B-tree | 균형 트리 | 등호, 범위, 정렬, UNIQUE | 보통 | 낮음 |
| GIN | 역인덱스(Posting List/Tree) | JSONB, 배열, tsvector | 큼 | 높음 |
| GiST | 일반화된 검색 트리 | 공간 데이터, 범위, 근접 검색 | 보통 | 보통 |
| BRIN | 블록 범위 요약 | 시계열, append-only 대용량 | 매우 작음 | 매우 낮음 |
| Hash | 해시 테이블 | 순수 등호 검색 | 작음 | 낮음 |
| SP-GiST | 공간 분할 트리 | 전화번호, IP, 비균형 트리 구조 | 보통 | 보통 |
B-tree의 한계와 고급 인덱스의 필요성
B-tree는 스칼라 값의 등호 비교와 범위 검색에 최적화되어 있다. 하지만 다음과 같은 시나리오에서는 B-tree가 비효율적이거나 아예 사용 불가능하다.
- JSONB 포함 검색:
WHERE metadata @> '...'같은 연산자는 B-tree로 인덱싱할 수 없다. - 전문 검색:
to_tsvector()기반 검색은 GIN 인덱스가 필수다. - 공간 쿼리:
ST_DWithin(),ST_Contains()같은 PostGIS 함수는 GiST 인덱스가 필요하다. - 수십억 행 시계열 테이블: B-tree 인덱스 자체가 수십 GB로 커져서 메모리 압박이 심해진다.
이런 문제를 해결하기 위해 PostgreSQL은 특화된 인덱스 유형을 제공한다.
GIN 인덱스 심층 분석
내부 구조
GIN(Generalized Inverted Index)은 역인덱스 구조를 사용한다. 내부적으로 키(key) 값에 대한 B-tree를 구성하고, 각 리프 노드에는 해당 키를 포함하는 행의 TID(Tuple Identifier) 목록인 Posting List 또는 Posting Tree를 저장한다.
예를 들어 JSONB 컬럼에 "tags": ["python", "database"]라는 값이 있으면, GIN 인덱스는 "python"과 "database" 각각을 키로 등록하고, 해당 행의 TID를 Posting List에 추가한다.
JSONB 인덱싱
JSONB 컬럼에 GIN 인덱스를 생성하면 @>, ?, ?|, ?& 등의 연산자를 인덱스 스캔으로 처리할 수 있다.
-- 테이블 생성
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
metadata JSONB NOT NULL
);
-- 100만 건 테스트 데이터 삽입
INSERT INTO products (name, metadata)
SELECT
'product_' || i,
jsonb_build_object(
'category', (ARRAY['electronics', 'clothing', 'food', 'toys'])[1 + (i % 4)],
'price', (random() * 1000)::int,
'tags', jsonb_build_array(
(ARRAY['sale', 'new', 'popular', 'limited'])[1 + (i % 4)],
(ARRAY['premium', 'budget', 'mid-range'])[1 + (i % 3)]
),
'in_stock', (i % 2 = 0)
)
FROM generate_series(1, 1000000) AS i;
-- GIN 인덱스 생성 (기본 jsonb_ops)
CREATE INDEX idx_products_metadata_gin ON products USING gin (metadata);
-- jsonb_path_ops 연산자 클래스 (더 작은 인덱스, @> 전용)
CREATE INDEX idx_products_metadata_path ON products USING gin (metadata jsonb_path_ops);
-- 포함 검색 쿼리와 EXPLAIN ANALYZE
EXPLAIN ANALYZE
SELECT id, name
FROM products
WHERE metadata @> '{"category": "electronics", "in_stock": true}';
실행 결과 예시:
Bitmap Heap Scan on products (cost=52.01..2084.18 rows=500 width=20)
(actual time=1.234..5.678 rows=125000 loops=1)
Recheck Cond: (metadata @> '{"category": "electronics", "in_stock": true}'::jsonb)
Heap Blocks: exact=8334
-> Bitmap Index Scan on idx_products_metadata_gin (cost=0.00..51.88 rows=500 width=0)
(actual time=0.891..0.891 rows=125000 loops=1)
Index Cond: (metadata @> '{"category": "electronics", "in_stock": true}'::jsonb)
Planning Time: 0.152 ms
Execution Time: 12.345 ms
jsonb_ops vs jsonb_path_ops 비교:
| 특성 | jsonb_ops (기본) | jsonb_path_ops |
|---|---|---|
| 지원 연산자 | @>, ?, ?|, ?&, @@, @? | @>, @@, @? |
| 인덱스 크기 | 큼 (키+경로 모두 인덱싱) | 작음 (경로 해시만 저장) |
| 키 존재 확인 | 가능 | 불가능 |
| 포함 검색 속도 | 빠름 | 더 빠름 |
전문 검색 (Full-Text Search)
-- 전문 검색용 테이블 및 GIN 인덱스
CREATE TABLE articles (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL,
tsv tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', title), 'A') ||
setweight(to_tsvector('english', body), 'B')
) STORED
);
-- GIN 인덱스 생성
CREATE INDEX idx_articles_tsv ON articles USING gin (tsv);
-- 전문 검색 쿼리
EXPLAIN ANALYZE
SELECT id, title, ts_rank(tsv, q) AS rank
FROM articles, to_tsquery('english', 'postgresql & indexing') AS q
WHERE tsv @@ q
ORDER BY rank DESC
LIMIT 10;
배열 인덱싱
-- 태그 배열에 GIN 인덱스 생성
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
title TEXT,
tags TEXT[]
);
CREATE INDEX idx_posts_tags_gin ON posts USING gin (tags);
-- 배열 포함 검색
EXPLAIN ANALYZE
SELECT * FROM posts WHERE tags @> ARRAY['postgresql', 'performance'];
-- 배열 겹침 검색
EXPLAIN ANALYZE
SELECT * FROM posts WHERE tags && ARRAY['database', 'backend'];
GIN Pending List와 fastupdate
GIN 인덱스는 기본적으로 fastupdate=on으로 설정되어 있다. 새로운 행이 삽입될 때 즉시 인덱스를 갱신하지 않고 Pending List에 임시 저장한 뒤, VACUUM이나 Pending List 크기 초과 시 일괄 병합한다.
- 장점: 쓰기 성능 향상 (특히 대량 INSERT 시)
- 단점: Pending List 병합 시 CPU/IO 스파이크 발생 가능, 검색 시 Pending List도 추가 스캔 필요
운영 환경에서는 gin_pending_list_limit 파라미터를 조정하거나, 피크 타임 전에 수동으로 SELECT gin_clean_pending_list('idx_name') 을 호출하는 전략이 효과적이다.
GiST 인덱스 - 공간 데이터와 범위 타입
내부 구조
GiST(Generalized Search Tree)는 확장 가능한 균형 트리 프레임워크다. B-tree와 달리 GiST는 하나의 고정된 비교 전략이 아니라, 연산자 클래스에 정의된 consistent, union, penalty, picksplit 등의 메서드를 통해 다양한 데이터 타입과 검색 전략을 지원한다.
내부적으로 R-tree 구조를 사용하여 공간 데이터를 바운딩 박스(MBR: Minimum Bounding Rectangle)로 감싸고 계층적으로 구성한다.
공간 데이터 인덱싱 (PostGIS)
-- PostGIS 확장 설치
CREATE EXTENSION IF NOT EXISTS postgis;
-- 공간 데이터 테이블
CREATE TABLE stores (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
location GEOMETRY(Point, 4326) NOT NULL
);
-- 100만 건 테스트 데이터 (서울 주변 랜덤 좌표)
INSERT INTO stores (name, location)
SELECT
'store_' || i,
ST_SetSRID(ST_MakePoint(
126.9 + random() * 0.2, -- 경도
37.4 + random() * 0.2 -- 위도
), 4326)
FROM generate_series(1, 1000000) AS i;
-- GiST 인덱스 생성
CREATE INDEX idx_stores_location_gist ON stores USING gist (location);
-- 반경 1km 이내 매장 검색
EXPLAIN ANALYZE
SELECT id, name,
ST_Distance(location::geography,
ST_SetSRID(ST_MakePoint(127.0, 37.5), 4326)::geography) AS distance_m
FROM stores
WHERE ST_DWithin(location::geography,
ST_SetSRID(ST_MakePoint(127.0, 37.5), 4326)::geography,
1000)
ORDER BY distance_m
LIMIT 20;
실행 결과 예시:
Limit (cost=8.45..8.50 rows=20 width=44)
(actual time=2.345..2.678 rows=20 loops=1)
-> Sort (cost=8.45..8.52 rows=25 width=44)
(actual time=2.340..2.350 rows=20 loops=1)
Sort Key: (st_distance(...))
Sort Method: top-N heapsort Memory: 27kB
-> Index Scan using idx_stores_location_gist on stores
(cost=0.42..7.89 rows=25 width=44)
(actual time=0.234..2.123 rows=785 loops=1)
Index Cond: (location && ...)
Filter: st_dwithin(...)
Planning Time: 0.456 ms
Execution Time: 2.789 ms
GiST 인덱스가 없으면 Sequential Scan이 발생하여 100만 건 전체를 스캔해야 하므로 수 초가 소요된다.
범위 타입 인덱싱
-- 예약 시스템에서 시간 범위 겹침 검색
CREATE TABLE reservations (
id SERIAL PRIMARY KEY,
room_id INT NOT NULL,
period TSTZRANGE NOT NULL,
guest_name TEXT
);
-- GiST 인덱스로 범위 겹침 검색 최적화
CREATE INDEX idx_reservations_period_gist ON reservations USING gist (period);
-- 특정 기간과 겹치는 예약 조회
EXPLAIN ANALYZE
SELECT * FROM reservations
WHERE period && tstzrange('2026-03-10 14:00', '2026-03-10 18:00', '[)');
-- EXCLUDE 제약 조건으로 겹침 방지 (GiST 필수)
ALTER TABLE reservations
ADD CONSTRAINT no_overlap
EXCLUDE USING gist (room_id WITH =, period WITH &&);
GiST가 지원하는 범위 연산자: && (겹침), @> (포함), <@ (포함됨), << (왼쪽), >> (오른쪽), -|- (인접)
KNN (K-Nearest Neighbor) 검색
GiST 인덱스는 ORDER BY distance 패턴을 인덱스 레벨에서 처리하는 KNN 검색을 지원한다.
-- 가장 가까운 10개 매장을 인덱스 스캔으로 조회
SELECT id, name, location <-> ST_SetSRID(ST_MakePoint(127.0, 37.5), 4326) AS dist
FROM stores
ORDER BY location <-> ST_SetSRID(ST_MakePoint(127.0, 37.5), 4326)
LIMIT 10;
<-> 연산자와 ORDER BY ... LIMIT 조합은 GiST 인덱스를 사용한 효율적인 KNN 검색을 수행한다. 전체 테이블을 정렬하지 않고, 인덱스 트리를 탐색하면서 가까운 순서대로 반환한다.
BRIN 인덱스 - 시계열과 대용량 테이블
내부 구조
BRIN(Block Range Index)은 테이블의 물리적 블록 범위(기본 128 페이지)별로 최소값과 최대값의 요약 정보만 저장한다. 인덱스 크기가 극도로 작아서 수십억 행 테이블에서도 수 MB 수준이다.
핵심 전제 조건: 컬럼 값과 물리적 저장 순서 사이에 강한 상관관계가 있어야 한다. 시계열 데이터에서 타임스탬프 컬럼이 대표적이다.
시계열 데이터 활용
-- 이벤트 로그 테이블 (시계열)
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
event_type TEXT NOT NULL,
payload JSONB
);
-- 5000만 건 테스트 데이터 삽입
INSERT INTO events (created_at, event_type, payload)
SELECT
'2025-01-01'::timestamptz + (i || ' seconds')::interval,
(ARRAY['click', 'view', 'purchase', 'signup'])[1 + (i % 4)],
jsonb_build_object('user_id', (i % 100000), 'value', random() * 100)
FROM generate_series(1, 50000000) AS i;
-- B-tree 인덱스 생성 (비교 기준)
CREATE INDEX idx_events_created_btree ON events (created_at);
-- BRIN 인덱스 생성
CREATE INDEX idx_events_created_brin ON events USING brin (created_at)
WITH (pages_per_range = 128);
-- 인덱스 크기 비교
SELECT
indexname,
pg_size_pretty(pg_relation_size(indexname::regclass)) AS index_size
FROM pg_indexes
WHERE tablename = 'events' AND indexname LIKE 'idx_events_created%';
크기 비교 결과 예시:
indexname | index_size
------------------------------+------------
idx_events_created_btree | 1071 MB
idx_events_created_brin | 128 kB
B-tree 대비 약 8,500배 작은 인덱스로 동일한 범위 검색을 처리할 수 있다.
-- BRIN 인덱스를 사용한 범위 검색
EXPLAIN ANALYZE
SELECT count(*) FROM events
WHERE created_at BETWEEN '2025-06-01' AND '2025-06-30';
실행 결과 예시:
Aggregate (cost=456789.12..456789.13 rows=1 width=8)
(actual time=234.567..234.568 rows=1 loops=1)
-> Bitmap Heap Scan on events (cost=48.12..445678.90 rows=2592000 width=0)
(actual time=12.345..198.765 rows=2592000 loops=1)
Recheck Cond: (created_at >= ... AND created_at <= ...)
Rows Removed by Recheck: 45678
Heap Blocks: lossy=19200
-> Bitmap Index Scan on idx_events_created_brin (cost=0.00..47.50 rows=2600000 width=0)
(actual time=0.234..0.234 rows=192000 loops=1)
Planning Time: 0.123 ms
Execution Time: 256.789 ms
pages_per_range 튜닝
pages_per_range 값은 정확도와 인덱스 크기의 트레이드오프다.
| pages_per_range | 인덱스 크기 | 정확도 | 최적 사용 사례 |
|---|---|---|---|
| 32 | 크게 증가 | 높음 | 소규모 범위 쿼리가 빈번한 경우 |
| 128 (기본) | 보통 | 보통 | 범용 시계열 데이터 |
| 256 이상 | 매우 작음 | 낮음 | 매우 큰 범위 쿼리 위주 |
BRIN이 적합하지 않은 경우
- 데이터가 랜덤하게 삽입되어 물리적 순서와 논리적 순서가 일치하지 않는 경우
- UPDATE로 인해 행이 다른 페이지로 이동(HOT 실패)하여 상관관계가 깨진 경우
- 포인트 쿼리(단일 행 조회)가 주된 워크로드인 경우
pg_stats 뷰의 correlation 값을 확인하면 BRIN 적합성을 판단할 수 있다. 절대값이 1에 가까울수록 적합하다.
SELECT tablename, attname, correlation
FROM pg_stats
WHERE tablename = 'events' AND attname = 'created_at';
-- correlation 값이 0.95 이상이면 BRIN 사용 적합
Partial Index와 Expression Index
Partial Index (부분 인덱스)
테이블의 일부 행에만 인덱스를 생성하여 인덱스 크기를 줄이고 쓰기 성능을 개선한다.
-- 주문 테이블에서 활성 주문만 인덱싱
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_id INT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
total_amount NUMERIC(10,2),
created_at TIMESTAMPTZ DEFAULT now()
);
-- 전체 인덱스 vs 부분 인덱스 크기 비교
CREATE INDEX idx_orders_status_full ON orders (status, created_at);
CREATE INDEX idx_orders_status_partial ON orders (created_at)
WHERE status IN ('pending', 'processing');
-- 부분 인덱스가 사용되는 쿼리
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE status = 'pending'
AND created_at > now() - interval '7 days'
ORDER BY created_at DESC;
부분 인덱스의 핵심은 쿼리의 WHERE 절이 인덱스의 WHERE 조건을 포함(imply)해야 한다는 점이다. PostgreSQL 옵티마이저는 단순한 동치 관계와 일부 함의 관계를 인식할 수 있지만, 복잡한 표현식은 인식하지 못할 수 있다.
Unique Partial Index로 조건부 유니크 제약
-- 사용자별 활성 이메일은 하나만 허용
CREATE UNIQUE INDEX idx_unique_active_email
ON users (email) WHERE is_active = true;
-- is_active = false인 행은 중복 이메일 허용
-- is_active = true인 행은 이메일 유니크 보장
Expression Index (표현식 인덱스)
컬럼 값을 변환한 결과에 인덱스를 생성한다. 쿼리에서 동일한 표현식을 사용해야 인덱스가 활용된다.
-- 대소문자 무시 검색을 위한 표현식 인덱스
CREATE INDEX idx_users_email_lower ON users (lower(email));
-- 이 쿼리는 인덱스 사용
EXPLAIN ANALYZE
SELECT * FROM users WHERE lower(email) = 'user@example.com';
-- 날짜 추출 표현식 인덱스
CREATE INDEX idx_orders_created_date ON orders ((created_at::date));
-- 날짜별 집계에 활용
EXPLAIN ANALYZE
SELECT created_at::date AS order_date, count(*)
FROM orders
WHERE created_at::date = '2026-03-10'
GROUP BY created_at::date;
-- JSONB 특정 키에 대한 표현식 인덱스 (GIN보다 작은 크기)
CREATE INDEX idx_products_category ON products ((metadata->>'category'));
-- B-tree 기반이므로 등호/범위 검색 가능
EXPLAIN ANALYZE
SELECT * FROM products WHERE metadata->>'category' = 'electronics';
Partial + Expression 조합
-- 최근 7일간 활성 사용자의 이메일 검색 최적화
CREATE INDEX idx_recent_active_users_email
ON users (lower(email))
WHERE last_login_at > now() - interval '7 days'
AND is_active = true;
이 조합은 인덱스 크기를 극적으로 줄이면서도 특정 쿼리 패턴에 대해 최적의 성능을 제공한다.
EXPLAIN ANALYZE를 활용한 인덱스 성능 분석
인덱스가 실제로 사용되는지 확인하는 가장 확실한 방법은 EXPLAIN ANALYZE다.
-- 기본 EXPLAIN ANALYZE
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM products
WHERE metadata @> '{"category": "electronics"}';
-- 실행 계획 핵심 확인 포인트:
-- 1. Scan 타입: Index Scan vs Bitmap Index Scan vs Seq Scan
-- 2. actual time: 실제 소요 시간
-- 3. rows: 예상 행 수 vs 실제 행 수 차이
-- 4. Buffers: shared hit (캐시) vs shared read (디스크)
인덱스 미사용 원인 진단
인덱스가 존재하는데도 사용되지 않는 주요 원인:
- 통계 부정확:
ANALYZE미실행으로 옵티마이저가 잘못된 카디널리티를 추정 - 선택도 낮음: 결과가 테이블의 10-15% 이상이면 Sequential Scan이 더 효율적
- 타입 불일치: 쿼리 조건의 데이터 타입과 인덱스 컬럼 타입이 다름
- 표현식 불일치: Expression Index와 쿼리의 표현식이 정확히 일치하지 않음
- enable_indexscan = off: 세션 레벨에서 인덱스 스캔이 비활성화된 경우
-- 사용되지 않는 인덱스 찾기
SELECT
schemaname || '.' || relname AS table,
indexrelname AS index,
pg_size_pretty(pg_relation_size(i.indexrelid)) AS index_size,
idx_scan AS times_used,
idx_tup_read AS tuples_read
FROM pg_stat_user_indexes i
JOIN pg_index USING (indexrelid)
WHERE idx_scan = 0
AND NOT indisunique
AND NOT indisprimary
ORDER BY pg_relation_size(i.indexrelid) DESC;
인덱스 블로트 관리와 REINDEX
인덱스 블로트란
PostgreSQL의 MVCC 구조상, UPDATE는 내부적으로 DELETE + INSERT로 처리된다. 삭제된 튜플의 인덱스 엔트리는 VACUUM이 정리할 때까지 공간을 차지한다. 이것이 누적되면 인덱스가 비정상적으로 비대해지는 인덱스 블로트가 발생한다.
블로트 측정
-- pgstattuple 확장으로 블로트 측정
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT
avg_leaf_density,
leaf_pages,
empty_pages,
deleted_pages,
round(100 - avg_leaf_density, 1) AS bloat_pct
FROM pgstatindex('idx_orders_status_full');
-- bloat_pct가 20%를 초과하면 REINDEX 고려
REINDEX CONCURRENTLY
운영 환경에서는 테이블 잠금 없이 인덱스를 재구축할 수 있는 REINDEX CONCURRENTLY를 사용한다.
-- 특정 인덱스 재구축 (무중단)
REINDEX INDEX CONCURRENTLY idx_orders_status_full;
-- 테이블의 모든 인덱스 재구축
REINDEX TABLE CONCURRENTLY orders;
-- 스키마 전체 인덱스 재구축
REINDEX SCHEMA CONCURRENTLY public;
주의 사항:
- REINDEX CONCURRENTLY가 중간에 실패하면
_ccnew접미사가 붙은 유효하지 않은 인덱스가 남는다. 반드시 확인하고 삭제해야 한다. - REINDEX CONCURRENTLY 실행 중에는 xmin horizon을 점유하므로, 장시간 실행 시 다른 VACUUM의 데드 튜플 정리가 지연될 수 있다.
-- 유효하지 않은 인덱스 확인
SELECT indexrelid::regclass AS index_name,
indisvalid
FROM pg_index
WHERE NOT indisvalid;
-- 유효하지 않은 인덱스 삭제
-- DROP INDEX CONCURRENTLY idx_name_ccnew;
자동 관리 전략
-- 블로트 20% 초과 인덱스를 자동으로 찾는 모니터링 쿼리
SELECT
schemaname || '.' || tablename AS table,
indexname,
pg_size_pretty(pg_relation_size(indexname::regclass)) AS size,
round(100 - (pgstatindex(indexname)).avg_leaf_density, 1) AS bloat_pct
FROM pg_indexes
WHERE schemaname = 'public'
ORDER BY bloat_pct DESC NULLS LAST;
인덱스 유형별 비교표
| 특성 | B-tree | GIN | GiST | BRIN |
|---|---|---|---|---|
| 최적 데이터 타입 | 스칼라 값 | JSONB, 배열, tsvector | 공간, 범위 | 시계열, 순차 |
| 인덱스 크기 | 보통 | 큼 (테이블의 60-80%) | 보통 | 극소 (수 KB) |
| 쓰기 오버헤드 | 낮음 | 높음 | 보통 | 매우 낮음 |
| 포인트 쿼리 | 최적 | 지원 | 지원 | 비효율적 |
| 범위 쿼리 | 최적 | 미지원 | 지원 | 최적 (상관관계 높을 때) |
| 전문 검색 | 미지원 | 최적 | 미지원 | 미지원 |
| 공간 쿼리 | 미지원 | 미지원 | 최적 | 제한적 |
| KNN 검색 | 미지원 | 미지원 | 최적 | 미지원 |
| 유니크 제약 | 지원 | 미지원 | 미지원 | 미지원 |
| 인덱스 전용 스캔 | 지원 | 미지원 | 미지원 | 미지원 |
| CONCURRENTLY 생성 | 지원 | 지원 | 지원 | 지원 |
| 물리 정렬 필요 | 불필요 | 불필요 | 불필요 | 필수 |
운영 체크리스트
인덱스 생성 전 체크리스트
- 쿼리 패턴 분석:
pg_stat_statements에서 빈도 높은 쿼리 확인 - 대상 컬럼의 데이터 타입과 연산자 확인 (B-tree로 충분한지 판단)
EXPLAIN ANALYZE로 현재 실행 계획 확인- 테이블 크기와 예상 인덱스 크기 산정
- CONCURRENTLY 옵션으로 무중단 생성 계획 수립
정기 모니터링 항목
- 사용되지 않는 인덱스 (
pg_stat_user_indexes.idx_scan = 0) - 인덱스 블로트 비율 (
pgstatindex함수) - 인덱스 크기 대비 테이블 크기 비율
- BRIN 인덱스의 상관관계 (
pg_stats.correlation) - GIN Pending List 크기 (
pg_stat_all_indexes.idx_tup_insert)
인덱스 유형 선택 플로우
- 등호/범위/정렬 쿼리 -> B-tree
- JSONB 포함 검색, 배열, 전문 검색 -> GIN
- 공간 데이터, 범위 겹침, KNN -> GiST
- 시계열/append-only 대용량 테이블 범위 검색 -> BRIN
- 특정 조건의 행만 자주 조회 -> Partial Index
- 함수/변환 결과 기반 검색 -> Expression Index
마무리
PostgreSQL의 고급 인덱스는 각각 명확한 설계 목적과 최적 시나리오를 가지고 있다. 핵심은 데이터의 특성과 쿼리 패턴에 맞는 인덱스 유형을 선택하는 것이다.
- JSONB나 전문 검색이 많다면 GIN을 검토하되, 쓰기 오버헤드와 인덱스 크기를 감안한다.
- 공간 데이터나 범위 겹침 쿼리가 있다면 GiST는 사실상 필수다.
- 수십억 행 시계열 테이블이라면 BRIN으로 인덱스 크기를 99% 이상 줄일 수 있다.
- Partial Index와 Expression Index는 어떤 인덱스 유형과도 조합 가능하여 추가적인 최적화를 제공한다.
가장 중요한 것은 EXPLAIN ANALYZE로 실제 실행 계획을 검증하고, 운영 환경에서 인덱스 블로트를 지속적으로 모니터링하는 것이다. 인덱스는 만들어 놓고 잊어버리는 것이 아니라 지속적으로 관리해야 하는 운영 대상이다.