PostgreSQL JSONB를 펼쳤더니 행이 사라진다: LEFT JOIN LATERAL과 null 구분
가변 항목을 JSONB로 받는 화면에서, 항목이 없는 데이터만 목록에서 사라질 수 있다. 원본 행을 삭제하지 않았는데도 조회 결과가 줄어드는 것이다. 이때 JSON 파싱부터 의심하기 전에 FROM 절의 조인 방식을 확인해 보자.
원본 행을 남겨야 한다면 JSONB 전개 결과를 LEFT JOIN LATERAL로 연결한다. 이후 오른쪽 조건을 WHERE에 잘못 두면 그 행이 다시 사라질 수 있다. 빈 객체, SQL NULL, JSON null도 서로 다른 입력이다.
이 글은 2026-09-27에 Node.js 24.19.0과 PGlite 0.5.8로 실행한 11개 검사 결과를 사용한다. 엔진은 PostgreSQL 18.3 (PGlite 0.5.8)와 wasm32 환경을 보고했다. PGlite는 PostgreSQL의 WASM 실행 환경이며, 이번 결과는 운영 PostgreSQL 서버·Spring API·IBSheet 통합이나 성능 검증을 대신하지 않는다. 문법과 의미는 PostgreSQL 18 공식 문서로 대조했다. 사용자의 운영 버전이 18.3이라는 뜻은 아니다.
먼저 네 행으로 문제를 재현한다
실험용 연결에서 임시 테이블을 만든다. 운영 테이블 이름을 바꿔 끼워 넣지 않고, 독립된 테스트 연결에서 실행하는 예제다.
CREATE TEMP TABLE item_demo (
id integer PRIMARY KEY,
attrs jsonb
);
INSERT INTO item_demo VALUES
(1, '{"seat":"A1","fare":1200,"note":null}'),
(2, '{}'),
(3, NULL),
(4, '{"seat":"B2"}');
1번에는 세 항목, 2번에는 빈 객체, 3번에는 SQL NULL, 4번에는 한 항목이 있다. 모든 데이터는 글을 위한 인공 샘플이다. 실제 고객·좌석 예약 데이터나 운영 장애 기록이 아니다.
기존 PostgreSQL 동적 컬럼·Spring Boot·Vue·IBSheet 글이 행을 JSONB로 묶어 화면에 전달했다면, 여기서는 받은 JSONB를 다시 항목별 행으로 펼치는 방향을 본다.
CROSS JOIN은 항목이 없는 기본 행을 반환하지 않는다
SELECT i.id, e.key, e.value::text AS value_json
FROM item_demo AS i
CROSS JOIN LATERAL jsonb_each(i.attrs) AS e
ORDER BY i.id, e.key;
실험에서는 총 4행이 나왔고 기본 ID는 1과 4만 남았다. 1번이 세 행으로 늘어난 덕분에 결과 행 수만 보면 원본의 4행과 같지만, 2번과 3번은 빠졌다. 따라서 단순히 count(*)가 같다는 이유로 데이터가 보존됐다고 판단하면 안 된다.
jsonb_each는 최상위 객체의 키·값을 행으로 반환한다. 키는 text, 값은 jsonb다. 여기서 LATERAL은 왼쪽 행의 값을 참조하는 관계를 명시한다. 함수 호출에서는 생략 가능한 경우도 있지만 예제는 의도를 드러내기 위해 적었다. JSON 함수 문서, LATERAL 문서
LEFT JOIN LATERAL로 기본 행을 보존한다
SELECT i.id, e.key, e.value::text AS value_json
FROM item_demo AS i
LEFT JOIN LATERAL jsonb_each(i.attrs) AS e ON true
ORDER BY i.id, e.key;
이제 결과는 6행이고 기본 ID 1~4가 모두 남는다. 표에서 SQL NULL은 표시용 설명이다. DB 클라이언트에 따라 빈칸 등으로 보일 수 있다.
| id | key | value_json |
|---|---|---|
| 1 | fare | 1200 |
| 1 | note | null이라는 텍스트 |
| 1 | seat | "A1" |
| 2 | SQL NULL | SQL NULL |
| 3 | SQL NULL | SQL NULL |
| 4 | seat | "B2" |
2번과 3번의 오른쪽 NULL은 실제 속성이 아니라 외부 조인이 채운 자리다. 반면 1번의 note는 존재하는 키이며 값이 JSON null이다. 표의 차이는 출력 장식이 아니라 다음 집계와 API 처리에서 유지해야 할 구분이다.
전체 조회가 기본 ID당 한 행이어야 한다면 이 결과를 바로 화면에 내보내지 않는다. 키·값 목록으로 묶거나 뒤의 재집계를 적용해야 한다. LEFT JOIN이 원본 행 수와 결과 행 수를 같게 만드는 기능은 아니다.
같은 필터라도 ON과 WHERE의 결과가 다르다
seat 항목만 보여 주되 항목이 없는 기본 행도 남기는 요구라면 다음과 같이 한다.
SELECT i.id, e.key, e.value::text AS value_json
FROM item_demo AS i
LEFT JOIN LATERAL jsonb_each(i.attrs) AS e
ON e.key = 'seat'
ORDER BY i.id;
실험 결과 ID 1~4가 모두 남았다. 2번과 3번은 오른쪽 값이 NULL이다.
반면 아래는 seat 항목이 있는 기본 행만 조회한다.
SELECT i.id, e.key, e.value::text AS value_json
FROM item_demo AS i
LEFT JOIN LATERAL jsonb_each(i.attrs) AS e ON true
WHERE e.key = 'seat'
ORDER BY i.id;
결과는 ID 1과 4뿐이다. WHERE가 외부 조인의 NULL 행을 제외한다. 이것이 무조건 버그는 아니다. “모든 기본 행을 남기고 해당 속성만 선택”하는 화면과 “해당 속성이 있는 기본 행만 검색”하는 화면은 요구가 다르다. 공식 조인 설명은 ON과 WHERE 조건의 차이를 별도로 설명한다.
운영 데이터의 범위 조건까지 전부 ON으로 옮겨서는 안 된다. 예를 들어 왼쪽 기본 테이블의 테넌트·권한·상태 조건은 해당 행을 조회 대상에서 제외하는 조건으로 유지해야 한다. 여기서 옮긴 것은 선택할 오른쪽 속성의 조건이다.
NULL처럼 보이는 세 상태를 구분한다
SELECT
attrs ? 'note' AS has_note,
(attrs -> 'note') IS NULL AS json_value_is_sql_null,
(attrs ->> 'note') IS NULL AS text_value_is_sql_null,
attrs ? 'missing' AS has_missing,
jsonb_typeof(attrs -> 'note') AS note_type
FROM item_demo
WHERE id = 1;
실제 검사 결과는 true, false, true, false, 'null'이었다. note 키는 존재하지만 JSON null이고, missing 키는 존재하지 않는다. 텍스트로 꺼낸 결과의 NULL 여부만 검사하면 차이를 잃을 수 있다. JSON 타입 공식 문서는 JSON null과 SQL NULL을 다른 개념으로 설명한다.
jsonb_each_text는 값을 text로 반환하므로 표시에는 편하지만 원래 숫자·문자열 타입 구분이 필요할 때는 주의해야 한다. 이번 전개 예제는 jsonb_each를 사용하고, 표를 읽기 쉽도록 마지막 SELECT에서만 text로 표시했다.
API에서도 빈 객체, 값이 없는 속성, 명시적 null을 같은 의미로 사용할지 먼저 결정한다. 프론트에서 빈칸으로 보인다는 이유만으로 서버에서 세 상태를 모두 같은 값으로 바꾸면 수정 요청의 의미까지 달라질 수 있다.
집계할 때는 보존용 NULL 행을 세지 않는다
SELECT i.id,
count(*) AS joined_rows,
count(e.key) AS attribute_count
FROM item_demo AS i
LEFT JOIN LATERAL jsonb_each(i.attrs) AS e ON true
GROUP BY i.id
ORDER BY i.id;
2번과 3번에서 count(*)는 1, count(e.key)는 0이었다. 전자는 외부 조인이 만든 자리까지 센다. 실제 키 개수를 원하면 후자가 맞다. JSON 객체의 키 자체는 NULL이 아니기 때문에 이 사례에서는 e.key가 보존용 행을 구별하는 기준이 된다.
기본 행에 주문 금액 같은 값이 있다면 항목 전개 뒤 sum(기본금액)을 계산할 때도 조심해야 한다. 한 기본 행이 속성 개수만큼 복제될 수 있다. 중복 행을 잘못 합산하는 문제는 조인·GROUP BY와 분리 조회 비교와 연결해서 점검할 수 있다. 이번 실험은 해당 금액 계산이나 성능을 측정하지 않았다.
다시 JSONB로 묶을 때는 키가 없는 보존용 행을 집계에서 제외한다.
SELECT i.id,
COALESCE(
jsonb_object_agg(e.key, e.value)
FILTER (WHERE e.key IS NOT NULL),
'{}'::jsonb
) AS rebuilt
FROM item_demo AS i
LEFT JOIN LATERAL jsonb_each(i.attrs) AS e ON true
GROUP BY i.id
ORDER BY i.id;
실험에서 FILTER 없이 2번을 집계하면 field name must not be null 오류(SQLSTATE 22023)가 났다. FILTER를 적용하면 2번·3번은 {}, 1번은 note의 JSON null을 포함한 객체로 반환됐다. 여기서 COALESCE는 집계 입력이 없을 때의 결과를 빈 객체로 바꾸는 역할이다. NULL 키 오류를 COALESCE만으로 막을 수는 없다. 집계 함수 문서
단, 이 쿼리는 원본의 SQL NULL과 빈 객체를 모두 {}로 통일한다. 원본 상태까지 되살리는 무손실 복원은 아니다. 원본이 SQL NULL인지 구분해야 하는 API라면 그 상태를 별도 필드로 전달하거나 계약에 맞는 CASE 분기를 추가해야 한다.
배열과 JSON null은 LEFT JOIN만으로 해결되지 않는다
다음 두 문장은 실험에서 모두 cannot call jsonb_each on a non-object 오류(SQLSTATE 22023)를 냈다.
SELECT * FROM jsonb_each('[]'::jsonb);
SELECT * FROM jsonb_each('null'::jsonb);
빈 객체 {}와 빈 배열 []은 다르다. SQL NULL과 'null'::jsonb도 다르다. LEFT JOIN은 함수가 0행을 반환한 경우의 행 보존을 다루며, 잘못된 입력 타입 때문에 난 함수 오류를 삼키지 않는다.
정상 계약이 객체라면 쓰기 단계에서 타입을 검증하는 것이 먼저다. 과거 혼합 데이터를 읽기 전용으로 점검해야 한다면, 함수에 전달할 인수를 CASE로 보호하고 원본 상태를 함께 보여 줄 수 있다.
WITH payload(id, attrs) AS (
VALUES (1, '{}'::jsonb),
(2, NULL::jsonb),
(3, 'null'::jsonb),
(4, '[]'::jsonb),
(5, '{"seat":"A1"}'::jsonb)
)
SELECT p.id,
CASE
WHEN p.attrs IS NULL THEN 'sql_null'
WHEN jsonb_typeof(p.attrs) = 'object' THEN 'object'
ELSE 'invalid_' || jsonb_typeof(p.attrs)
END AS input_kind,
e.key
FROM payload AS p
LEFT JOIN LATERAL jsonb_each(
CASE WHEN jsonb_typeof(p.attrs) = 'object'
THEN p.attrs ELSE '{}'::jsonb END
) AS e ON true
ORDER BY p.id;
실험에서는 5개 ID가 모두 남고, JSON null·배열은 각각 invalid_null, invalid_array로 구분됐다. 객체만 허용한다는 이 예제 계약에서 잘못된 타입이라는 의미다. JSONB 자체가 배열을 저장하지 못한다는 뜻은 아니다.
이 CASE는 데이터를 고치거나 지우지 않는다. 잘못된 값을 {}로 UPDATE하는 처방으로 바꾸지 말자. 실제 배열을 펼쳐야 하는 요구라면 배열 함수와 별도의 API 계약을 선택한다. 타입 보호를 WHERE 조건의 작성 순서에 의존하는 대신 함수 인수 안에 명시한 이유도 여기에 있다.
서비스에 적용할 때 확인할 것
재현 자료에는 전체 실행 코드, SQL, 결과 JSON, 고정된 의존성 목록을 포함했다. 별도 폴더에서 npm ci --ignore-scripts 후 node verify.mjs로 실행하면 11개 검사와 엔진 버전을 확인할 수 있다. 의존성 다운로드에는 네트워크가 필요하다. 운영 DB 연결 정보는 사용하지 않는다.
운영 반영 전에는 실제 PostgreSQL 버전에서 같은 입력을 대조하고, API 응답에서 기본 ID 집합이 유지되는지 확인한다. 원본 행 수와 전개 행 수를 따로 기록하고, 빈 객체·SQL NULL·JSON null·배열·없는 키를 테스트 데이터에 포함한다. 표의 표시가 정상이라는 확인과 DB 결과의 정확성은 별도다.
정확성이 확보된 다음에 실행 계획과 데이터 크기를 본다. JSONB를 썼다고 자동으로 빠르거나 느리다고 판단하지 않는다. 운영 구간별 쿼리 비용을 비교하려면 pg_stat_statements 스냅샷 차이 가이드를 참고할 수 있다. 이번 작은 데이터 실험으로 운영 성능 개선율이나 인덱스 효과를 주장하지 않는다.
JSONB 전개 문제를 고칠 때는 세 질문을 나눠 보자. 항목이 없어도 기본 행을 남겨야 하는가, 오른쪽 조건은 항목 선택인가 기본 행 검색인가, 빈 값들의 의미를 합쳐도 되는가. 이 조건을 먼저 정하면 LEFT JOIN, 필터 위치, 집계 결과를 같은 기준으로 검증할 수 있다.