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/documentsmetadata에 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 scanjsonb_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_ops GIN을 쓰고, 포함(@>) 위주면 더 작은 jsonb_path_ops를 고른다.
  • 자주 거는 특정 경로는 전체 GIN보다 표현식(B-tree) 인덱스가 가볍고 범위·정렬에 유리하다.
  • 무엇이든 EXPLAIN (ANALYZE, BUFFERS)로 seq scan을 확인한 뒤 인덱스를 추가한다. GIN은 쓰기·용량 비용이 있으니 남발하지 않는다.

관련: 안전한 스키마 마이그레이션 · autovacuum와 bloat · pgvector - 셋업과 HNSW 인덱스