JSONB 컬럼에 필터 조건을 걸면 PostgreSQL은 테이블 전체를 훑는다. 행이 수천 건만 되어도 응답 시간이 눈에 띄게 느려진다. GIN 인덱스는 JSONB 내부의 키-값 쌍을 역색인으로 만들어, 특정 조건에 해당하는 행만 빠르게 찾아내는 인덱스 유형이다.

B-tree가 JSONB에 맞지 않는 이유

B-tree 인덱스는 하나의 스칼라 값을 기준으로 정렬된 트리를 구성한다. 정수, 문자열, 타임스탬프처럼 값 하나가 곧 정렬 키인 컬럼에 적합하다.

JSONB는 하나의 컬럼 안에 여러 키-값 쌍이 들어 있다. {"skyStatus": "CLEAR", "type": "NONE", "current": 22.5} 같은 데이터를 B-tree로 인덱싱하면, JSON 문서 전체를 하나의 값으로 취급해서 정렬한다. "skyStatus가 CLEAR인 행을 찾아라" 같은 내부 키 기반 검색에는 전혀 도움이 되지 않는다.

역색인의 동작 원리

GIN은 Generalized Inverted Index의 약자다. 전문 검색 엔진이 단어별로 "이 단어가 등장하는 문서 목록"을 관리하는 것과 같은 구조를 쓴다.

flowchart LR subgraph btree ["B-tree"] direction TB R1["row 1"] --- V1["22.5"] R2["row 2"] --- V2["CLEAR"] R3["row 3"] --- V3["NONE"] end btree ~~~ gin subgraph gin ["GIN 역색인"] direction TB K1["skyStatus: CLEAR"] --- T1["row 1, row 5, row 8"] K2["type: NONE"] --- T2["row 1, row 3"] K3["current: 22.5"] --- T3["row 1, row 12"] end style btree fill:#f0f8f0,stroke:#4CAF50 style gin fill:#e3f2fd,stroke:#2196f3 style K1 fill:#e3f2fd,stroke:#2196f3,stroke-width:2px style K2 fill:#e3f2fd,stroke:#2196f3,stroke-width:2px style K3 fill:#e3f2fd,stroke:#2196f3,stroke-width:2px style T1 fill:#fff3e0,stroke:#FF9800,stroke-width:2px style T2 fill:#fff3e0,stroke:#FF9800,stroke-width:2px style T3 fill:#fff3e0,stroke:#FF9800,stroke-width:2px style R1 fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style R2 fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style R3 fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style V1 fill:#fff3e0,stroke:#FF9800,stroke-width:2px style V2 fill:#fff3e0,stroke:#FF9800,stroke-width:2px style V3 fill:#fff3e0,stroke:#FF9800,stroke-width:2px

B-tree는 행 하나가 값 하나를 가리킨다. GIN은 반대로, 키-값 조합 하나가 해당되는 행 목록을 가리킨다. skyStatus: CLEAR라는 엔트리 하나에 row 1, 5, 8이 연결되어 있으므로, 이 세 행만 꺼내면 된다.

이 구조 덕분에 "이 JSON 안에 특정 키-값이 포함되어 있는가?"라는 질문에 매우 빠르게 답할 수 있다.

연산자 클래스

GIN 인덱스를 생성할 때 연산자 클래스를 지정할 수 있다. 어떤 클래스를 쓰느냐에 따라 지원하는 연산자와 인덱스 크기가 달라진다.

jsonb_ops

기본값이다. 별도로 지정하지 않으면 이 클래스가 적용된다.

CREATE INDEX idx_weather ON feeds USING gin (weather_snapshot);
-- 위와 동일
CREATE INDEX idx_weather ON feeds USING gin (weather_snapshot jsonb_ops);

JSONB의 키와 값을 모두 개별 엔트리로 인덱싱한다. 그래서 아래 연산자를 전부 지원한다.

  • @> — containment. JSON이 특정 하위 구조를 포함하는가
  • ? — 특정 키가 존재하는가
  • ?| — 주어진 키 중 하나라도 존재하는가
  • ?& — 주어진 키가 모두 존재하는가

키 존재 여부를 검색해야 한다면 이 클래스를 써야 한다.

jsonb_path_ops

@> containment 연산자만 지원하는 대신, 인덱스 크기가 훨씬 작고 검색이 빠르다.

CREATE INDEX idx_weather ON feeds USING gin (weather_snapshot jsonb_path_ops);

jsonb_ops가 키와 값을 따로 인덱싱하는 것과 달리, jsonb_path_ops는 루트부터 값까지의 전체 경로를 해시하여 하나의 엔트리로 저장한다. {"skyStatus": "CLEAR"}라면 skyStatus → CLEAR라는 경로 전체를 해시한 값 하나만 들어간다.

엔트리 수가 적으므로 인덱스 크기가 jsonb_ops 대비 2~3배 작다. 검색도 해시 비교만 하면 되니 더 빠르다.

선택 기준

대부분의 JSONB 필터링은 "특정 키에 특정 값이 있는가"를 확인하는 containment 검색이다. 키 존재 여부 검색(?)이 필요 없다면 jsonb_path_ops를 쓰는 것이 유리하다.

기준jsonb_opsjsonb_path_ops
지원 연산자@>, ?, `?\, ?&`@>
인덱스 크기2~3배 작음
검색 속도보통빠름
키 존재 검색가능불가

GIN 인덱스 생성

실제 테이블에 GIN 인덱스를 추가하는 구문이다.

-- feeds 테이블의 weather_snapshot JSONB 컬럼
CREATE INDEX idx_feeds_weather_snapshot
    ON feeds USING gin (weather_snapshot jsonb_path_ops);

-- feed_clothes 테이블의 clothes_snapshot JSONB 컬럼
CREATE INDEX idx_feed_clothes_snapshot
    ON feed_clothes USING gin (clothes_snapshot jsonb_path_ops);

이미 데이터가 있는 테이블에 GIN 인덱스를 생성하면, 전체 행을 스캔하면서 인덱스를 구축한다. 행이 많으면 시간이 걸릴 수 있다. 운영 환경에서는 CONCURRENTLY 옵션으로 테이블 잠금 없이 생성할 수 있다.

CREATE INDEX CONCURRENTLY idx_feeds_weather_snapshot
    ON feeds USING gin (weather_snapshot jsonb_path_ops);

CONCURRENTLY는 인덱스 구축 중에도 읽기/쓰기가 가능하다. 단, 트랜잭션 안에서는 사용할 수 없고, 구축 시간이 더 오래 걸린다.

GIN이 동작하는 쿼리 패턴

GIN 인덱스가 실제로 사용되려면 올바른 연산자를 써야 한다. 가장 중요한 것이 @> containment 연산자다.

-- skyStatus가 CLEAR인 피드 조회
SELECT * FROM feeds
WHERE weather_snapshot @> '{"skyStatus": "CLEAR"}';

-- 강수 타입이 RAIN인 피드 조회
SELECT * FROM feeds
WHERE weather_snapshot @> '{"type": "RAIN"}';

-- 복합 조건도 가능
SELECT * FROM feeds
WHERE weather_snapshot @> '{"skyStatus": "CLEAR", "type": "NONE"}';

@>는 "왼쪽 JSON이 오른쪽 JSON을 포함하는가"를 검사한다. weather_snapshotskyStatus: CLEAR라는 키-값 쌍이 들어 있으면 참이다. GIN 역색인에서 해당 엔트리를 찾아 행 목록을 바로 반환하므로 전체 스캔이 발생하지 않는다.

jsonb_ops를 사용했다면 키 존재 검색도 인덱스를 탄다.

-- weather_snapshot에 skyStatus 키가 존재하는 행
SELECT * FROM feeds
WHERE weather_snapshot ? 'skyStatus';

GIN이 동작하지 않는 쿼리 패턴

GIN 인덱스가 있어도 연산자가 맞지 않으면 무시된다. 다음 패턴들은 전부 Sequential Scan으로 빠진다.

jsonb_extract_path_text 함수

-- GIN 인덱스를 타지 않는다
SELECT * FROM feeds
WHERE jsonb_extract_path_text(weather_snapshot, 'skyStatus') = 'CLEAR';

이 함수는 JSONB에서 값을 text로 추출한 뒤 문자열 비교를 한다. GIN은 JSONB 타입의 containment 연산을 인덱싱하는 것이지, text 비교를 인덱싱하는 것이 아니다. 반환 타입 자체가 달라서 GIN과 무관하다.

화살표 연산자 + 등호 비교

-- 역시 GIN 인덱스를 타지 않는다
SELECT * FROM feeds
WHERE weather_snapshot ->> 'skyStatus' = 'CLEAR';

->> 연산자도 text를 반환한다. jsonb_extract_path_text와 같은 이유로 GIN이 무시된다.

대소 비교

-- GIN은 범위 검색을 지원하지 않는다
SELECT * FROM feeds
WHERE (weather_snapshot ->> 'current')::float > 20.0;

GIN은 "포함 여부"를 검사하는 인덱스다. 크다/작다 같은 범위 비교는 B-tree의 영역이다. 숫자 범위 검색이 필요하면 해당 값을 별도 컬럼으로 추출하고 B-tree 인덱스를 거는 것이 낫다.

EXPLAIN으로 인덱스 확인

인덱스를 생성했으면 실제로 쿼리가 인덱스를 사용하는지 확인해야 한다.

EXPLAIN ANALYZE
SELECT * FROM feeds
WHERE weather_snapshot @> '{"skyStatus": "CLEAR"}';

GIN 인덱스를 타는 경우 실행 계획에 Bitmap Index Scan이 나타난다.

Bitmap Heap Scan on feeds
  Recheck Cond: (weather_snapshot @> '{"skyStatus": "CLEAR"}'::jsonb)
  ->  Bitmap Index Scan on idx_feeds_weather_snapshot
        Index Cond: (weather_snapshot @> '{"skyStatus": "CLEAR"}'::jsonb)

인덱스를 타지 않는 경우 Seq Scan이 나타난다.

Seq Scan on feeds
  Filter: (jsonb_extract_path_text(weather_snapshot, 'skyStatus') = 'CLEAR')

Seq Scan이 보이면 쿼리의 연산자를 확인해야 한다. GIN이 지원하는 연산자를 쓰고 있는지가 첫 번째 체크 포인트다.

행 수가 매우 적으면 PostgreSQL이 인덱스보다 Sequential Scan이 빠르다고 판단하여 인덱스를 무시할 수 있다. 테스트 시에는 충분한 데이터를 넣고 확인하거나, SET enable_seqscan = off;로 강제 비활성화하여 인덱스 동작을 확인한다.

GIN 인덱스의 쓰기 비용

GIN은 읽기 성능을 높이는 대신 쓰기 성능을 희생한다. INSERT나 UPDATE가 발생할 때마다 역색인 엔트리를 갱신해야 하기 때문이다.

PostgreSQL은 이 비용을 줄이기 위해 fastupdate 메커니즘을 사용한다. 새로운 엔트리를 즉시 인덱스에 반영하지 않고, 임시 목록에 모아두었다가 일정량이 쌓이면 한꺼번에 반영한다.

  • fastupdate = on — 기본값. 쓰기가 빠르지만, 임시 목록이 클 때 검색이 느려질 수 있다.
  • fastupdate = off — 즉시 반영. 쓰기가 느려지지만 검색 성능이 일정하다.

피드처럼 읽기가 쓰기보다 훨씬 많은 테이블에서는 기본값을 유지하는 것이 적절하다.

자주 하는 실수

jsonb_extract_path_text를 쓰면서 GIN 효과를 기대

jsonb_extract_path_text는 결과가 text 타입이다. GIN은 JSONB containment 연산을 인덱싱하므로, text 비교 쿼리에는 아무 효과가 없다. @> 연산자로 변환해야 한다.

```sql

-- 변경 전 (GIN 무시)

WHERE jsonb_extract_path_text(weather_snapshot, 'skyStatus') = 'CLEAR'

-- 변경 후 (GIN 사용)

WHERE weather_snapshot @> '{"skyStatus": "CLEAR"}'

```

[!DANGER] jsonb_path_ops에서 키 존재 연산자 사용

jsonb_path_ops@> 연산자만 지원한다. ? 연산자로 키 존재 여부를 검색하면 인덱스를 타지 않고 Sequential Scan이 발생한다. 키 존재 검색이 필요하면 jsonb_ops를 써야 한다.

[!DANGER] 소규모 테이블에 GIN을 걸고 성능 향상을 기대

GIN 인덱스는 역색인을 구축하고 유지하는 비용이 있다. 행이 수백 건 이하인 테이블에서는 Sequential Scan이 오히려 빠르다. PostgreSQL 옵티마이저도 이를 알고 있어서, 행이 적으면 인덱스를 무시한다. GIN은 데이터가 충분히 많을 때 의미가 있다.