Skip to content

Split View: PostgreSQL 고급 인덱싱 완전 가이드: GIN·GiST·BRIN·Partial Index 실전 활용

✨ Learn with Quiz
|

PostgreSQL 고급 인덱싱 완전 가이드: GIN·GiST·BRIN·Partial Index 실전 활용

PostgreSQL Advanced Indexing

들어가며

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
지원 연산자@>, ?, ?|, ?&, @@, @?@>, @@, @?
인덱스 크기큼 (키+경로 모두 인덱싱)작음 (경로 해시만 저장)
키 존재 확인가능불가능
포함 검색 속도빠름더 빠름
-- 전문 검색용 테이블 및 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 (디스크)

인덱스 미사용 원인 진단

인덱스가 존재하는데도 사용되지 않는 주요 원인:

  1. 통계 부정확: ANALYZE 미실행으로 옵티마이저가 잘못된 카디널리티를 추정
  2. 선택도 낮음: 결과가 테이블의 10-15% 이상이면 Sequential Scan이 더 효율적
  3. 타입 불일치: 쿼리 조건의 데이터 타입과 인덱스 컬럼 타입이 다름
  4. 표현식 불일치: Expression Index와 쿼리의 표현식이 정확히 일치하지 않음
  5. 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-treeGINGiSTBRIN
최적 데이터 타입스칼라 값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)

인덱스 유형 선택 플로우

  1. 등호/범위/정렬 쿼리 -> B-tree
  2. JSONB 포함 검색, 배열, 전문 검색 -> GIN
  3. 공간 데이터, 범위 겹침, KNN -> GiST
  4. 시계열/append-only 대용량 테이블 범위 검색 -> BRIN
  5. 특정 조건의 행만 자주 조회 -> Partial Index
  6. 함수/변환 결과 기반 검색 -> Expression Index

마무리

PostgreSQL의 고급 인덱스는 각각 명확한 설계 목적과 최적 시나리오를 가지고 있다. 핵심은 데이터의 특성과 쿼리 패턴에 맞는 인덱스 유형을 선택하는 것이다.

  • JSONB나 전문 검색이 많다면 GIN을 검토하되, 쓰기 오버헤드와 인덱스 크기를 감안한다.
  • 공간 데이터나 범위 겹침 쿼리가 있다면 GiST는 사실상 필수다.
  • 수십억 행 시계열 테이블이라면 BRIN으로 인덱스 크기를 99% 이상 줄일 수 있다.
  • Partial Index와 Expression Index는 어떤 인덱스 유형과도 조합 가능하여 추가적인 최적화를 제공한다.

가장 중요한 것은 EXPLAIN ANALYZE로 실제 실행 계획을 검증하고, 운영 환경에서 인덱스 블로트를 지속적으로 모니터링하는 것이다. 인덱스는 만들어 놓고 잊어버리는 것이 아니라 지속적으로 관리해야 하는 운영 대상이다.

PostgreSQL Advanced Indexing Guide: GIN, GiST, BRIN, and Partial Index in Practice

PostgreSQL Advanced Indexing

Introduction

PostgreSQL offers six index types: B-tree, Hash, GIN, GiST, SP-GiST, and BRIN. While most tutorials stop at B-tree, real-world applications frequently encounter problems that B-tree alone cannot efficiently solve -- JSONB queries, full-text search, spatial queries, and time-series tables with billions of rows.

This article goes beyond B-tree to explore the internal structures of GIN, GiST, BRIN, Partial Index, and Expression Index, covering when and how to apply each with EXPLAIN ANALYZE-based performance data.

PostgreSQL Index Types Overview

A quick summary of PostgreSQL index types:

Index TypeInternal StructureBest Use CasesSizeWrite Cost
B-treeBalanced treeEquality, range, sort, UNIQUEMediumLow
GINInverted index (Posting List/Tree)JSONB, arrays, tsvectorLargeHigh
GiSTGeneralized search treeSpatial data, ranges, proximityMediumMedium
BRINBlock range summaryTime-series, append-only large tablesVery smallVery low
HashHash tablePure equality lookupsSmallLow
SP-GiSTSpace-partitioned treePhone numbers, IP addresses, unbalanced treesMediumMedium

B-tree Limitations and the Need for Advanced Indexes

B-tree is optimized for scalar value equality comparisons and range searches. However, it becomes inefficient or unusable in the following scenarios:

  • JSONB containment queries: Operators like @> in WHERE metadata @> '...' cannot be indexed with B-tree.
  • Full-text search: to_tsvector()-based search requires GIN indexes.
  • Spatial queries: PostGIS functions like ST_DWithin() and ST_Contains() need GiST indexes.
  • Billion-row time-series tables: The B-tree index itself grows to tens of GB, causing memory pressure.

PostgreSQL provides specialized index types to address these challenges.

GIN Index Deep Dive

Internal Structure

GIN (Generalized Inverted Index) uses an inverted index structure. Internally, it builds a B-tree over key values, and each leaf node stores a Posting List or Posting Tree -- a list of TIDs (Tuple Identifiers) for rows containing that key.

For example, if a JSONB column contains "tags": ["python", "database"], the GIN index registers both "python" and "database" as keys and adds the row TID to each key's Posting List.

JSONB Indexing

Creating a GIN index on a JSONB column enables index scans for operators like @>, ?, ?|, and ?&.

-- Create table
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    metadata JSONB NOT NULL
);

-- Insert 1 million test rows
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 index with default jsonb_ops
CREATE INDEX idx_products_metadata_gin ON products USING gin (metadata);

-- GIN index with jsonb_path_ops (smaller index, @> only)
CREATE INDEX idx_products_metadata_path ON products USING gin (metadata jsonb_path_ops);

-- Containment query with EXPLAIN ANALYZE
EXPLAIN ANALYZE
SELECT id, name
FROM products
WHERE metadata @> '{"category": "electronics", "in_stock": true}';

Example output:

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 comparison:

Propertyjsonb_ops (default)jsonb_path_ops
Supported operators@>, ?, ?|, ?&, @@, @?@>, @@, @?
Index sizeLarge (indexes keys + paths)Small (stores path hashes only)
Key existence checksSupportedNot supported
Containment search speedFastFaster
-- Full-text search table with GIN index
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
);

-- Create GIN index
CREATE INDEX idx_articles_tsv ON articles USING gin (tsv);

-- Full-text search query
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;

Array Indexing

-- GIN index on tag arrays
CREATE TABLE posts (
    id SERIAL PRIMARY KEY,
    title TEXT,
    tags TEXT[]
);

CREATE INDEX idx_posts_tags_gin ON posts USING gin (tags);

-- Array containment search
EXPLAIN ANALYZE
SELECT * FROM posts WHERE tags @> ARRAY['postgresql', 'performance'];

-- Array overlap search
EXPLAIN ANALYZE
SELECT * FROM posts WHERE tags && ARRAY['database', 'backend'];

GIN Pending List and fastupdate

By default, GIN indexes are configured with fastupdate=on. When new rows are inserted, they are not immediately merged into the index -- instead, they are temporarily stored in a Pending List and batch-merged during VACUUM or when the Pending List exceeds its size limit.

  • Advantage: Improved write performance, especially during bulk INSERTs
  • Disadvantage: CPU/IO spikes during Pending List merge; search queries must also scan the Pending List

In production, tune the gin_pending_list_limit parameter or manually call SELECT gin_clean_pending_list('idx_name') before peak hours.

GiST Index -- Spatial Data and Range Types

Internal Structure

GiST (Generalized Search Tree) is an extensible balanced tree framework. Unlike B-tree, GiST supports various data types and search strategies through operator class methods: consistent, union, penalty, picksplit, and others.

Internally, it uses an R-tree structure that wraps spatial data in bounding boxes (MBR: Minimum Bounding Rectangle) and organizes them hierarchically.

Spatial Data Indexing (PostGIS)

-- Install PostGIS extension
CREATE EXTENSION IF NOT EXISTS postgis;

-- Spatial data table
CREATE TABLE stores (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    location GEOMETRY(Point, 4326) NOT NULL
);

-- Insert 1 million test rows (random coordinates around Seoul)
INSERT INTO stores (name, location)
SELECT
    'store_' || i,
    ST_SetSRID(ST_MakePoint(
        126.9 + random() * 0.2,   -- longitude
        37.4 + random() * 0.2     -- latitude
    ), 4326)
FROM generate_series(1, 1000000) AS i;

-- Create GiST index
CREATE INDEX idx_stores_location_gist ON stores USING gist (location);

-- Find stores within 1km radius
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;

Example output:

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

Without the GiST index, a Sequential Scan across all 1 million rows would take several seconds.

Range Type Indexing

-- Overlapping time range search for a reservation system
CREATE TABLE reservations (
    id SERIAL PRIMARY KEY,
    room_id INT NOT NULL,
    period TSTZRANGE NOT NULL,
    guest_name TEXT
);

-- GiST index for range overlap optimization
CREATE INDEX idx_reservations_period_gist ON reservations USING gist (period);

-- Find overlapping reservations
EXPLAIN ANALYZE
SELECT * FROM reservations
WHERE period && tstzrange('2026-03-10 14:00', '2026-03-10 18:00', '[)');

-- EXCLUDE constraint to prevent overlaps (requires GiST)
ALTER TABLE reservations
ADD CONSTRAINT no_overlap
EXCLUDE USING gist (room_id WITH =, period WITH &&);

GiST supports range operators: && (overlap), @> (contains), <@ (contained by), << (left of), >> (right of), -|- (adjacent).

GiST indexes support KNN searches that handle ORDER BY distance patterns at the index level.

-- Find the 10 nearest stores using index scan
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;

The <-> operator combined with ORDER BY ... LIMIT performs efficient KNN search through the GiST index. Rather than sorting the entire table, it traverses the index tree and returns results in nearest-first order.

BRIN Index -- Time-Series and Large Tables

Internal Structure

BRIN (Block Range Index) stores only summary information -- minimum and maximum values -- for each physical block range (default: 128 pages) in the table. This makes the index extremely small, typically just a few MB even for tables with billions of rows.

The key prerequisite: there must be a strong correlation between column values and their physical storage order. Timestamp columns in time-series data are the classic example.

Time-Series Data Usage

-- Event log table (time-series)
CREATE TABLE events (
    id BIGSERIAL PRIMARY KEY,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    event_type TEXT NOT NULL,
    payload JSONB
);

-- Insert 50 million test rows
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 index for comparison
CREATE INDEX idx_events_created_btree ON events (created_at);

-- BRIN index
CREATE INDEX idx_events_created_brin ON events USING brin (created_at)
WITH (pages_per_range = 128);

-- Compare index sizes
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%';

Size comparison example:

       indexname              | index_size
------------------------------+------------
 idx_events_created_btree     | 1071 MB
 idx_events_created_brin      | 128 kB

The BRIN index is approximately 8,500 times smaller than B-tree while handling the same range queries.

-- Range query using BRIN index
EXPLAIN ANALYZE
SELECT count(*) FROM events
WHERE created_at BETWEEN '2025-06-01' AND '2025-06-30';

Example output:

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 Tuning

The pages_per_range value is a trade-off between accuracy and index size.

pages_per_rangeIndex SizeAccuracyBest For
32LargerHighFrequent small-range queries
128 (default)MediumMediumGeneral time-series data
256+Very smallLowPrimarily large-range queries

When BRIN Is Not Suitable

  • Data is inserted randomly with no correlation between physical and logical order
  • UPDATE operations move rows to different pages (HOT failure), breaking the correlation
  • Point queries (single row lookups) are the primary workload

Check the correlation value in pg_stats to assess BRIN suitability. Values closer to 1 (absolute) are ideal.

SELECT tablename, attname, correlation
FROM pg_stats
WHERE tablename = 'events' AND attname = 'created_at';
-- correlation above 0.95 indicates good BRIN suitability

Partial Index and Expression Index

Partial Index

Create an index on only a subset of table rows to reduce index size and improve write performance.

-- Order table: index only active orders
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()
);

-- Full index vs Partial index size comparison
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');

-- Query that uses the partial index
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE status = 'pending'
  AND created_at > now() - interval '7 days'
ORDER BY created_at DESC;

The key to partial indexes is that the query WHERE clause must imply the index predicate. The PostgreSQL optimizer can recognize simple inequality implications, but may not recognize complex expressions.

Unique Partial Index for Conditional Uniqueness

-- Allow only one active email per user
CREATE UNIQUE INDEX idx_unique_active_email
ON users (email) WHERE is_active = true;

-- Rows where is_active = false can have duplicate emails
-- Rows where is_active = true enforce email uniqueness

Expression Index

Create an index on the result of transforming column values. The query must use the exact same expression for the index to be utilized.

-- Expression index for case-insensitive search
CREATE INDEX idx_users_email_lower ON users (lower(email));

-- This query uses the index
EXPLAIN ANALYZE
SELECT * FROM users WHERE lower(email) = 'user@example.com';

-- Date extraction expression index
CREATE INDEX idx_orders_created_date ON orders ((created_at::date));

-- Used for date-based aggregations
EXPLAIN ANALYZE
SELECT created_at::date AS order_date, count(*)
FROM orders
WHERE created_at::date = '2026-03-10'
GROUP BY created_at::date;

-- Expression index on a specific JSONB key (smaller than full GIN)
CREATE INDEX idx_products_category ON products ((metadata->>'category'));

-- B-tree based, so equality and range searches work
EXPLAIN ANALYZE
SELECT * FROM products WHERE metadata->>'category' = 'electronics';

Combining Partial + Expression

-- Optimize email search for recently active users
CREATE INDEX idx_recent_active_users_email
ON users (lower(email))
WHERE last_login_at > now() - interval '7 days'
  AND is_active = true;

This combination dramatically reduces index size while providing optimal performance for specific query patterns.

EXPLAIN ANALYZE for Index Performance Analysis

The most reliable way to verify index usage is EXPLAIN ANALYZE.

-- Basic EXPLAIN ANALYZE
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM products
WHERE metadata @> '{"category": "electronics"}';

-- Key points to check in the execution plan:
-- 1. Scan type: Index Scan vs Bitmap Index Scan vs Seq Scan
-- 2. actual time: Real elapsed time
-- 3. rows: Estimated vs actual row count discrepancy
-- 4. Buffers: shared hit (cache) vs shared read (disk)

Diagnosing Index Non-Usage

Common reasons why an existing index is not used:

  1. Stale statistics: ANALYZE not run, causing the optimizer to estimate incorrect cardinality
  2. Low selectivity: When results exceed 10-15% of the table, Sequential Scan is more efficient
  3. Type mismatch: Query condition data type differs from the indexed column type
  4. Expression mismatch: Expression Index and query expression do not match exactly
  5. enable_indexscan = off: Index scan disabled at the session level
-- Find unused indexes
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;

Index Bloat Management and REINDEX

What Is Index Bloat

Due to PostgreSQL MVCC architecture, UPDATE is internally handled as DELETE + INSERT. Index entries for deleted tuples occupy space until VACUUM cleans them up. When this accumulates, index bloat occurs -- the index grows abnormally large.

Measuring Bloat

-- Measure bloat using pgstattuple extension
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');

-- Consider REINDEX when bloat_pct exceeds 20%

REINDEX CONCURRENTLY

In production, use REINDEX CONCURRENTLY to rebuild indexes without locking the table.

-- Rebuild a specific index (non-blocking)
REINDEX INDEX CONCURRENTLY idx_orders_status_full;

-- Rebuild all indexes on a table
REINDEX TABLE CONCURRENTLY orders;

-- Rebuild all indexes in a schema
REINDEX SCHEMA CONCURRENTLY public;

Important caveats:

  • If REINDEX CONCURRENTLY fails midway, it leaves behind an invalid index with a _ccnew suffix. You must check for and drop these.
  • REINDEX CONCURRENTLY holds the xmin horizon during execution, which can delay dead tuple cleanup by other VACUUM processes.
-- Check for invalid indexes
SELECT indexrelid::regclass AS index_name,
       indisvalid
FROM pg_index
WHERE NOT indisvalid;

-- Drop invalid indexes
-- DROP INDEX CONCURRENTLY idx_name_ccnew;

Automated Management Strategy

-- Monitoring query to find indexes with bloat exceeding 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;

Index Type Comparison Table

PropertyB-treeGINGiSTBRIN
Best data typesScalar valuesJSONB, arrays, tsvectorSpatial, rangesTime-series, sequential
Index sizeMediumLarge (60-80% of table)MediumExtremely small (KB)
Write overheadLowHighMediumVery low
Point queriesOptimalSupportedSupportedInefficient
Range queriesOptimalNot supportedSupportedOptimal (high correlation)
Full-text searchNot supportedOptimalNot supportedNot supported
Spatial queriesNot supportedNot supportedOptimalLimited
KNN searchNot supportedNot supportedOptimalNot supported
Unique constraintsSupportedNot supportedNot supportedNot supported
Index-only scanSupportedNot supportedNot supportedNot supported
CONCURRENTLY createSupportedSupportedSupportedSupported
Physical sort requiredNoNoNoYes

Operations Checklist

Pre-Index Creation Checklist

  • Analyze query patterns: Check high-frequency queries in pg_stat_statements
  • Verify the target column data type and operators (determine if B-tree suffices)
  • Check the current execution plan with EXPLAIN ANALYZE
  • Estimate table size and expected index size
  • Plan for non-blocking creation with the CONCURRENTLY option

Regular Monitoring Items

  • Unused indexes (pg_stat_user_indexes.idx_scan = 0)
  • Index bloat percentage (pgstatindex function)
  • Index size relative to table size
  • BRIN index correlation (pg_stats.correlation)
  • GIN Pending List size (pg_stat_all_indexes.idx_tup_insert)

Index Type Selection Flow

  1. Equality / range / sort queries -> B-tree
  2. JSONB containment, arrays, full-text search -> GIN
  3. Spatial data, range overlaps, KNN -> GiST
  4. Time-series / append-only large table range queries -> BRIN
  5. Frequently querying only specific row subsets -> Partial Index
  6. Searching on function/transformation results -> Expression Index

Conclusion

Each PostgreSQL advanced index has a clear design purpose and optimal scenario. The key is to select the right index type based on your data characteristics and query patterns.

  • If you have many JSONB or full-text search queries, consider GIN -- but account for write overhead and index size.
  • For spatial data or range overlap queries, GiST is practically mandatory.
  • For billion-row time-series tables, BRIN can reduce index size by over 99%.
  • Partial Index and Expression Index can be combined with any index type for additional optimization.

Most importantly, always validate actual execution plans with EXPLAIN ANALYZE and continuously monitor index bloat in production. Indexes are not fire-and-forget assets -- they are operational objects that require ongoing maintenance.