빌드노트
jsonb_set에 true를 줬는데 중첩 키가 생기지 않는 이유
jsonb_set의 true는 없는 마지막 항목을 추가하지만 중간 부모까지 만들지는 않는다. PostgreSQL 18.6에서 부모 없음·객체·JSON null을 비교하고, 필요한 부모만 만들며 형제 키를 보존하는 조건식과 루트 병합의 차이를 확인한다.
PostgreSQL에서 jsonb_set('{}'::jsonb, '{prefs,theme}', '"dark"'::jsonb, true)를 실행해도 결과는 {}다. 마지막 인자를 true로 주면 없는 경로 전체를 만들어 줄 것 같지만, 추가할 수 있는 것은 마지막 항목이며 그 앞의 경로는 이미 있어야 한다. 이 입력에는 부모인 prefs가 없다.
PostgreSQL 18의 jsonb_set 설명 (새 탭에서 열림)은 앞선 경로가 없으면 입력을 그대로 반환한다고 명시한다. 이 문제에서는 true를 다시 지정하기보다 부모가 없는지, 객체인지, 다른 값인지부터 구별해야 한다.
부모가 없을 때와 빈 객체일 때는 결과가 다르다
다음 쿼리는 네 가지 합성 JSON에 같은 수정을 적용한다. PostgreSQL 18의 실습용 데이터베이스에 접속한 SQL 클라이언트에서 그대로 실행할 수 있다. 테이블이나 실제 데이터는 필요 없다. 아래 출력은 별도로 띄운 PostgreSQL 18.6에서 직접 확인한 값이다.
WITH samples(n, label, doc) AS (
VALUES
(1, 'missing', '{}'::jsonb),
(2, 'empty-object', '{"prefs":{}}'::jsonb),
(3, 'sibling', '{"prefs":{"lang":"ko"}}'::jsonb),
(4, 'json-null', '{"prefs":null}'::jsonb)
)
SELECT label,
doc ? 'prefs' AS has_parent,
jsonb_typeof(doc->'prefs') AS parent_type,
jsonb_set(doc, '{prefs,theme}', '"dark"'::jsonb, true) AS result
FROM samples ORDER BY n;
label | has_parent | parent_type | result
missing | f | [SQL NULL] | {}
empty-object | t | object | {"prefs": {"theme": "dark"}}
sibling | t | object | {"prefs": {"lang": "ko", "theme": "dark"}}
json-null | t | null | {"prefs": null}
출력의 |는 열 구분자다. t와 f는 참과 거짓이며, [SQL NULL]은 SQL NULL을 표시하도록 지정한 문자열이다. 클라이언트 기본 설정에서는 빈칸으로 보일 수 있다.
missing은 prefs 자체가 없어서 수정되지 않았다. empty-object는 부모 객체가 있어 theme를 추가했다. sibling에서는 기존 lang을 남기고 theme만 추가했다. 마지막 행은 키는 있지만 값이 JSON null이다. 이것은 자식 키를 담을 객체가 아니며, 이 실행에서도 입력 그대로 남았다. parent_type의 null은 SQL NULL이 아니라 타입 이름을 나타내는 text 값이다.
별도 비교에서 빈 부모 객체에 false를 주면 theme는 생기지 않았다. 이미 있는 theme를 light에서 dark로 바꾸는 것은 false로도 됐다. 이 인자는 기존 값 교체 여부보다 없는 마지막 항목의 추가 여부를 가른다.
없는 부모만 만들고, 기존 객체는 그대로 쓴다
입력이 {}임을 아는 경우에는 jsonb_set으로 먼저 {prefs}에 {}를 넣고, 그 결과에 {prefs,theme}를 설정하면 된다. 두 호출을 이어 실행한 결과는 {"prefs": {"theme": "dark"}}였다.
여러 형태의 입력을 받는 코드에서는 부모를 매번 {}로 교체하면 안 된다. 기존 prefs 안의 형제 키도 함께 지워지기 때문이다. 다음은 루트가 객체이고 prefs 키가 없을 때만 부모를 만든 뒤, 부모가 객체일 때 자식을 설정하는 조회다. 첫 줄의 JSON 리터럴을 바꿔 자기 입력 형태를 비교할 수 있다.
WITH input(doc) AS (
VALUES ('{"id":7,"prefs":{"lang":"ko"}}'::jsonb)
), prepared AS (
SELECT CASE
WHEN jsonb_typeof(doc) = 'object' AND NOT (doc ? 'prefs')
THEN jsonb_set(doc, '{prefs}', '{}'::jsonb, true)
ELSE doc
END AS ready
FROM input
)
SELECT CASE WHEN jsonb_typeof(ready->'prefs') = 'object'
THEN 'applied' ELSE 'needs-policy' END AS decision,
CASE WHEN jsonb_typeof(ready->'prefs') = 'object'
THEN jsonb_set(ready, '{prefs,theme}', '"dark"'::jsonb, true)
ELSE ready
END AS result
FROM prepared;
decision | result
applied | {"id": 7, "prefs": {"lang": "ko", "theme": "dark"}}
같은 조건식에 {"id":7}을 넣으면 id를 남기고 부모와 자식을 만들었다. 빈 부모 객체에도 자식을 추가했고, 기존 theme가 있는 객체에서는 그 값만 교체했다. 루트의 id와 부모 안의 lang은 유지됐다.
반면 prefs가 JSON null, 배열, 문자열인 입력은 needs-policy와 원래 값을 반환했다. 이 표시는 오류를 자동 해결했다는 뜻이 아니다. 호출한 쪽에서 입력을 거부하거나 별도 변환 규칙을 적용해야 한다. 위 조건식은 루트 자체가 배열·JSON null·숫자·SQL NULL인 경우도 수정하지 않는 것을 확인했다. 객체가 아닌 값을 일괄 {}로 바꾸는 정책은 이 예제에 넣지 않았다.
COALESCE(doc->'prefs', '{}'::jsonb)만으로도 이 구분을 대신할 수 없다. 합성 조회에서 없는 키는 {}로 대체됐지만 명시적 JSON null은 null, 배열은 원래 배열로 남았다. SQL NULL을 대체하는 것과 JSON 구조를 객체로 정하는 것은 다른 판단이다.
루트 병합으로 바꾸면 형제 키가 사라질 수 있다
중첩 객체를 통째로 만들어 ||로 붙이는 식도 비교해 볼 필요가 있다. 아래 쿼리는 같은 입력에서 루트 병합과 자식 키 수정을 나란히 실행한다.
WITH input(doc) AS (
VALUES ('{"id":7,"prefs":{"lang":"ko"}}'::jsonb)
)
SELECT doc || '{"prefs":{"theme":"dark"}}'::jsonb AS root_merge,
jsonb_set(doc, '{prefs,theme}', '"dark"'::jsonb, true) AS nested_set
FROM input;
root_merge | nested_set
{"id": 7, "prefs": {"theme": "dark"}} | {"id": 7, "prefs": {"lang": "ko", "theme": "dark"}}
루트 병합은 id를 보존했지만, 중복된 루트 키 prefs의 값을 오른쪽 객체로 교체해 lang을 없앴다. 공식 문서의 jsonb 병합 연산자 설명 (새 탭에서 열림)도 객체의 중복 키에는 오른쪽 값을 쓰며 재귀적으로 병합하지 않는다고 설명한다. prefs 전체 교체가 목적이라면 맞는 동작이지만, 자식 하나를 추가하면서 형제를 지켜야 하는 수정에는 그대로 쓸 수 없다.
여기서 확인한 범위는 PostgreSQL 18.6의 합성 SELECT와 두 단계 객체 경로다. 운영 UPDATE, 동시 갱신, 더 깊은 경로의 자동 생성, 배열 인덱스 수정은 검증하지 않았다. 경로가 더 깊다면 마지막 키 앞의 부모들도 각각 존재하는지와 필요한 구조인지를 확인해야 한다.