PostgreSQL JSONB

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는 가변적인 속성/메타데이터에 사용합니다.

참고 자료

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux