JSON vs JSONB: 저장 방식의 근본적 차이
PostgreSQL은 두 가지 JSON 타입을 제공합니다. 이름이 비슷하지만 내부 저장 방식이 완전히 다릅니다.
| 항목 | JSON | JSONB |
|---|---|---|
| 저장 형태 | 원본 텍스트 그대로 | 바이너리로 파싱 후 저장 |
| 공백·키 순서 | 보존 | 제거/재정렬 |
| 중복 키 | 보존 (마지막 값 사용) | 마지막 값만 유지 |
| INSERT 속도 | 빠름 (파싱 없음) | 느림 (파싱 필요) |
| 조회 속도 | 느림 (매번 파싱) | 빠름 (사전 파싱) |
| 인덱스 | 불가 | GIN 인덱스 지원 |
| 동등 비교(=) | 불가 | 가능 |
결론: 거의 모든 실무 상황에서 JSONB를 사용합니다. JSON 타입은 원본 텍스트를 정확히 보존해야 하는 감사 로그 등 극히 제한적인 경우에만 사용합니다.
JSONB 컬럼 정의와 기본 조작
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
attributes JSONB NOT NULL DEFAULT '{}',
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 데이터 삽입
INSERT INTO products (name, attributes, metadata)
VALUES (
'MacBook Pro 16',
'{
"brand": "Apple",
"specs": {
"cpu": "M3 Max",
"ram_gb": 36,
"storage_gb": 1024
},
"colors": ["Space Black", "Silver"],
"in_stock": true,
"price": 3499.00
}',
'{"source": "crawler", "fetched_at": "2026-02-21T10:00:00Z"}'
);
JSONB 접근 연산자: ->, ->>, #>, #>>
| 연산자 | 반환 타입 | 설명 | 예시 |
|---|---|---|---|
-> |
jsonb | 키/인덱스로 접근 | attributes->'brand' → "Apple" (jsonb) |
->> |
text | 키/인덱스로 접근 (텍스트) | attributes->>'brand' → Apple (text) |
#> |
jsonb | 경로 배열로 중첩 접근 | attributes#>'{specs,cpu}' → "M3 Max" |
#>> |
text | 경로 배열로 중첩 접근 (텍스트) | attributes#>>'{specs,cpu}' → M3 Max |
-- 기본 접근
SELECT
name,
attributes->>'brand' AS brand, -- text
attributes->'specs'->>'cpu' AS cpu, -- 체이닝
attributes#>>'{specs,ram_gb}' AS ram, -- 경로 접근
(attributes->'specs'->>'ram_gb')::int AS ram_int, -- 타입 캐스팅
attributes->'colors'->>0 AS first_color, -- 배열 인덱스
jsonb_array_length(attributes->'colors') AS color_count -- 배열 길이
FROM products;
핵심: ->는 jsonb를 반환하고 ->>는 text를 반환합니다. WHERE 조건에서 숫자 비교를 하려면 (column->>'key')::int처럼 명시적 캐스팅이 필요합니다.
JSONB 포함 연산자: @>, <@, ?, ?|, ?&
| 연산자 | 설명 | 예시 |
|---|---|---|
@> |
왼쪽이 오른쪽을 포함 | attributes @> '{"brand":"Apple"}' |
<@ |
왼쪽이 오른쪽에 포함됨 | '{"brand":"Apple"}' <@ attributes |
? |
최상위 키 존재 여부 | attributes ? 'brand' |
?| |
키 중 하나라도 존재 (OR) | attributes ?| array['brand','model'] |
?& |
모든 키 존재 (AND) | attributes ?& array['brand','price'] |
-- Apple 브랜드이면서 재고가 있는 제품
SELECT * FROM products
WHERE attributes @> '{"brand": "Apple", "in_stock": true}';
-- 중첩 객체 포함 검사
SELECT * FROM products
WHERE attributes @> '{"specs": {"cpu": "M3 Max"}}';
-- colors 배열에 "Silver" 포함
SELECT * FROM products
WHERE attributes->'colors' ? 'Silver';
-- brand 키가 존재하는 행만
SELECT * FROM products
WHERE attributes ? 'brand';
중요: @> 연산자는 GIN 인덱스를 활용할 수 있어 대용량 테이블에서도 빠릅니다. 반면 ->>로 값을 추출한 뒤 비교하는 방식은 인덱스를 타지 못합니다.
GIN 인덱스: JSONB 쿼리 성능의 핵심
JSONB의 진정한 힘은 GIN(Generalized Inverted Index) 인덱스에 있습니다. 두 가지 operator class를 제공합니다:
| Operator Class | 지원 연산자 | 인덱스 크기 | 적합한 상황 |
|---|---|---|---|
jsonb_ops (기본) |
@>, ?, ?|, ?& |
큼 | 다양한 키로 검색할 때 |
jsonb_path_ops |
@>만 |
작음 (2~3배) | 포함 검색(@>)이 대부분일 때 |
-- 기본 GIN 인덱스 (jsonb_ops)
CREATE INDEX idx_products_attrs ON products USING GIN (attributes);
-- jsonb_path_ops — @> 전용이지만 인덱스 크기 2~3배 작음
CREATE INDEX idx_products_attrs_path ON products USING GIN (attributes jsonb_path_ops);
-- 특정 키의 값에 대한 B-Tree 인덱스 (Expression Index)
CREATE INDEX idx_products_brand ON products ((attributes->>'brand'));
-- 숫자 비교를 위한 Expression Index
CREATE INDEX idx_products_price ON products (((attributes->>'price')::numeric));
어떤 인덱스를 선택해야 하는가
| 쿼리 패턴 | 권장 인덱스 | 이유 |
|---|---|---|
WHERE attrs @> '{"brand":"Apple"}' |
GIN (jsonb_path_ops) | @> 전용, 인덱스 크기 작음 |
WHERE attrs ? 'brand' |
GIN (jsonb_ops) | ? 연산자는 jsonb_ops만 지원 |
WHERE attrs->>'brand' = 'Apple' |
B-Tree Expression | GIN은 ->> 비교를 지원하지 않음 |
WHERE (attrs->>'price')::numeric > 1000 |
B-Tree Expression | 범위 비교는 B-Tree가 적합 |
JSONB 수정 연산자와 함수
-- || : 병합 (Merge) — 최상위 키 덮어쓰기
UPDATE products
SET attributes = attributes || '{"in_stock": false, "discount": 0.1}'
WHERE id = 1;
-- jsonb_set: 중첩 경로의 특정 값 변경
UPDATE products
SET attributes = jsonb_set(attributes, '{specs,ram_gb}', '64')
WHERE id = 1;
-- jsonb_set: 새 키 추가 (create_if_missing 기본값 true)
UPDATE products
SET attributes = jsonb_set(attributes, '{specs,gpu}', '"M3 Max GPU"')
WHERE id = 1;
-- - : 키 제거
UPDATE products
SET attributes = attributes - 'discount'
WHERE id = 1;
-- #- : 중첩 경로의 키 제거
UPDATE products
SET attributes = attributes #- '{specs,gpu}'
WHERE id = 1;
-- 배열에 요소 추가
UPDATE products
SET attributes = jsonb_set(
attributes,
'{colors}',
(attributes->'colors') || '"Midnight"'
)
WHERE id = 1;
수정 연산자 정리
| 연산자/함수 | 동작 | 예시 |
|---|---|---|
|| |
최상위 키 병합/덮어쓰기 | attrs || '{"k":"v"}' |
jsonb_set() |
특정 경로의 값 설정 | jsonb_set(attrs, '{a,b}', '1') |
- (text) |
최상위 키 제거 | attrs - 'key' |
- (int) |
배열에서 인덱스로 제거 | attrs - 0 (첫 요소) |
#- |
경로 배열로 중첩 키 제거 | attrs #- '{a,b}' |
jsonb_each·jsonb_array_elements: 행 전개 함수
-- jsonb_each: 키-값 쌍을 행으로 전개
SELECT p.name, kv.key, kv.value
FROM products p,
jsonb_each(p.attributes) AS kv
WHERE p.id = 1;
-- 결과:
-- name | key | value
-- MacBook Pro 16 | brand | "Apple"
-- MacBook Pro 16 | specs | {"cpu":"M3 Max","ram_gb":36,...}
-- MacBook Pro 16 | colors | ["Space Black","Silver"]
-- ...
-- jsonb_array_elements: 배열을 행으로 전개
SELECT p.name, color.value AS color
FROM products p,
jsonb_array_elements_text(p.attributes->'colors') AS color
WHERE p.id = 1;
-- 결과:
-- name | color
-- MacBook Pro 16 | Space Black
-- MacBook Pro 16 | Silver
-- 실전: 특정 색상을 가진 제품 검색
SELECT DISTINCT p.name
FROM products p,
jsonb_array_elements_text(p.attributes->'colors') AS color
WHERE color.value = 'Silver';
jsonb_path_query: SQL/JSON Path (PostgreSQL 12+)
PostgreSQL 12에서 도입된 SQL/JSON Path는 JSONB 데이터를 XPath와 유사한 표현식으로 조회합니다.
-- 기본 경로 조회
SELECT jsonb_path_query(attributes, '$.specs.cpu') AS cpu
FROM products WHERE id = 1;
-- "M3 Max"
-- 배열 요소 순회
SELECT jsonb_path_query(attributes, '$.colors[*]') AS each_color
FROM products WHERE id = 1;
-- "Space Black"
-- "Silver"
-- 필터 조건: RAM 32GB 이상인 제품
SELECT name FROM products
WHERE jsonb_path_exists(attributes, '$.specs ? (@.ram_gb >= 32)');
-- 조건부 값 추출
SELECT name,
jsonb_path_query_first(attributes, '$.price') AS price
FROM products
WHERE jsonb_path_exists(attributes, '$.price ? (@ > 1000)');
주요 Path 함수 비교
| 함수 | 반환 | 설명 |
|---|---|---|
jsonb_path_query() |
setof jsonb | 모든 매칭 결과 (여러 행) |
jsonb_path_query_first() |
jsonb (단일) | 첫 번째 매칭 결과만 |
jsonb_path_exists() |
boolean | 매칭 존재 여부 |
jsonb_path_match() |
boolean | Path 표현식의 boolean 결과 |
집계 함수: jsonb_agg·jsonb_object_agg
-- 행을 JSON 배열로 집계
SELECT jsonb_agg(jsonb_build_object(
'id', id,
'name', name,
'brand', attributes->>'brand'
)) AS products_json
FROM products
WHERE attributes @> '{"in_stock": true}';
-- 키-값 쌍으로 JSON 객체 집계
SELECT jsonb_object_agg(
attributes->>'brand',
count(*)
) AS brand_counts
FROM products
GROUP BY attributes->>'brand';
JSONB 컬럼 설계: 언제 써야 하고 언제 피해야 하는가
| JSONB가 적합한 경우 | 정규 컬럼이 적합한 경우 |
|---|---|
| 스키마가 제품/카테고리마다 다른 속성 | 모든 행이 동일한 구조를 가질 때 |
| 외부 API 응답을 그대로 저장 | JOIN·FK로 관계가 필요할 때 |
| 사용자 정의 메타데이터, 설정값 | 자주 집계(SUM, AVG)하는 숫자 데이터 |
| 스키마 변경이 빈번한 초기 개발 단계 | NOT NULL, CHECK 등 제약이 필요할 때 |
| 이벤트 페이로드, 로그 데이터 | ORDER BY, GROUP BY가 주 쿼리 패턴일 때 |
안티패턴: “JSONB 하나에 모든 데이터를 넣는” 것은 관계형 DB의 장점(타입 안전성, 제약 조건, 효율적 인덱싱)을 포기하는 것입니다. JSONB는 정규 컬럼을 보완하는 용도로 사용하세요. 핵심 비즈니스 필드(status, user_id, created_at)는 반드시 정규 컬럼으로 유지합니다.
EXPLAIN으로 JSONB 쿼리 성능 검증
-- GIN 인덱스가 사용되는지 확인
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM products
WHERE attributes @> '{"brand": "Apple"}';
-- 결과 예시:
-- Bitmap Heap Scan on products
-- Recheck Cond: (attributes @> '{"brand": "Apple"}'::jsonb)
-- -> Bitmap Index Scan on idx_products_attrs_path ← GIN 인덱스 사용
-- Index Cond: (attributes @> '{"brand": "Apple"}'::jsonb)
-- ❌ GIN을 타지 못하는 패턴
EXPLAIN SELECT * FROM products
WHERE attributes->>'brand' = 'Apple';
-- Seq Scan on products ← Expression Index 없으면 전체 스캔
핵심 정리
- JSONB를 사용하세요. JSON은 원본 보존이 필요한 극히 제한된 경우에만 사용합니다.
@>연산자 + GIN 인덱스가 JSONB 쿼리의 가장 효율적인 조합입니다.->>로 추출 후 비교하면 GIN을 활용할 수 없습니다.- GIN 인덱스는 두 가지:
jsonb_ops(범용)와jsonb_path_ops(@> 전용, 더 작음). 쿼리 패턴에 따라 선택합니다. - 특정 키의 값으로 범위 검색(=, <, >)이 필요하면 Expression Index(B-Tree)를 생성합니다.
- jsonb_set()과 || 연산자로 부분 업데이트가 가능하므로, 전체 JSON을 교체할 필요가 없습니다.
- 핵심 비즈니스 필드는 반드시 정규 컬럼으로 유지하고, JSONB는 가변적인 속성/메타데이터에 사용합니다.
참고 자료