PostgreSQL JSONB를 운영에서 쓸 때 먼저 정해야 할 기준

JSONB는 주문 옵션, 사용자 설정, 외부 연동 응답, 이벤트 속성처럼 항목마다 구조가 조금씩 달라지는 데이터를 저장할 때 유용합니다. 하지만 편하다는 이유만으로 모든 컬럼을 JSONB 하나에 넣으면 목록 조회, 필터 검색, 정렬, 통계 집계가 빠르게 어려워집니다. 운영 기준은 단순합니다. 자주 조회하고 정렬하고 조인하는 값은 일반 컬럼으로 빼고, 자주 바뀌거나 조회 조건이 다양한 부가 속성만 JSONB에 둡니다. 특히 상태값, 생성일, 사용자 ID, 테넌트 ID, 결제 금액처럼 업무 흐름을 결정하는 값은 JSONB 내부에만 숨기지 않는 편이 안전합니다.

JSONB 튜닝의 핵심은 쿼리 패턴을 먼저 나누는 것입니다. 첫째, 특정 키가 존재하는지 확인하는 조회가 있습니다. 둘째, 특정 키의 값이 정확히 일치하는지 확인하는 조회가 있습니다. 셋째, JSON 내부 값을 꺼내 범위 비교나 정렬을 하는 조회가 있습니다. 이 세 가지는 같은 JSONB 컬럼을 쓰더라도 필요한 인덱스와 SQL 작성 방식이 다릅니다. 하나의 GIN 인덱스로 모든 문제가 해결된다고 생각하면 실제 서비스에서는 느린 정렬, 높은 메모리 사용, 예상보다 큰 인덱스 때문에 다시 병목을 만나게 됩니다.

예제 테이블과 기본 조회 패턴

아래 예시는 전자상거래 주문 이벤트를 저장하는 테이블입니다. tenant_id와 created_at은 일반 컬럼으로 두고, 결제수단, 쿠폰, 배송지역, 기기 정보 같은 부가 속성은 payload JSONB에 둡니다. 이 구조에서는 테넌트와 기간으로 먼저 범위를 좁힌 뒤 JSONB 조건을 적용하는 방식이 가장 흔합니다.

CREATE TABLE order_events (
  id bigserial PRIMARY KEY,
  tenant_id bigint NOT NULL,
  event_type text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  payload jsonb NOT NULL
);

INSERT INTO order_events (tenant_id, event_type, payload)
VALUES
  (10, 'paid', '{"payment":"card","coupon":"WELCOME","region":"seoul","amount":59000}'),
  (10, 'paid', '{"payment":"bank","region":"busan","amount":42000}'),
  (10, 'cancelled', '{"payment":"card","reason":"stockout","amount":59000}');

-- 특정 키와 값이 포함된 이벤트 조회
SELECT id, created_at, payload
FROM order_events
WHERE tenant_id = 10
  AND created_at >= now() - interval '7 days'
  AND payload @> '{"payment":"card"}'::jsonb
ORDER BY created_at DESC
LIMIT 50;

이 쿼리에서 중요한 점은 JSONB 조건만 보는 것이 아니라 tenant_id, created_at, ORDER BY, LIMIT까지 함께 본다는 것입니다. 실제 화면은 보통 특정 고객사, 특정 기간, 최신순 페이지네이션을 요구합니다. JSONB 인덱스만 추가하고 tenant_id와 created_at 인덱스를 놓치면 데이터가 늘수록 정렬 비용이 다시 커질 수 있습니다.

GIN 인덱스는 포함 검색에 맞춰 사용하기

JSONB의 대표 인덱스는 GIN입니다. payload @> '{"payment":"card"}' 같은 포함 검색, payload ? 'coupon' 같은 키 존재 확인에 잘 맞습니다. 다만 기본 jsonb_ops와 jsonb_path_ops의 차이를 알고 선택해야 합니다. jsonb_ops는 다양한 연산자를 지원하지만 인덱스가 커질 수 있습니다. jsonb_path_ops는 @> 포함 검색에 집중해 보통 더 작고 빠를 수 있지만 지원 범위가 좁습니다. 서비스에서 주로 쓰는 조건이 포함 검색이라면 jsonb_path_ops를 먼저 검토할 만합니다.

-- 테넌트와 최신순 목록 조회를 위한 일반 B-tree 인덱스
CREATE INDEX idx_order_events_tenant_created
ON order_events (tenant_id, created_at DESC);

-- JSONB 포함 검색이 많을 때 검토할 GIN 인덱스
CREATE INDEX idx_order_events_payload_gin
ON order_events USING gin (payload jsonb_path_ops);

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, created_at
FROM order_events
WHERE tenant_id = 10
  AND created_at >= now() - interval '7 days'
  AND payload @> '{"payment":"card"}'::jsonb
ORDER BY created_at DESC
LIMIT 50;

인덱스를 만든 뒤에는 반드시 EXPLAIN으로 실제 실행 계획을 확인해야 합니다. 예상과 달리 순차 스캔이 선택될 수도 있습니다. 테이블이 작거나 조건 선택도가 낮거나 통계가 오래되었거나 LIMIT와 정렬 때문에 다른 인덱스가 더 유리하다고 판단될 수 있기 때문입니다. 운영에서는 쿼리 한 번만 보고 판단하지 말고 피크 시간대의 대표 파라미터, 넓은 기간 조건, 좁은 기간 조건을 나누어 비교하는 것이 좋습니다.

값을 꺼내 비교하거나 정렬한다면 표현식 인덱스와 생성 컬럼

payload->>'region' = 'seoul'처럼 문자열 값을 꺼내 비교하는 정도라면 표현식 인덱스를 쓸 수 있습니다. 하지만 금액, 날짜, 등급처럼 타입 변환이 필요하고 여러 화면에서 반복해서 쓰는 값이라면 생성 컬럼을 두는 편이 더 관리하기 쉽습니다. 생성 컬럼은 JSON 내부 값을 관계형 컬럼처럼 노출하므로 타입, 인덱스, 통계, 제약 조건을 더 명확하게 다룰 수 있습니다.

-- 자주 필터링하는 문자열 값은 표현식 인덱스로 시작할 수 있습니다.
CREATE INDEX idx_order_events_region_expr
ON order_events ((payload->>'region'));

-- 반복 사용되는 숫자 값은 생성 컬럼으로 분리하면 쿼리가 단순해집니다.
ALTER TABLE order_events
ADD COLUMN amount_numeric numeric
GENERATED ALWAYS AS ((payload->>'amount')::numeric) STORED;

CREATE INDEX idx_order_events_amount
ON order_events (tenant_id, amount_numeric);

SELECT id, amount_numeric
FROM order_events
WHERE tenant_id = 10
  AND amount_numeric >= 50000
ORDER BY amount_numeric DESC
LIMIT 20;

생성 컬럼을 만들 때는 JSON 값이 항상 숫자로 변환 가능한지 먼저 확인해야 합니다. 이미 운영 데이터가 섞여 있다면 바로 ALTER TABLE을 실행하기 전에 잘못된 값을 찾는 검증 쿼리를 먼저 돌리는 것이 안전합니다. 데이터 품질이 불안정한 경우에는 애플리케이션 저장 단계에서 스키마 검증을 추가하거나, 별도 정규화 테이블로 분리하는 편이 낫습니다.

부분 인덱스로 쓰는 조건만 작게 만들기

모든 이벤트에 대해 큰 GIN 인덱스를 만드는 것은 비용이 큽니다. 특정 event_type에서만 JSONB 검색이 필요하다면 부분 인덱스를 사용해 인덱스 크기와 쓰기 비용을 줄일 수 있습니다. 예를 들어 결제 완료 이벤트에서만 payment, coupon, amount를 검색한다면 paid 조건을 포함한 부분 인덱스가 더 실용적입니다.

CREATE INDEX idx_order_events_paid_payload_gin
ON order_events USING gin (payload jsonb_path_ops)
WHERE event_type = 'paid';

SELECT id
FROM order_events
WHERE event_type = 'paid'
  AND payload @> '{"coupon":"WELCOME"}'::jsonb;

부분 인덱스는 쿼리의 WHERE 조건이 인덱스 조건과 맞아야 효과가 납니다. 애플리케이션 코드에서 event_type 조건을 빼먹으면 인덱스를 사용하지 못할 수 있습니다. 따라서 반복되는 조회는 Repository, DAO, 쿼리 빌더 같은 한 곳에 모아 같은 조건식을 유지하는 것이 좋습니다.

운영 점검 순서

  • 자주 쓰는 조회 화면을 기준으로 JSONB 조건, 정렬, LIMIT, 기간 조건을 함께 기록합니다.
  • 키 존재 확인, 포함 검색, 값 추출 비교, 정렬 중 어떤 패턴인지 분류합니다.
  • @> 포함 검색 중심이면 GIN jsonb_path_ops를 검토하고, 다양한 연산자가 필요하면 기본 jsonb_ops를 검토합니다.
  • 반복해서 꺼내 쓰는 값은 표현식 인덱스보다 생성 컬럼 또는 일반 컬럼 분리를 우선 검토합니다.
  • 전체 데이터가 아니라 특정 이벤트나 상태에서만 검색한다면 부분 인덱스로 인덱스 크기를 줄입니다.
  • 인덱스 추가 후에는 EXPLAIN (ANALYZE, BUFFERS)로 실제 읽은 블록, 정렬 여부, 실행 시간을 확인합니다.
  • JSONB는 유연성을 위한 도구이지 관계형 모델을 완전히 대체하는 저장소가 아니라는 점을 설계 기준으로 둡니다.