백엔드 데이터 인프라 33편. JSONB의 깊이 — 연산자·GIN 인덱스·jsonb_path_query·실전 활용 풀어쓴 학습 노트.
이 글은 백엔드 데이터 인프라 시리즈 70편 중 33편이에요. 32편 데이터 타입 개요 에서 JSONB를 짧게 소개했죠. 이번 33편은 PG의 "가장 강력한 차별 무기" — JSONB 깊이.
JSON vs JSONB — 정확한 비교
| JSON | JSONB | |
|---|---|---|
| 저장 | 텍스트 그대로 | 이진 (파싱 후) |
| 입력 속도 | 빠름 | 약간 느림 (파싱) |
| 조회 속도 | 매번 파싱 | 빠름 |
| 인덱스 | X | GIN 인덱스 가능 |
| 키 순서 | 보존 | 정렬 |
| 중복 키 | 보존 | 마지막만 |
| 크기 | 약간 작음 | 약간 큼 |
99% 시나리오 = JSONB. JSON 은 "원본 포맷 보존 필수" (감사 로그 등) 일 때만.
기본 입력·조회
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 입력
INSERT INTO events (payload) VALUES
('{"user": 1, "action": "click", "url": "/home"}'),
('{"user": 2, "action": "purchase", "amount": 50000, "items": [1, 2, 3]}');
-- 조회
SELECT payload FROM events WHERE id = 1;
문자열 형태로 입력 — PG 가 자동으로 JSONB 변환.
연산자 — 추출
-- -> = JSONB 추출 (결과 JSONB)
SELECT payload -> 'user' FROM events;
-- "1" (JSONB 타입)
-- ->> = 텍스트 추출 (결과 TEXT)
SELECT payload ->> 'user' FROM events;
-- 1 (TEXT)
-- #> = 깊은 JSONB 추출
SELECT payload #> '{items, 0}' FROM events;
-- 1 (JSONB)
-- #>> = 깊은 텍스트 추출
SELECT payload #>> '{items, 0}' FROM events;
-- 1 (TEXT)
룰:
- -> · #> = JSONB 반환 (체이닝 가능)
- ->> · #>> = TEXT 반환 (최종 값)
연산자 — 검사
-- @> = 포함
WHERE payload @> '{"action": "click"}' -- payload 안에 이 JSON 포함
-- <@ = 포함됨
WHERE '{"a": 1}'::JSONB <@ payload
-- ? = 키 존재
WHERE payload ? 'user' -- top-level 'user' 키 있음
WHERE payload ? 'items'
-- ?| = 키 중 하나라도
WHERE payload ?| ARRAY['user', 'admin']
-- ?& = 키 모두
WHERE payload ?& ARRAY['user', 'action']
이 연산자들이 — GIN 인덱스로 가속 가능. NoSQL DB 부럽지 않은 성능.
GIN 인덱스 — JSONB 의 핵심
-- 기본 GIN 인덱스
CREATE INDEX idx_events_payload ON events USING GIN (payload);
-- 더 가벼운 (jsonb_path_ops)
CREATE INDEX idx_events_payload_ops ON events USING GIN (payload jsonb_path_ops);
| 인덱스 | 지원 연산자 | 크기 | 속도 |
|---|---|---|---|
| GIN (기본) | @>·?·?|·?& |
큼 | 보통 |
| GIN jsonb_path_ops | @> 만 |
작음 | 빠름 |
@> 만 사용하면 — jsonb_path_ops 가 50% 작고 빠름.
부분 인덱스 — 특정 키만
-- payload->>'user' 컬럼만 인덱스
CREATE INDEX idx_events_user ON events ((payload->>'user'));
-- 활용
SELECT * FROM events WHERE payload->>'user' = '1'; -- 인덱스 사용
특정 필드 자주 검색 = 표현식 인덱스. GIN 보다 작고 빠름.
수정 함수
-- jsonb_set: 값 갱신
UPDATE events SET payload = jsonb_set(payload, '{action}', '"updated"');
-- jsonb_set + create_if_missing
UPDATE events SET payload = jsonb_set(payload, '{new_key}', '"value"', TRUE);
-- 키 제거
UPDATE events SET payload = payload - 'old_key';
-- 깊은 키 제거
UPDATE events SET payload = payload #- '{a, b}';
-- 병합 (얕은)
UPDATE events SET payload = payload || '{"extra": 1}';
jsonb_path_query — SQL/JSON Path
PG 12+ 의 강력 도구.
-- 단순 추출
SELECT jsonb_path_query(payload, '$.user') FROM events;
-- 필터
SELECT jsonb_path_query(payload, '$.items[*] ? (@ > 1)') FROM events;
-- 조건부 검사 (boolean)
SELECT jsonb_path_match(payload, '$.amount > 10000') FROM events;
XPath 의 JSON 버전. 복잡한 JSON 탐색에 강력.
빌더 함수
-- 객체 만들기
SELECT jsonb_build_object('id', 1, 'name', 'Alice');
-- {"id": 1, "name": "Alice"}
-- 배열 만들기
SELECT jsonb_build_array(1, 2, 3);
-- [1, 2, 3]
-- 행 → JSON
SELECT row_to_json(users) FROM users WHERE id = 1;
API 응답 SQL 안에서 직접.
집계 함수
-- 그룹 결과를 JSON 배열로
SELECT
user_id,
jsonb_agg(jsonb_build_object('id', id, 'amount', amount)) AS orders
FROM orders
GROUP BY user_id;
결과:
user_id | orders
--------+----------------------------------------------
1 | [{"id":1,"amount":10000},{"id":2,"amount":5000}]
복잡한 N+1 회피 — 한 SQL 로 모두.
실전 — 회원 메타 정보
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email CITEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
meta JSONB NOT NULL DEFAULT '{}'::JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 동적 메타 데이터
INSERT INTO users (email, name, meta) VALUES
('alice@x.com', 'Alice',
'{"preferences": {"theme": "dark", "lang": "ko"}, "verified": true}');
-- 인덱스
CREATE INDEX idx_users_meta ON users USING GIN (meta jsonb_path_ops);
-- 조회
SELECT * FROM users WHERE meta @> '{"verified": true}';
SELECT * FROM users WHERE meta->'preferences'->>'theme' = 'dark';
-- 부분 인덱스로 빠르게
CREATE INDEX idx_users_theme
ON users ((meta->'preferences'->>'theme'));
스키마 마이그레이션 없이 새 필드 추가 가능. NoSQL 의 유연 + RDBMS 의 무결성.
함정 5가지
(1) JSON 박기
JSONB 무조건. JSON 은 거의 안 씀.
(2) GIN 인덱스 안 박고 검색
SELECT * FROM events WHERE payload @> '{...}';
-- 풀스캔 (느림)
GIN 인덱스 박기.
(3) jsonb_set 의 create_if_missing 누락
jsonb_set(payload, '{new_key}', 'value') -- ❌ 새 키 안 생김
jsonb_set(payload, '{new_key}', 'value', TRUE) -- ✅
(4) NULL JSONB
UPDATE events SET payload = jsonb_set(payload, '{a}', 'b');
-- payload 가 NULL 이면 결과도 NULL
COALESCE(payload, '{}'::JSONB) 안전 가드.
(5) 너무 큰 JSONB
한 컬럼에 100KB 박으면 — TOAST 압축 + 디스크 비용. 자주 읽는 필드는 별도 컬럼.
(1) 동적 메타데이터 = JSONB + GIN. (2) 자주 검색 필드 = 표현식 인덱스. (3) API 응답 = jsonb_build_object + jsonb_agg. (4) 마이그레이션 = JSON 으로 시작 → 안정화 후 컬럼화. (5) 큰 JSONB는 별도 컬럼 추출.
한 줄 정리 — JSONB = PG 의 차별 무기. ->·->>·@>·? 연산자 + GIN 인덱스. jsonb_set·jsonb_path_query·jsonb_build_object·jsonb_agg 가 핵심 도구. 스키마 유연 + RDBMS 무결성. NoSQL 와 RDBMS 의 좋은 조합.
시험 직전 한 번 더 — JSONB 입문자가 매번 헷갈리는 것
- JSON vs JSONB = JSONB 거의 항상
- JSON 은 원본 포맷 보존 필수만
->JSONB 추출,->>TEXT 추출#>깊은 JSONB,#>>깊은 TEXT- 체이닝 = JSONB 결과는 또
->가능 @>포함,<@포함됨?키 존재,?|?&여러 키- GIN 인덱스 = JSONB 필수
- jsonb_path_ops = @> 만 빠름·작음
- 표현식 인덱스 =
((payload->>'key')) jsonb_set(jsonb, path, value, create_if_missing)payload - 'key'= 키 제거payload || other= 얕은 병합- jsonb_path_query = SQL/JSON Path
- jsonb_build_object/array = JSON 만들기
- jsonb_agg = 그룹 → JSON 배열
- N+1 회피 = jsonb_agg + JOIN 한 SQL
- 동적 메타데이터 = JSONB
- 큰 JSONB = TOAST 압축
- 자주 읽는 필드 = 별도 컬럼 추출
- NULL JSONB 주의 = COALESCE
- citext + JSONB 자주 함께
- row_to_json = 행 → JSON
- API 응답 = SQL 안에서 직접 만들기
- JPA =
@Type(JsonBinaryType.class)또는 hypersistence
시리즈 다른 편
- Part 2 SQL Language 깊이: 28편 ~ 32편 데이터 타입 개요 · 33편 (현재 글)
시리즈 다음 글
다음 글(34편)에서는 인덱스 소개 — PG 인덱스의 큰 그림.
공식 문서: PostgreSQL 18 — JSON Data Types에서 더 자세한 사양을 확인할 수 있어요.