- Published on
PostgreSQL JSONB 인덱스가 안타는 이유 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 로그를 보면 분명 JSONB에 GIN 인덱스를 만들어뒀는데, EXPLAIN은 계속 Seq Scan을 보여줄 때가 있습니다. 이 문제는 “PostgreSQL이 인덱스를 무시한다”기보다, 현재 쿼리 형태와 인덱스가 서로 맞지 않거나(연산자 불일치), 비용 추정이 인덱스가 더 비싸다고 판단하거나(통계/선택도), 혹은 데이터 타입·표현식 때문에 인덱스 조건으로 내려가지 못하는 경우가 대부분입니다.
아래는 실무에서 가장 자주 만나는 JSONB 인덱스가 안 타는 이유 7가지와, 각각의 진단/해결 방법입니다. (PostgreSQL 12+ 기준으로 설명하되, 버전에 따라 세부 동작은 조금씩 다를 수 있습니다.)
> 참고로 DB 성능 문제는 애플리케이션/인프라 이슈와 엮여 재현이 어렵습니다. 장애성 이슈를 다룬 글로는 PostgreSQL RDS deadlock_detected(40P01) 원인·해결도 함께 보면 운영 관점에서 도움이 됩니다.
0) 먼저 확인: 어떤 인덱스가 있고, 실행계획은 무엇인가
문제 재현 전, 최소한 아래 3가지는 확보해야 합니다.
- 실제 쿼리(바인딩 값 포함)
- 인덱스 정의
- 실행계획(
EXPLAIN (ANALYZE, BUFFERS))
-- 인덱스 확인
\d+ events
-- 실행계획(실측)
EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM events
WHERE payload @> '{"type":"click"}';
실행계획에서 특히 봐야 할 포인트:
Seq ScanvsBitmap Index Scan/Index ScanRows Removed by Filter가 큰지(필터를 나중에 적용하는지)Recheck Cond가 있는지(GIN bitmap 후 재검사)Planning Time/Execution Time과Buffers(읽기 패턴)
이제부터는 “왜 인덱스가 안 타는가”를 7가지 유형으로 분류해 해결합니다.
1) 연산자 불일치: 인덱스가 지원하는 연산자를 안 쓴다
JSONB에 가장 흔히 거는 인덱스는 GIN이며, 보통 다음 두 가지 연산자 계열을 기대합니다.
- containment:
@> - key existence:
?,?|,?&
그런데 쿼리가 ->, ->>, jsonb_extract_path_text() 같은 추출 함수 위주면, GIN(JSONB) 인덱스가 바로 매칭되지 않습니다.
안 타는 예
-- payload에서 type을 문자열로 뽑아 비교 (대부분 GIN jsonb 인덱스와 직접 매칭 안 됨)
SELECT id
FROM events
WHERE payload->>'type' = 'click';
해결 1: 쿼리를 containment로 바꾸기
SELECT id
FROM events
WHERE payload @> '{"type":"click"}';
해결 2: 표현식(B-tree) 인덱스 추가
payload->>'type' 패턴이 많다면, 아예 그 표현식에 B-tree 인덱스를 거는 편이 더 낫습니다.
CREATE INDEX CONCURRENTLY idx_events_payload_type
ON events ((payload->>'type'));
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM events WHERE payload->>'type' = 'click';
2) opclass 선택 실수: jsonb_ops vs jsonb_path_ops
GIN 인덱스는 opclass에 따라 지원/성능이 달라집니다.
jsonb_ops(기본): 기능 폭이 넓음(키/값/구조 전반), 인덱스가 커질 수 있음jsonb_path_ops:@>containment에 특화(작고 빠른 경우가 많음), 대신 지원 범위가 좁음
흔한 함정
jsonb_path_ops로 만들어 놓고, containment가 아닌 패턴(존재 연산자 등)을 주로 쓰면 기대만큼 못 탑니다.
-- containment 특화 인덱스
CREATE INDEX CONCURRENTLY idx_events_payload_gin_path
ON events USING gin (payload jsonb_path_ops);
-- 그런데 쿼리는 key existence 위주
SELECT id FROM events WHERE payload ? 'type';
해결
@>위주면jsonb_path_ops유지/추가?/?|/?&도 많이 쓰면jsonb_ops로 별도 인덱스를 두거나, 쿼리 패턴을 통일
-- 범용 인덱스(기본 opclass)
CREATE INDEX CONCURRENTLY idx_events_payload_gin_ops
ON events USING gin (payload);
> 인덱스 2개가 부담되면, 실제 워크로드에서 가장 비싼 상위 N개 쿼리 패턴을 기준으로 선택하세요.
3) 타입/캐스팅 불일치: 숫자/불리언/문자열이 섞여 선택도가 깨진다
JSON은 타입이 중요합니다. {"age": 10}과 {"age": "10"}은 다릅니다. 쿼리에서 문자열로 비교하거나, 반대로 숫자 비교를 하려고 캐스팅을 걸면 인덱스 사용이 어려워집니다.
안 타는 예 (캐스팅/함수 적용)
SELECT id
FROM events
WHERE (payload->>'age')::int >= 18;
위 쿼리는 표현식 인덱스 없이는 GIN 인덱스로 해결하기 어렵습니다.
해결 1: 표현식 인덱스 + 타입 정규화
CREATE INDEX CONCURRENTLY idx_events_age_int
ON events (((payload->>'age')::int));
SELECT id
FROM events
WHERE ((payload->>'age')::int) >= 18;
해결 2: JSON 구조를 타입 일관되게 저장
- age는 항상 숫자로 저장
- boolean은 항상
true/false로 저장 - 날짜는 ISO8601 문자열로 저장하되, 자주 필터링하면 별도 컬럼으로 승격
JSONB는 유연하지만, 인덱싱/통계/비용추정은 “일관된 타입”을 좋아합니다.
4) 조건이 너무 넓다: 선택도 낮으면 Seq Scan이 더 싸다
인덱스가 “가능”해도, PostgreSQL은 **비용 기반(cost-based)**으로 더 싸다고 판단하면 Seq Scan을 택합니다. JSONB 조건이 너무 흔하면(예: 80% 행이 매치) 인덱스가 오히려 손해입니다.
예
-- 대부분의 row가 type을 가지고 있다면
SELECT count(*)
FROM events
WHERE payload ? 'type';
이 경우 인덱스로 찾고 테이블을 다시 읽는 random I/O가 많아져 Seq Scan이 선택될 수 있습니다.
해결
- 더 선택적인 조건을 추가(기간, tenant_id 등)
- JSONB에서 자주 필터링하는 필드는 일반 컬럼으로 분리(정규화)
- 부분 인덱스(partial index)로 범위를 좁히기
-- 최근 30일 데이터만 자주 조회한다면 부분 인덱스
CREATE INDEX CONCURRENTLY idx_events_payload_recent
ON events USING gin (payload)
WHERE created_at >= now() - interval '30 days';
EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM events
WHERE created_at >= now() - interval '7 days'
AND payload @> '{"type":"click"}';
5) OR/NOT/복잡한 논리: 플래너가 인덱스 결합을 포기한다
OR가 섞이면 인덱스 조건을 잘게 쪼개 BitmapOr로 결합할 수도 있지만, 조건이 복잡하거나 비용이 커지면 Seq Scan으로 넘어갈 수 있습니다. NOT(<>, NOT (payload @> ...))도 마찬가지로 인덱스 친화적이지 않습니다.
안 타는 예
SELECT id
FROM events
WHERE payload @> '{"type":"click"}'
OR payload @> '{"type":"view"}';
해결 1: IN/배열 형태로 재구성(가능한 경우)
JSONB는 SQL의 IN처럼 깔끔하게 바꾸기 어려울 수 있어, 종종 UNION ALL이 더 명확합니다.
EXPLAIN (ANALYZE, BUFFERS)
(
SELECT id FROM events WHERE payload @> '{"type":"click"}'
)
UNION ALL
(
SELECT id FROM events WHERE payload @> '{"type":"view"}'
);
해결 2: 부정 조건은 “긍정 조건 + 제외”로 재설계
예: NOT 조건이 핵심이라면, 먼저 좁은 집합을 만든 뒤 제외하는 방식이 더 낫습니다(CTE/서브쿼리).
6) 통계/ANALYZE 부족: JSONB는 특히 추정이 틀리기 쉽다
플래너가 인덱스를 선택하려면 “얼마나 많은 row가 걸릴지”를 알아야 합니다. 그런데 JSONB 조건은 컬럼 통계가 제한적이라 추정 오차가 커지기 쉽고, 그 결과 인덱스를 타야 하는데도 Seq Scan이 나올 수 있습니다.
해결 1: 기본 점검(자주 놓침)
VACUUM (ANALYZE) events;
-- 또는
ANALYZE events;
해결 2: 통계 타겟 조정(특정 컬럼)
ALTER TABLE events ALTER COLUMN payload SET STATISTICS 1000;
ANALYZE events;
해결 3: 확장 통계(정규 컬럼과 결합 조건이 많을 때)
예를 들어 tenant_id와 created_at 같이 자주 함께 필터링되는 일반 컬럼에는 확장 통계를 고려할 수 있습니다(JSONB 자체에 대한 직접적 해결은 제한적이지만, 전체 비용추정에는 도움).
CREATE STATISTICS st_events_tenant_created (dependencies)
ON tenant_id, created_at
FROM events;
ANALYZE events;
운영 환경에서 통계/플래너 문제는 다른 장애와 함께 나타나기도 합니다. DB 락/데드락까지 동반한다면 앞서 언급한 글(PostgreSQL RDS deadlock_detected(40P01) 원인·해결)도 같이 점검하세요.
7) 인덱스는 타지만 “느리다”: GIN 재검사/힙 접근이 병목
EXPLAIN에 인덱스가 보인다고 끝이 아닙니다. JSONB GIN은 종종 다음 패턴으로 동작합니다.
Bitmap Index Scan으로 후보 TID를 모음Bitmap Heap Scan으로 테이블(힙)을 읽음Recheck Cond로 실제 조건을 재검사
후보가 많거나(선택도 낮음), 테이블이 커서 랜덤 읽기가 많으면 인덱스를 타도 느립니다. 사용자는 이를 “인덱스가 안 탄다”로 오해하기도 합니다(체감상 비슷하니까요).
진단 포인트
EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM events
WHERE payload @> '{"type":"click"}'
LIMIT 100;
Heap Blocks: exact가 큰지Recheck Cond로 많은 row가 걸러지는지BUFFERS에서read가 급증하는지
해결
- 더 선택적인 조건 추가(기간/테넌트)
- 부분 인덱스
- 필요한 컬럼만 조회하도록 커버링 설계(단, GIN은 전통적 의미의 covering이 제한적)
- 자주 쓰는 필드는 컬럼으로 승격 + B-tree
추가로, 애플리케이션에서 JSONB 필터를 “동적 쿼리”로 만들다 보면 조건 조합이 폭발합니다. 이때는 서버 액션/캐시/쿼리 형태가 꼬이면서 체감 성능이 더 나빠질 수 있는데, 웹 애플리케이션 관점의 디버깅은 Next.js 14 서버액션 500·CSRF·캐시 꼬임 해결 같은 글의 접근(재현/관측/격리)이 의외로 도움이 됩니다.
실전 체크리스트: 10분 안에 결론 내는 순서
EXPLAIN (ANALYZE, BUFFERS)로 실측한다(추정만 보지 말 것)- 쿼리 연산자가 인덱스와 맞는지 확인한다(
@>,?vs->>) - 인덱스 opclass가 쿼리 패턴과 맞는지 본다(
jsonb_opsvsjsonb_path_ops) - 캐스팅/함수로 컬럼을 감싸 인덱스 조건이 깨졌는지 본다
- 선택도가 낮아 Seq Scan이 합리적인지 확인한다(매칭 비율)
ANALYZE/통계 타겟을 조정해 비용추정이 개선되는지 본다- 인덱스가 타도 느리면(힙 접근/재검사) 조건을 더 좁히거나 스키마를 바꾼다
결론: “JSONB 인덱스”가 아니라 “쿼리-인덱스-통계”의 삼각형 문제
PostgreSQL에서 JSONB는 강력하지만, 인덱스가 잘 타려면 (1) 인덱스가 지원하는 연산자, (2) 일관된 타입과 쿼리 형태, (3) 충분한 통계와 높은 선택도가 함께 맞아야 합니다. 특히 payload->>'x' = ... 같은 패턴이 많다면, GIN 하나로 해결하려 하기보다 표현식 B-tree 인덱스 또는 컬럼 승격을 병행하는 것이 장기적으로 가장 안정적입니다.
원하시면 실제 인덱스 DDL과 문제 쿼리, EXPLAIN (ANALYZE, BUFFERS) 결과를 주시면 “왜 안 타는지”를 위 7가지 중 어디에 해당하는지 짚어서, 가장 비용 대비 효과가 큰 수정안(쿼리 변경 vs 인덱스 추가 vs 부분 인덱스 vs 스키마 변경)으로 정리해드릴게요.