GIN — JSONB, 배열, 전문 검색 인덱싱
B-Tree 가 못 하는 일
지금까지 다룬 B-Tree 인덱스는 "행 하나에 값 하나"를 전제로 합니다. 그런데 tags text[] 같은 배열 컬럼이나 attributes jsonb 같은 컬럼은 행 하나에 값이 여러 개 들어 있습니다. tags @> ARRAY['sale'] (이 행의 태그 목록이 'sale' 을 포함하는가) 같은 질문에 B-Tree는 답을 못 줍니다. 정렬 기준으로 삼을 단일 값이 없기 때문입니다. GIN(Generalized Inverted Index)은 이 문제를 반대 방향에서 풉니다 — "각 행에 어떤 값들이 들어 있는가"가 아니라 "각 값이 어떤 행들에 들어 있는가"를 색인합니다. 검색엔진의 역색인(inverted index)과 원리가 같습니다.
JSONB 색인 — 연산자 클래스가 성능을 가른다
CREATE TABLE events (
id bigserial PRIMARY KEY,
payload jsonb NOT NULL
);
CREATE INDEX idx_events_payload ON events USING GIN (payload);기본 연산자 클래스(jsonb_ops)는 키와 값 조합을 전부 색인해서 @>(포함), ?(키 존재) 연산자를 지원합니다.
-- payload 안에 {"level": "error"} 를 포함하는 행
SELECT * FROM events WHERE payload @> '{"level": "error"}';키 몇 개만 자주 조회한다면 jsonb_path_ops 연산자 클래스가 더 낫습니다.
CREATE INDEX idx_events_payload_path ON events
USING GIN (payload jsonb_path_ops);jsonb_path_ops는 @> 연산자만 지원하는 대신(? 연산자는 못 씀) 인덱스 크기가 jsonb_ops 대비 확연히 작고, 조회 성능도 대체로 더 좋습니다. ? 로 키 존재 여부를 확인해야 하는 워크로드가 아니라면 저는 거의 항상 jsonb_path_ops 를 먼저 씁니다.
배열과 전문 검색도 같은 원리
-- 배열: tags 컬럼에 특정 값이 포함된 행 찾기
CREATE INDEX idx_products_tags ON products USING GIN (tags);
SELECT * FROM products WHERE tags @> ARRAY['limited-edition'];
-- 전문 검색: tsvector 컬럼에 GIN
ALTER TABLE articles ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('simple', title || ' ' || body)) STORED;
CREATE INDEX idx_articles_search ON articles USING GIN (search_vector);
SELECT title FROM articles
WHERE search_vector @@ to_tsquery('simple', 'postgresql & index');전문 검색을 LIKE '%keyword%' 로 구현하고 있다면, 이 방식은 인덱스를 전혀 못 타서(양쪽 와일드카드는 leftmost 조건이 없는 것과 같습니다) 테이블이 커질수록 선형으로 느려집니다. tsvector + GIN 조합으로 바꾸면 이 문제 자체가 사라집니다. 다만 to_tsvector('simple', ...) 대신 'english' 같은 언어별 설정을 쓰면 어간 추출과 불용어 처리가 붙는데, 한국어는 PostgreSQL 기본 설정에 형태소 분석기가 없어서 별도 확장(pg_bigm, mecab 연동 등)이 필요합니다. 이 책에서는 다루지 않지만, 한국어 전문 검색을 붙일 계획이라면 미리 알아두는 게 좋습니다.
GIN 의 대가 — 쓰기가 느려진다
flowchart LR A["INSERT/UPDATE"] --> B["새 값의 모든 원소를 인덱스 항목으로 분해"] B --> C["각 원소마다 역색인 엔트리 갱신"] C --> D["B-Tree보다 쓰기 비용 큼"]
행 하나에 배열 원소가 10개 있으면 GIN은 최악의 경우 10개의 색인 항목을 갱신해야 합니다. B-Tree는 값 하나당 항목 하나면 끝나는 것과 대조적입니다. 이 때문에 GIN 인덱스가 걸린 테이블에 대량 삽입을 할 때는 INSERT 가 눈에 띄게 느려집니다. PostgreSQL은 gin_pending_list_limit 설정으로 이 문제를 완화합니다 — 새 항목을 즉시 정식 구조에 반영하지 않고 임시 리스트에 모아뒀다가 나중에 한꺼번에 병합합니다. 쓰기가 몰리는 테이블이라면 기본값을 그대로 쓰지 말고, 이 설정과 autovacuum 주기를 함께 튜닝하는 걸 권합니다.