숏컷 코드
SELECT
profile ->> 'nickname' AS nickname
FROM users
WHERE profile ->> 'country' = 'KR';문법
-> 와 ->> 의 반환 타입 차이가 필터 동작을 결정한다
-> 는 JSON 값 자체(jsonb 타입)를 반환하고, ->> 는 텍스트(text 타입)를 반환합니다. 이 차이는 단순한 출력 형식 문제가 아니라 비교 연산 가능 여부를 결정합니다. WHERE profile -> 'country' = 'KR' 은 타입 불일치로 동작하지 않으며, 텍스트 비교를 위해서는 반드시 ->> 를 써야 합니다. 중첩된 값을 꺼낼 때는 #>> (경로 배열)를 사용합니다.
-- 중첩 경로 접근
SELECT
profile #>> '{address, city}' AS city
FROM users
WHERE profile #>> '{address, country}' = 'KR';JSONB는 유연한 속성을 넣을 때 쓰고, 핵심 키는 일반 컬럼으로 남겨 두는 편이 안전하다
JSONB는 스키마가 자주 바뀌는 부가 속성에는 강하지만, 조인 키나 핵심 필터 컬럼까지 모두 넣어 두는 용도에는 맞지 않습니다. "자주 조인하거나 제약을 걸 값인가"를 먼저 묻고, 그렇다면 일반 컬럼으로 분리하는 편이 낫습니다.
-- 부가 속성 저장
profile JSONB
-- 핵심 키는 일반 컬럼
org_id BIGINT NOT NULLJSONB 인덱스가 없으면 전체 테이블 스캔이 발생한다
jsonb 컬럼에 GIN 인덱스를 만들면 @> (포함 연산자), ? (키 존재 확인) 같은 JSONB 전용 연산자를 인덱스로 가속할 수 있습니다. 그러나 ->> 기반의 텍스트 추출 필터(WHERE profile ->> 'country' = 'KR')는 GIN 인덱스를 타지 않으므로, 이 패턴이 자주 쓰인다면 표현식 인덱스를 별도로 생성해야 합니다.
-- 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 필드에는 외래 키 제약, CHECK 제약, NOT NULL 등 관계형 제약을 직접 걸 수 없고, 옵티마이저가 칼럼 통계를 활용하기 어려워 쿼리 계획이 불리해질 수 있습니다. 핵심 식별자나 조인 기준이 되는 값은 일반 컬럼으로 두는 것이 원칙입니다.
-- 나쁜 패턴: 자주 조인되는 값을 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;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 인덱스 + @> / ? 연산자 |
| 특정 텍스트 키를 자주 필터링할 때 | 표현식 인덱스 ((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