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의 약자다. 전문 검색 엔진이 단어별로 "이 단어가 등장하는 문서 목록"을 관리하는 것과 같은 구조를 쓴다.
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_ops | jsonb_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_snapshot에 skyStatus: 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는 결과가 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은 데이터가 충분히 많을 때 의미가 있다.