Quick Reference
SELECT
profile ->> 'nickname' AS nickname
FROM users
WHERE profile ->> 'country' = 'KR';JSONB는 유연한 부가 속성에 두고, 조인·외래 키·반복 정렬 조건은 일반 컬럼 또는 generated column으로 분리합니다. JSON 전체·키 존재·포함 검색은 GIN, 자주 비교하는 한 경로는 B-tree expression index가 기본 선택입니다. 기본 jsonb_ops GIN은 ? 계열과 @>를 넓게 지원하고, 더 작은 jsonb_path_ops는 @>와 일부 jsonpath 연산에만 맞습니다.
문법
-> 와 ->> 의 반환 타입 차이가 필터 동작을 결정한다
-> 는 JSON 값 자체(jsonb 타입)를 반환하고, ->> 는 텍스트(text 타입)를 반환합니다. 이 차이는 단순한 출력 형식 문제가 아니라 비교식의 literal 형식도 바꿉니다. JSONB 비교는 profile -> 'country' = '"KR"'::jsonb처럼 JSON 문자열로 써야 하고, 텍스트 비교는 ->>가 읽기 쉽습니다. 중첩된 값을 꺼낼 때는 #>> (경로 배열)를 사용합니다.
-- 중첩 경로 접근
SELECT
profile #>> '{address, city}' AS city
FROM users
WHERE profile #>> '{address, country}' = 'KR';JSONB는 유연한 속성을 넣을 때 쓰고, 핵심 키는 일반 컬럼으로 남겨 두는 편이 안전하다
JSONB는 스키마가 자주 바뀌는 부가 속성에는 강하지만, 조인 키나 핵심 필터 컬럼까지 모두 넣어 두는 용도에는 맞지 않습니다. "자주 조인하거나 제약을 걸 값인가"를 먼저 묻고, 그렇다면 일반 컬럼으로 분리하는 편이 낫습니다.
-- 부가 속성 저장
profile JSONB
-- 핵심 키는 일반 컬럼
org_id BIGINT NOT NULLJSONB query 형태가 GIN과 expression index의 선택을 결정한다
jsonb 컬럼에 기본 GIN(jsonb_ops)을 만들면 @> (포함), ?·?|·?& (키 존재)와 indexable jsonpath 조건을 index 후보로 만들 수 있습니다. jsonb_path_ops GIN은 @>와 일부 @?·@@ jsonpath 조건에 집중해 더 작고 빠를 수 있지만, ? 계열 키 존재 연산자는 지원하지 않습니다. 반면 ->> 기반 텍스트 비교는 GIN이 아니라 해당 expression의 B-tree index를 별도로 검토합니다.
-- GIN 인덱스: @> 와 ? 연산자에 유효
CREATE INDEX idx_users_profile_gin ON users USING GIN (profile);
-- 표현식 인덱스: ->> 텍스트 필터에 유효
CREATE INDEX idx_users_profile_country
ON users ((profile ->> 'country'));
-- @> 연산자로 필터: GIN 인덱스 활용 가능
SELECT * FROM users
WHERE profile @> '{"country": "KR"}';JSONB는 스키마를 늦추는 도구이지 관계형 설계를 대체하는 도구는 아니다
실험적 속성, 외부 API 원본, 사용자별 확장 설정처럼 구조가 자주 바뀌는 값은 JSONB가 잘 맞습니다. 반대로 정렬, 조인, 제약, 집계가 반복되는 값은 일반 컬럼으로 빼는 편이 더 낫습니다. "자주 조회하는 핵심 키인가"를 먼저 물으면 JSONB 남용을 줄일 수 있습니다.
-- 유연한 부가 속성
settings JSONB
-- 핵심 필터와 조인은 일반 컬럼
country_code TEXT NOT NULL
org_id BIGINT NOT NULLJSONB는 유연성과 관계형 제약 사이의 트레이드오프다
jsonb 는 스키마 변경 없이 새 속성을 추가하기 쉬워 이벤트 페이로드, 외부 API 응답, 확장 속성 저장에 잘 맞습니다. JSONB 컬럼에도 NOT NULL과 CHECK를 걸 수 있고, JSON shape·필수 키도 expression으로 검사할 수 있습니다. 다만 JSON 경로 expression 자체를 외래 키 대상으로 만들 수는 없으므로, 참조·유일성·반복 조인이 필요한 값은 일반 컬럼 또는 generated column으로 꺼내는 편이 낫습니다.
-- 나쁜 패턴: 자주 조인되는 값을 JSONB 안에 숨김
SELECT u.id, p.profile ->> 'org_id' AS org
FROM users u JOIN profiles p ON p.user_id = u.id;
-- 좋은 패턴: 조인 키는 일반 컬럼으로 분리
SELECT u.id, u.org_id
FROM users u
WHERE u.org_id = 42;CREATE TABLE user_profiles (
profile jsonb NOT NULL
CHECK (jsonb_typeof(profile) = 'object')
CHECK (profile ? 'country'),
country_code text GENERATED ALWAYS AS (profile ->> 'country') STORED,
FOREIGN KEY (country_code) REFERENCES countries(code)
);jsonb_set 과 || 로 JSONB를 부분 수정할 수 있다
JSONB 값을 업데이트할 때 전체를 덮어쓰지 않고 특정 키만 바꾸려면 jsonb_set 함수나 || (병합 연산자)를 사용합니다. 두 방법의 차이는 기존 키 유지 여부에 있습니다. || 는 최상위 키를 병합(기존 값 덮어쓰기)하고, jsonb_set 은 경로를 지정해 중첩 키도 수정합니다.
-- 특정 키 값 변경
UPDATE users
SET profile = jsonb_set(profile, '{nickname}', '"새닉네임"')
WHERE id = 1;
-- 최상위 키 병합 (기존 키는 유지, country만 변경)
UPDATE users
SET profile = profile || '{"country": "US"}'
WHERE id = 1;선택 기준
| 상황 | 적합한 선택 |
|---|---|
| JSON 값 자체를 반환해야 할 때 | -> (jsonb 타입 반환) |
| 텍스트 비교 필터가 필요할 때 | ->> (text 타입 반환) |
| 키 존재 여부나 포함 관계 인덱싱 | GIN 인덱스 + @> / ? 연산자 |
? 키 존재 검색도 필요하다 | 기본 GIN jsonb_ops |
| 포함 검색 위주이고 index 크기가 중요하다 | jsonb_path_ops를 연산자 범위와 함께 검토 |
| 특정 텍스트 키를 자주 필터링할 때 | 표현식 인덱스 ((col ->> 'key')) |
| 자주 조인되거나 제약이 필요한 값 | 일반 컬럼으로 분리 |
| 스키마가 자주 바뀌는 확장 속성 | JSONB 컬럼 |
주의할 점
JSONB는 편하지만 "컬럼 설계를 나중으로 미루는 만능 해결책"은 아닙니다. ->> 텍스트 필터는 GIN 인덱스를 타지 않으므로
자주 필터링하는 키는 표현식 인덱스를 별도로 만들어야 합니다. 핵심 식별자나 조인 키까지 JSONB 안에 넣기 시작하면
제약 설계와 실행 계획 최적화가 모두 어려워집니다.
SELECT *
FROM users
WHERE profile -> 'country' = '"KR"';이처럼 JSON 값 비교를 억지로 문자열처럼 맞추면 읽기 어려워지고 실수도 늘어납니다. 텍스트 비교가 목적이면 보통 ->>가 더 자연스럽습니다.
SELECT *
FROM users
WHERE profile ->> 'is_active' = true;->>는 text를 반환하므로, 이렇게 불리언이나 숫자와 바로 비교하면 타입이 섞여 오류나 암묵적 캐스팅 혼란이 생깁니다. JSONB 필터는 "지금 내가 꺼낸 값이 text인지 jsonb인지"를 먼저 확인하는 습관이 중요합니다.
참고 링크
2 sources