빌드노트
PostgreSQL jsonb @>는 배열의 순서와 중복 개수를 검사하지 않는다
jsonb의 @>는 배열 원소의 순서와 중복 횟수를 구분하지 않는다. PostgreSQL 18.6에서 포함과 동등 비교를 대조하고, 양방향 포함에 길이를 더해도 값별 개수는 같지 않을 수 있음을 확인한다. 배열 전체의 일치와 개수 비교를 구별해 조건을 선택한다.
PostgreSQL에서 jsonb 배열을 @>로 비교하면 [1,2]가 [2,1]을 포함한다고 나온다. [1]도 [1,1]을 포함한다고 나온다. @>는 배열 원소의 순서와 중복 횟수를 구분하지 않는다. 배열 전체의 순서와 각 원소의 개수까지 같아야 한다면 =로 동등을 비교해야 한다.
이는 오류가 아니라 연산자의 의미다. PostgreSQL 18의 JSONB 포함 문서 (새 탭에서 열림)는 포함 판정에서 배열 순서는 중요하지 않고, 중복 원소는 한 번 있는 것으로 취급한다고 설명한다. 따라서 오른쪽에 같은 값이 두 번 적혀 있어도 왼쪽에서 두 자리를 찾아 대응시키지 않는다.
같은 입력으로 포함과 동등을 비교한다
다음 쿼리는 격리된 PostgreSQL 18.6에서 실행했다. PostgreSQL 18의 실습용 데이터베이스에 연결한 SQL 클라이언트에서 그대로 실행할 수 있고, 테이블이나 기존 데이터는 필요 없다. actual은 가진 배열, expected는 검사할 배열이다. 두 값을 명시적으로 jsonb로 변환해 같은 입력에 @>와 =를 적용한다.
WITH samples(n, actual, expected) AS (
VALUES
(1, '[1,2]'::jsonb, '[1,2]'::jsonb),
(2, '[1,2]'::jsonb, '[2,1]'::jsonb),
(3, '[1]'::jsonb, '[1,1]'::jsonb),
(4, '[1,2]'::jsonb, '[1,3]'::jsonb)
)
SELECT actual, expected,
actual @> expected AS contains,
actual = expected AS same_array
FROM samples ORDER BY n;
아래는 실행 결과를 열 구분자 |로 옮긴 것이다. t는 참, f는 거짓이다.
actual | expected | contains | same_array
[1, 2] | [1, 2] | t | t
[1, 2] | [2, 1] | t | f
[1] | [1, 1] | t | f
[1, 2] | [1, 3] | f | f
첫 행은 두 배열이 같아 두 비교가 모두 참이었다. 둘째 행은 원소가 같지만 순서가 달라 same_array만 거짓이다. 셋째 행에서도 contains는 참이지만, =는 1이 한 번 있는 배열과 두 번 있는 배열을 구별했다. 마지막 행의 3은 왼쪽 배열에 없어서 포함 판정도 거짓이었다. 순서와 중복을 무시한다고 없는 값까지 허용하는 것은 아니다.
배열 전체의 일치를 검사하는 코드라면 조건을 actual = expected로 바꾼다. 반대로 지금 필요한 것이 원소의 포함 여부라면 @>의 위 결과가 요구사항에 맞는지 판단한다. JSON 함수와 연산자 문서 (새 탭에서 열림)는 jsonb의 일반 비교 연산자와 포함 연산자를 구분해 설명한다. 실습 결과가 다르면 먼저 양쪽 입력의 실제 값과 SQL 타입, 실행한 연산자를 확인한다.
길이까지 같아도 값별 개수는 다를 수 있다
[1]과 [1,1]만 보면 포함 조건에 배열 길이를 더하면 해결될 것 같다. 하지만 전체 길이는 각 값의 개수를 알려 주지 않는다. 다음은 길이가 같은 두 배열에 양방향 포함 검사까지 적용한 비교다.
WITH input(actual, expected) AS (
VALUES ('[1,1,2]'::jsonb, '[1,2,2]'::jsonb)
)
SELECT actual @> expected AS forward,
expected @> actual AS backward,
jsonb_array_length(actual) = jsonb_array_length(expected) AS same_length,
actual = expected AS same_array
FROM input;
forward | backward | same_length | same_array
t | t | t | f
두 배열은 길이가 3이고 서로 포함한다고 나오지만, 1과 2가 나온 횟수는 다르다. 따라서 @>와 역방향 포함을 묶거나, 여기에 길이 비교까지 더해도 값별 개수의 일치를 보장하지 못한다.
순서는 무시하되 각 값의 횟수는 같아야 하는 요구라면 두 배열을 펼쳐 값별 개수를 비교해야 한다. 위의 같은 입력에서 무엇이 다른지 다음 조회로 확인할 수 있다. jsonb_array_elements가 원소를 행으로 펼치고 GROUP BY와 count(*)가 값마다 횟수를 센다.
WITH input(side, doc) AS (
VALUES ('actual', '[1,1,2]'::jsonb),
('expected', '[1,2,2]'::jsonb)
)
SELECT side, value, count(*) AS occurrences
FROM input
CROSS JOIN LATERAL jsonb_array_elements(doc) AS e(value)
GROUP BY side, value
ORDER BY side, value;
side | value | occurrences
actual | 1 | 2
actual | 2 | 1
expected | 1 | 1
expected | 2 | 2
이 조회는 차이를 확인하는 용도다. 개수를 검사하는 조건을 구현할 때는 한쪽에만 있는 값도 포함해 두 집계 결과의 모든 값과 횟수가 맞는지 비교한다. 배열 전체의 순서까지 같아야 한다면 앞의 = 비교를 쓰면 된다. 공식 함수 설명 (새 탭에서 열림)에서 jsonb_array_elements는 최상위 배열을 펼치는 함수이고, jsonb_array_length는 최상위 원소 수를 반환하는 함수다.
포함 검사가 중첩 구조를 모두 평탄화하는 것도 아니다. 별도 실행에서 '[1,[2,3]]'::jsonb @> '[3]'::jsonb는 f, 오른쪽을 '[[3]]'::jsonb로 감싼 비교는 t였다. 이 두 결과는 안쪽 배열의 구조를 맞추는 것과 순서·중복 횟수를 검사하는 것이 다른 문제임을 보여 준다.
여기서 확인한 범위는 PostgreSQL 18.6의 합성 JSONB 입력이다. 위 개수 조회를 임의의 중첩 문서에 대한 완성된 비교 함수로 검증한 것은 아니며, SQL NULL 입력의 처리 정책도 다루지 않았다. 인덱스 사용 여부와 성능은 측정하지 않았다.