PostgreSQL 기초 강좌 #7 JSONB: 관계형 데이터베이스에 유연함 더하기

4 분 소요

“이 부분은 스키마가 계속 바뀌는데, 그것 때문에 NoSQL을 따로 둬야 하나"라는 고민에 대한 PostgreSQL의 답이 JSONB입니다. 관계형 테이블의 한 컬럼에 JSON 문서를 넣고, 그 안을 인덱스로 검색할 수 있습니다. 1편에서 “그건 별도 시스템이 필요하다던 것들이 내장되어 있다"고 했던 대표 사례이고, DynamoDB vs RDS에서 다룬 선택 문제의 상당 부분을 PostgreSQL 하나로 해결할 수 있게 해 주는 기능입니다.

json이 아니라 jsonb입니다 #

타입이 둘 있습니다. json은 입력 텍스트를 그대로 보관하고, jsonb는 파싱된 이진 형태로 저장합니다. 실무 기본값은 jsonb입니다. 조회 연산이 훨씬 빠르고 인덱스를 만들 수 있기 때문입니다. json을 고를 이유는 “입력 원문(키 순서, 중복 키, 공백)을 그대로 보존해야 한다"는 특수한 요건뿐입니다.

events 테이블 생성
CREATE TABLE events (
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id    bigint NOT NULL REFERENCES users (id),
    event_type text NOT NULL,
    payload    jsonb NOT NULL DEFAULT '{}',
    created_at timestamptz NOT NULL DEFAULT now()
);

INSERT INTO events (user_id, event_type, payload) VALUES
    (1, 'purchase', '{"item": "keyboard", "amount_krw": 89000, "coupon": {"code": "AUG10", "rate": 0.1}}');

이벤트 로그, 외부 API 응답 보관, 상품별로 제각각인 속성처럼 “행마다 모양이 다른 데이터"가 JSONB의 자리입니다.

연산자: 꺼내기와 묻기 #

JSONB 연산자
-- 꺼내기: ->는 jsonb로, ->>는 text로
SELECT payload -> 'coupon' -> 'code'   FROM events;  -- "AUG10" (jsonb)
SELECT payload ->> 'item'              FROM events;  -- keyboard (text)
SELECT payload #>> '{coupon,code}'     FROM events;  -- AUG10 (경로로 text)

-- 묻기: ?는 키 존재, @>는 포함
SELECT * FROM events WHERE payload ? 'coupon';                      -- coupon 키가 있는 행
SELECT * FROM events WHERE payload @> '{"item": "keyboard"}';       -- 이 구조를 포함하는 행

->->>의 구분이 첫 관문입니다. ->는 결과가 여전히 jsonb라서 체인을 이어 갈 수 있고, ->>는 text로 꺼내므로 비교·출력의 마지막 단계에 씁니다. payload ->> 'amount_krw'를 숫자와 비교하려면 (payload ->> 'amount_krw')::numeric처럼 캐스팅이 필요합니다. JSON 안에는 자료형이 느슨하게 들어 있으므로, 2편에서 다진 자료형 규율이 JSONB 경계에서 캐스팅으로 다시 등장하는 셈입니다.

GIN 인덱스: JSONB 검색의 짝 #

B-tree는 “컬럼 값 전체"를 정렬하는 인덱스라서 문서 안쪽을 못 봅니다. JSONB 검색의 짝은 GIN 인덱스입니다. 문서 안의 키와 값을 뒤집어 색인해서, @>(포함)나 ?(존재) 검색을 인덱스로 받쳐 줍니다.

GIN 인덱스 생성
CREATE INDEX idx_events_payload ON events USING GIN (payload);

-- 이제 이 검색이 인덱스를 탑니다
SELECT * FROM events WHERE payload @> '{"coupon": {"code": "AUG10"}}';

효과는 5편의 EXPLAIN ANALYZE로 확인하면 됩니다(Bitmap Heap Scan 형태가 정상입니다). 다만 GIN은 문서 전체를 색인하므로 쓰기 비용과 크기가 상당합니다. 특정 키 하나만 검색한다면 그 식에만 B-tree를 만드는 식 인덱스(ON events ((payload ->> 'item')))가 훨씬 가볍습니다. GIN 계열의 심화(옵션, 다른 인덱스 타입과의 비교)는 실전 편에서 다룹니다.

갱신은 문서 단위가 기본입니다 #

jsonb_set으로 갱신
UPDATE events
SET payload = jsonb_set(payload, '{coupon,rate}', '0.15')
WHERE id = 1;

jsonb_set으로 경로 하나만 바꾸는 문법이 있지만, 저장 층에서는 6편에서 본 MVCC 원리대로 행의 새 버전이 통째로 만들어집니다. “필드 하나만 바꾸니까 싸겠지"가 아니라는 뜻입니다. 큰 문서를 자주 부분 갱신하는 워크로드라면, 자주 바뀌는 값을 문서 밖의 일반 컬럼으로 빼는 것이 맞는 설계입니다.

설계 경계: 전부 JSONB에 넣고 싶어질 때 #

JSONB의 유혹은 “그냥 다 payload에 넣자"입니다. 경계는 명확합니다. 조인 키, 자주 검색·정렬하는 값, NOT NULL·CHECK·FK로 지키고 싶은 값은 일반 컬럼입니다. JSONB 내부에는 이런 제약이 닿지 않으므로(2편에서 세운 “무결성은 DB 층에서” 원칙이 문서 안에서는 무력합니다), 구조가 안정된 핵심 필드는 컬럼으로 승격하고, 모양이 계속 바뀌는 주변부만 JSONB에 남기는 것이 균형점입니다. 위의 events 테이블이 그 모범입니다. user_id와 event_type은 컬럼, 나머지가 payload입니다.

정리 #

  • 타입은 jsonb가 기본값입니다. json은 입력 원문 보존이라는 특수 요건 전용입니다.
  • 꺼내기는 ->(jsonb)와 -»(text)의 구분이 핵심이고, 비교에는 캐스팅이 따라옵니다. 검색은 ?(존재)와 @>(포함)입니다.
  • JSONB 검색의 짝은 GIN 인덱스입니다. 특정 키 하나만 찾는다면 식 인덱스가 더 가볍습니다.
  • 부분 갱신도 MVCC상 행 전체의 새 버전입니다. 자주 바뀌는 값은 문서 밖 컬럼으로 뺍니다.
  • 조인 키·검색 대상·제약이 필요한 값은 컬럼, 모양이 바뀌는 주변부만 JSONB입니다. 다음 편은 뷰, CTE, 윈도 함수로 쿼리의 표현력을 올립니다.
X