- Published on
PostgreSQL JSONB 인덱스 안 타는 이유 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 로그나 APM에서 특정 API가 느려져 EXPLAIN (ANALYZE, BUFFERS)를 까보면, JSONB 컬럼에 인덱스까지 만들어놨는데도 Seq Scan이 보이는 경우가 흔합니다. 특히 JSONB는 연산자 종류가 많고(키 존재, 포함, 경로, 텍스트 비교 등), 인덱스 타입도 B-tree/GIN/GiST로 갈리며, 통계 수집 방식도 일반 컬럼과 다르게 체감되는 부분이 있어 “인덱스가 있는데 왜 안 타지?”가 자주 발생합니다.
이 글은 JSONB 인덱스가 안 타는 대표적인 7가지 이유를 실행 계획에서 어떻게 확인하고, 어떤 쿼리/인덱스로 바꿔야 하는지를 중심으로 정리합니다.
관련해서 테이블이 커지고 bloat가 생기면 플래너 추정이 더 흔들릴 수 있으니, 운영에서 autovacuum 이슈가 있다면 함께 점검하세요: PostgreSQL autovacuum 지연으로 테이블 bloat 해결
준비: 예제 테이블과 데이터
CREATE TABLE events (
id bigserial PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now(),
props jsonb NOT NULL
);
-- 예시 인덱스들(상황별로 다르게 사용)
CREATE INDEX events_props_gin ON events USING gin (props);
CREATE INDEX events_props_path_gin ON events USING gin (props jsonb_path_ops);
-- 특정 키를 자주 조회한다면 표현식 인덱스
CREATE INDEX events_props_user_id_btree ON events ((props->>'userId'));
실행 계획 확인은 최소한 아래 형태로 보세요.
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM events
WHERE props @> '{"type":"purchase"}'::jsonb;
1) 연산자가 인덱스 지원 대상이 아니다(또는 타입이 다르다)
JSONB에서 “비슷해 보이는데 인덱스가 안 타는” 1순위는 연산자 선택입니다.
- GIN 인덱스가 잘 타는 대표 패턴
- 포함:
@> - 키 존재:
?,?|,?& - JSONPath(버전에 따라):
@?,@@
- 포함:
- 반대로 자주 실수하는 패턴
props->>'key' = 'value'를 GIN 하나로 해결하려고 함
예를 들어 아래는 GIN(props)만으로는 기대만큼 못 탈 수 있습니다(플래너가 비용상 Seq Scan을 택하거나, 인덱스 조건으로 못 밀어넣는 경우).
SELECT *
FROM events
WHERE props->>'type' = 'purchase';
이 경우 선택지는 보통 둘 중 하나입니다.
- 쿼리를
@>형태로 바꿔 GIN을 확실히 태우기
SELECT *
FROM events
WHERE props @> '{"type":"purchase"}'::jsonb;
- 해당 키가 핵심 필터라면 표현식 B-tree 인덱스를 추가
CREATE INDEX events_type_btree ON events ((props->>'type'));
SELECT *
FROM events
WHERE props->>'type' = 'purchase';
정리하면, “JSONB 인덱스”라고 뭉뚱그리면 안 되고 내 쿼리의 연산자가 어떤 인덱스 타입과 결합되는지부터 확인해야 합니다.
2) jsonb_path_ops를 써놓고, 쿼리가 그 기능을 안 쓴다
jsonb_path_ops는 GIN 인덱스를 더 작고 빠르게 만들 수 있지만, 지원하는 쿼리 패턴이 제한적입니다. 주로 @> 포함 연산에 최적화되어 있고, 모든 연산자를 커버하지 않습니다.
예를 들어 아래 인덱스를 만들었는데:
CREATE INDEX events_props_path_gin ON events USING gin (props jsonb_path_ops);
이후 키 존재 연산을 많이 쓰면:
SELECT *
FROM events
WHERE props ? 'userId';
플래너가 해당 인덱스를 못 쓰거나(또는 비효율적이라 포기) 다른 경로를 택할 수 있습니다. 이때는
@>위주면jsonb_path_ops유지- 키 존재/배열 관련/다양한 연산이면 기본
jsonb_ops(디폴트) GIN을 고려
처럼 워크로드에 맞춰 인덱스 클래스를 선택해야 합니다.
3) 함수/캐스팅/연산을 씌워서 인덱스 표현과 불일치
인덱스는 “정확히 같은 표현”에만 잘 붙습니다. JSONB 쿼리에서 자주 하는 실수는 다음입니다.
lower(props->>'email') = 'a@b.com'to_timestamp((props->>'ts')::bigint)props->'a'->'b'경로를 매번 가공
예:
SELECT *
FROM events
WHERE lower(props->>'email') = 'test@example.com';
(props->>'email')에 인덱스가 있어도 lower(...) 때문에 못 탑니다. 해결은 표현식 인덱스를 쿼리와 동일하게 만드는 것입니다.
CREATE INDEX events_email_lower_btree ON events (lower((props->>'email')));
SELECT *
FROM events
WHERE lower(props->>'email') = 'test@example.com';
캐스팅도 마찬가지입니다.
-- 숫자 비교를 문자열로 하면 범위/정렬 의미가 깨지고 플래너도 흔들립니다.
SELECT *
FROM events
WHERE (props->>'amount')::numeric >= 100;
-- 표현식 인덱스를 정확히 맞추기
CREATE INDEX events_amount_num_btree ON events (((props->>'amount')::numeric));
핵심은 “인덱스가 있는 컬럼을 건드리지 말라”가 아니라, 건드릴 거면 그 표현을 인덱스로 박아라입니다.
4) 선택도가 낮아(너무 많이 매칭) 인덱스가 더 비싸다
인덱스가 안 타는 건 “못 타서”가 아니라 “타면 손해라서”인 경우가 많습니다.
예를 들어 type='view'가 전체의 80%라면, GIN으로 후보 TID를 잔뜩 모은 뒤 힙을 랜덤 I/O로 읽는 것보다, 그냥 테이블을 쭉 읽는 게 더 쌀 수 있습니다.
이럴 때의 접근:
- 더 선택적인 조건과 결합해서 복합 전략을 만들기
- 예:
created_at범위 + JSONB 조건
- 예:
- 파티셔닝/시간 조건으로 먼저 줄이기
- 부분 인덱스(partial index)로 “자주 찾는 소수 케이스”만 인덱싱
부분 인덱스 예:
CREATE INDEX events_purchase_props_gin
ON events USING gin (props)
WHERE props @> '{"type":"purchase"}'::jsonb;
SELECT *
FROM events
WHERE props @> '{"type":"purchase"}'::jsonb
AND created_at >= now() - interval '7 days';
이 패턴은 “전체 JSONB를 다 인덱싱”하지 않고도 자주 쓰는 케이스를 빠르게 만들 수 있습니다.
5) 통계/플래너 추정이 틀어져서 잘못된 계획을 고른다
플래너는 통계를 기반으로 비용을 계산합니다. JSONB는 구조가 유동적이라 통계가 빈약해지기 쉽고, 테이블이 커지거나 업데이트가 많으면 더 흔들립니다.
점검 루틴:
ANALYZE events;
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM events
WHERE props @> '{"type":"purchase"}'::jsonb;
만약 ANALYZE 이후 계획이 바뀐다면 통계 문제 가능성이 큽니다. 운영에서는 다음을 함께 봅니다.
- autovacuum/analyze가 제때 도는지
- 테이블 bloat로 인해 I/O 비용이 커져 플래너 판단이 달라지지 않는지
이 주제는 별도 글로 더 깊게 다뤘습니다: PostgreSQL autovacuum 지연으로 테이블 bloat 해결
추가로, 특정 키를 자주 필터링한다면 JSONB 전체 통계에 기대기보다 표현식 인덱스 + 컬럼 통계 형태로 플래너가 예측하기 쉬운 구조로 바꾸는 게 효과적입니다.
6) GIN 인덱스의 fastupdate/pending list로 인해 성능이 역전된다
GIN은 쓰기 성능을 위해 pending list를 두는 fastupdate 옵션이 있습니다. 쓰기가 많은 테이블에서 pending list가 커지면, 조회 시 pending list를 병합/스캔하는 비용이 커져 “인덱스를 타도 느려서” 플래너가 다른 계획을 고를 수 있습니다.
확인:
SELECT
indexrelid::regclass AS index_name,
*
FROM pg_stat_all_indexes
WHERE indexrelid::regclass::text LIKE '%events%';
대응 옵션:
VACUUM으로 pending list를 정리- 워크로드에 따라
fastupdate를 끄거나 튜닝
-- fastupdate 비활성화(쓰기 비용 증가 가능)
ALTER INDEX events_props_gin SET (fastupdate = off);
-- pending list 정리를 유도
VACUUM (ANALYZE) events;
주의: 무조건 fastupdate=off가 정답은 아닙니다. 쓰기/읽기 비율과 지연 허용치에 따라 결정해야 합니다.
7) OR, NOT, 부정 조건 때문에 인덱스 사용이 깨진다
JSONB 조건을 여러 개 섞을 때 OR/NOT이 들어가면 인덱스 사용이 어려워지거나, BitmapOr/BitmapAnd로 가더라도 비용이 커져 Seq Scan으로 회귀할 수 있습니다.
예:
SELECT *
FROM events
WHERE (props @> '{"type":"purchase"}'::jsonb)
OR (props @> '{"type":"refund"}'::jsonb);
이 경우는 다음처럼 쿼리를 재구성하면 좋아지는 경우가 많습니다.
UNION ALL로 분해(중복 제거 필요 없으면UNION ALL)
SELECT *
FROM events
WHERE props @> '{"type":"purchase"}'::jsonb
UNION ALL
SELECT *
FROM events
WHERE props @> '{"type":"refund"}'::jsonb;
- 부정 조건(
NOT)은 가능한 긍정 조건으로 바꾸거나, 범위를 좁히는 다른 조건(시간/상태)을 먼저 적용
-- 나쁜 예: 부정은 대개 선택도가 높아(대부분이 참) 인덱스 효율이 낮습니다.
SELECT *
FROM events
WHERE NOT (props ? 'debug');
-- 개선: debug가 있는 것만 따로 관리하거나, debug=false 같은 명시적 플래그로 모델링
특히 JSONB는 “키가 없으면 조건이 어떻게 평가되는지”까지 섬세하게 설계해야 하므로, 부정 조건이 늘어나면 성능과 정합성 둘 다 흔들릴 수 있습니다.
실전 체크리스트: 인덱스가 안 탈 때 이렇게 진단
EXPLAIN (ANALYZE, BUFFERS)로 실제 실행 계획 확인- 조건이
@>,?계열인지, 아니면->>텍스트 비교인지 분류 - 인덱스 타입이 맞는지 확인
- 포함 위주: GIN +
@> - 특정 키 동등/범위: 표현식 B-tree
- 포함 위주: GIN +
- 함수/캐스팅이 인덱스를 깨고 있지 않은지 확인
- 선택도 문제인지 확인(너무 많이 매칭하면 인덱스가 손해)
ANALYZE/autovacuum 상태 확인- GIN
fastupdate및 pending list 문제 의심 OR/NOT을UNION ALL등으로 재구성 가능한지 검토
마무리: “JSONB 인덱스”가 아니라 “쿼리 패턴 인덱스”다
JSONB는 유연하지만, 그만큼 플래너가 확신을 갖기 어렵고(통계), 연산자/인덱스 조합에 따라 성능 편차가 큽니다. 결론은 단순합니다.
- 포함 검색이면
@>에 맞춰 GIN을 설계하고 - 특정 키 필터가 핵심이면 표현식 B-tree로 “관계형 컬럼처럼” 다루며
- 통계/GIN 운영 옵션까지 포함해 플래너가 올바른 비용을 계산하도록 돕는다
이 3가지를 기준으로 보면, “인덱스가 있는데 왜 안 타지?” 문제의 대부분은 재현되고 해결됩니다.