PostgreSQL jsonb 인덱싱
상위: Database MOC
개요
jsonb 컬럼을 조건에 자주 쓰면 GIN 인덱스가 답이지만, GIN에는 두 가지 연산자 클래스(jsonb_ops, jsonb_path_ops)가 있고 지원하는 연산자가 다르다. 흔한 함정은 성능만 보고 jsonb_path_ops를 골랐다가, 키 존재 검사(?, ?|, ?&)를 쓰는 쿼리가 인덱스를 못 타고 seq scan으로 떨어지는 것이다. 이 문서는 두 연산자 클래스의 차이, 표현식 인덱스라는 더 가벼운 대안, 그리고 EXPLAIN (ANALYZE, BUFFERS)로 확인 후 인덱스를 추가하는 원칙을 정리한다. 테이블은 일반화하여 documents(metadata jsonb)로 표기한다.
원리
두 GIN 연산자 클래스
| 연산자 클래스 | 지원 연산자 | 인덱스 크기 | 용도 |
|---|---|---|---|
jsonb_ops (기본) | @> ? `? | ?& @? @@` | 큼 (키+값 모두 색인) |
jsonb_path_ops | @> @? @@ | 작음·빠름 (경로 해시만) | 포함(@>) 위주 조회 |
jsonb_path_ops는 값 경로의 해시만 색인하므로 인덱스가 작고 @>(포함) 조회가 빠르다. 그러나 키 존재 연산자 ?(단일 키), ?|(키 중 하나), ?&(모든 키)를 지원하지 않는다. 이 연산자를 쓰는 쿼리는 jsonb_path_ops 인덱스가 있어도 seq scan을 한다.
연산자 의미 정리
metadata @> '{"lang":"ko"}' -- 포함: lang이 ko인가 (jsonb_path_ops OK)
metadata ? 'lang' -- 키 존재: lang 키가 있는가 (jsonb_ops만)
metadata ?| array['ko','en'] -- 키 중 하나라도 존재
metadata ?& array['a','b'] -- 나열한 키가 모두 존재즉 “값이 무엇인가”를 묻는 @>는 둘 다 되지만, “키가 존재하는가”를 묻는 ? 계열은 jsonb_ops만 커버한다.
jsonpath와 @? / @@
PostgreSQL 12+는 SQL/JSON path 언어와 두 연산자 @?(경로가 매칭되는가), @@(경로 술어가 참인가)를 제공한다. 이 둘은 두 연산자 클래스 모두 인덱스로 가속된다. 복잡한 조건(범위·와일드카드)을 @>로 표현하기 어려울 때 jsonpath가 유용하다.
-- score >= 80 인 문서 (jsonpath 술어)
SELECT id FROM documents WHERE metadata @@ '$.score >= 80';
-- tags 배열에 'rag'가 있는가
SELECT id FROM documents WHERE metadata @? '$.tags[*] ? (@ == "rag")';다만 jsonpath 술어를 GIN이 항상 세밀하게 활용하지는 못해 recheck 비용이 있으므로, 단순 등호/범위라면 아래의 표현식 인덱스가 더 예측 가능하다.
RAG 메타데이터 필터링 관점
RAG 서빙에서 document_chunks/documents의 metadata에 tenant, source, lang, 접근 권한 같은 필드를 넣고 벡터 검색과 함께 거는 경우가 많다. 이때 “값 일치”(tenant, lang)는 표현식 인덱스가, “키 존재”(옵션 플래그 유무)는 jsonb_ops GIN이 적합하다. 벡터 인덱스와의 상호작용은 pgvector - 셋업과 HNSW 인덱스의 필터 스타베이션 항목과 함께 설계해야 한다. 메타필터가 지나치게 좁으면 HNSW 후보가 모두 걸러져 결과가 비기 때문이다.
실전
워크로드에 맞는 인덱스 선택
-- 키 존재(?, ?|, ?&) 검사가 필요하면 기본 jsonb_ops
CREATE INDEX idx_docs_meta ON documents USING gin (metadata);
-- 포함(@>) 조회만 한다면 더 작고 빠른 jsonb_path_ops
CREATE INDEX idx_docs_meta_path
ON documents USING gin (metadata jsonb_path_ops);특정 경로만 조회하면 표현식 인덱스
전체 metadata를 GIN으로 색인할 필요 없이 자주 거는 한두 경로만 조회한다면, 표현식(B-tree) 인덱스가 훨씬 가볍고 정렬·범위 조회에도 유리하다.
-- metadata->>'lang' 등호/정렬용 B-tree 표현식 인덱스
CREATE INDEX idx_docs_lang ON documents ((metadata->>'lang'));
-- 이 쿼리가 위 인덱스를 탄다 (표현식이 정확히 일치해야 함)
SELECT id FROM documents WHERE metadata->>'lang' = 'ko';숫자 비교가 필요하면 캐스팅까지 인덱스 표현식에 포함시킨다.
CREATE INDEX idx_docs_score ON documents (((metadata->>'score')::int));
SELECT id FROM documents WHERE (metadata->>'score')::int >= 80;EXPLAIN으로 확인 후 추가
인덱스를 추측으로 붙이지 말고, 실제 쿼리가 어디서 시간을 쓰는지 먼저 본다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM documents WHERE metadata @> '{"lang":"ko"}';Seq Scan on documents ... rows removed by filter가 크게 나오면 인덱스가 없거나 못 타는 것이고, 인덱스 추가 후 Bitmap Index Scan on idx_docs_meta로 바뀌는지 확인한다.
함정·트러블슈팅
| 증상 | 원인 | 대응 |
|---|---|---|
?/`? | /?&` 쿼리가 seq scan | jsonb_path_ops 인덱스는 키 존재 미지원 |
| GIN 인덱스가 있는데도 안 탐 | 쿼리 연산자가 해당 클래스 미지원 | 워크로드 연산자에 맞는 클래스 선택 |
| 표현식 인덱스 무시됨 | 인덱스 표현식과 쿼리 표현식 불일치 | metadata->>'lang' 등 표현식을 정확히 일치 |
| 쓰기 지연·인덱스 비대 | 무분별한 전체 metadata GIN | 필요한 경로만 표현식 인덱스로 축소 |
- GIN은 공짜가 아니다 — GIN 인덱스는 쓰기 비용과 용량이 크다.
fastupdate(기본 on)로 쓰기를 완화하지만 조회 지연이 튈 수 있다. 정말 GIN이 필요한지EXPLAIN으로 확인한 뒤 붙인다. @>는 최상위 포함 매칭 —@>는 중첩 구조 전체 포함을 검사한다. 배열 요소 포함 여부 등은 경로/캐스팅과 함께 신중히 설계한다.- 부분 인덱스와 결합 — 특정 조건 문서만 자주 조회하면
WHERE (metadata->>'active') = 'true'부분 인덱스로 크기를 더 줄일 수 있다.
정리
- jsonb GIN에는
jsonb_ops(기본)와jsonb_path_ops두 클래스가 있다.jsonb_path_ops는@>에 빠르지만 키 존재?/?|/?&를 지원하지 않는다. - 키 존재 검사를 쓰면 기본
jsonb_opsGIN을 쓰고, 포함(@>) 위주면 더 작은jsonb_path_ops를 고른다. - 자주 거는 특정 경로는 전체 GIN보다 표현식(B-tree) 인덱스가 가볍고 범위·정렬에 유리하다.
- 무엇이든
EXPLAIN (ANALYZE, BUFFERS)로 seq scan을 확인한 뒤 인덱스를 추가한다. GIN은 쓰기·용량 비용이 있으니 남발하지 않는다.
관련: 안전한 스키마 마이그레이션 · autovacuum와 bloat · pgvector - 셋업과 HNSW 인덱스