Published on

PostgreSQL JSONB 인덱스가 안타는 이유 7가지

Authors

서버 로그를 보면 분명 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 Scan vs Bitmap Index Scan/Index Scan
  • Rows Removed by Filter가 큰지(필터를 나중에 적용하는지)
  • Recheck Cond가 있는지(GIN bitmap 후 재검사)
  • Planning Time/Execution TimeBuffers (읽기 패턴)

이제부터는 “왜 인덱스가 안 타는가”를 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_idcreated_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분 안에 결론 내는 순서

  1. EXPLAIN (ANALYZE, BUFFERS)실측한다(추정만 보지 말 것)
  2. 쿼리 연산자가 인덱스와 맞는지 확인한다(@>, ? vs ->>)
  3. 인덱스 opclass가 쿼리 패턴과 맞는지 본다(jsonb_ops vs jsonb_path_ops)
  4. 캐스팅/함수로 컬럼을 감싸 인덱스 조건이 깨졌는지 본다
  5. 선택도가 낮아 Seq Scan이 합리적인지 확인한다(매칭 비율)
  6. ANALYZE/통계 타겟을 조정해 비용추정이 개선되는지 본다
  7. 인덱스가 타도 느리면(힙 접근/재검사) 조건을 더 좁히거나 스키마를 바꾼다

결론: “JSONB 인덱스”가 아니라 “쿼리-인덱스-통계”의 삼각형 문제

PostgreSQL에서 JSONB는 강력하지만, 인덱스가 잘 타려면 (1) 인덱스가 지원하는 연산자, (2) 일관된 타입과 쿼리 형태, (3) 충분한 통계와 높은 선택도가 함께 맞아야 합니다. 특히 payload->>'x' = ... 같은 패턴이 많다면, GIN 하나로 해결하려 하기보다 표현식 B-tree 인덱스 또는 컬럼 승격을 병행하는 것이 장기적으로 가장 안정적입니다.

원하시면 실제 인덱스 DDL과 문제 쿼리, EXPLAIN (ANALYZE, BUFFERS) 결과를 주시면 “왜 안 타는지”를 위 7가지 중 어디에 해당하는지 짚어서, 가장 비용 대비 효과가 큰 수정안(쿼리 변경 vs 인덱스 추가 vs 부분 인덱스 vs 스키마 변경)으로 정리해드릴게요.