백엔드 데이터 인프라 33편 — JSON과 JSONB 깊이

2026-05-17백엔드 데이터 인프라

백엔드 데이터 인프라 33편. JSONB의 깊이 — 연산자·GIN 인덱스·jsonb_path_query·실전 활용 풀어쓴 학습 노트.

📚 백엔드 데이터 인프라 · 33편 — JSON과 JSONB 깊이

이 글은 백엔드 데이터 인프라 시리즈 70편 중 33편이에요. 32편 데이터 타입 개요 에서 JSONB(이진 저장 JSON 타입)를 짧게 소개했죠. 이번 33편은 PG 가 다른 RDBMS 와 가장 크게 벌어지는 지점, 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(Generalized Inverted Index) 인덱스로 가속되니, JSON 검색 성능에서 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 가 크기는 절반쯤이고 속도는 더 나옵니다.

부분 인덱스 — 특정 키만

-- 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(XML 경로 질의 언어) 의 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(대용량 값 별도 저장 메커니즘) 압축과 디스크 비용이 따라옵니다. 자주 읽는 필드는 별도 컬럼으로 빼는 편이 낫습니다.

🎯 JSONB 활용 패턴

(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

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!