JSONPath 필터 심화: 재귀, 슬라이스, 다중 조건 매칭
핵심 요약: 재귀 하강, 슬라이스, 다중 조건 필터는 JSONPath가 강력해지고 오용되는 지점입니다. 필터 의미, 결과 모양, 라이브러리 차이를 다룹니다.

핵심부터 말하면, 알려진 깊이를 먼저 찾고 슬라이스와 필터를 더하세요. $..에 손을 뻗지 마세요. 필터의 맨 경로는 존재만 검사하므로 불리언은 @.stock == true로 검사하세요.
$.store.book[0].title은 누구나 씁니다. JSONPath를 실제로 깨뜨리는 것은$..와 슬라이스, 여러 조건을 결합해 결과가 너무 많거나 너무 적거나, 예상하지 못한 순서가 나올 때입니다.
1. 샘플 문서
{
"store": {
"book": [
{ "title": "JSON: The Definitive Guide", "price": 8.95, "stock": true, "tags": ["json", "spec"] },
{ "title": "A History of Data Formats", "price": 12.99, "stock": false, "tags": ["history"] },
{ "title": "How Parsers Work", "price": 8.99, "stock": true, "tags": ["parser", "json"] },
{ "title": "Streaming JSON in Practice", "price": 9.5, "stock": true, "tags": [] }
],
"bicycle": { "color": "red", "price": 19.95 }
},
"updatedAt": "2026-10-31"
}
아래 모든 표현식은 이 문서를 입력으로 씁니다. 두 전제를 기억하세요. 결과는 노드 집합이고, 순서는 조건을 쓴 순서가 아니라 문서 순서를 따릅니다. 필터 안에서 @는 현재 후보 노드만 가리킵니다.
2. 재귀 하강 $..: 편리하지만 비쌈
..는 "현재 위치에서 어느 깊이에서든 재귀 검색"을 뜻합니다. RFC 9535는 이를 하위 세그먼트로 정의하고 이름, 와일드카드, 인덱스, 슬라이스, 필터와 결합할 수 있습니다:
$..price // every member named price at any depth
$..book[?(@.price < 10)] // any book at any depth, then filter
$..[?(@.stock)] // any node at any depth where stock exists
세 가지를 이해하세요:
$..book은 깊이와 무관하게book이라는 모든 노드를 찾습니다.- 순회는 깊이 우선이고, 출력 순서는 구현이 자식을 방문하는 방식에 달려 있습니다.
..는 어떤 인덱스도 쓸 수 없어 비용이 전체 노드 수에 비례합니다.
결과적으로 $..price와 $.store.book[*].price는 출력에서 자전거 가격만 다를 수 있지만 비용은 한 단계 차이 날 수 있습니다. 깊이를 알면 항상 명시적 경로를 쓰세요. 두 경로 언어의 경계만 비교하려면 JSONPath vs JSON Pointer를 참고하세요.
3. 배열 슬라이스와 와일드카드: 세 함정
슬라이스 문법은 [start:end:step]이며 시작 포함 끝 배타이고, 음수는 꼬리에서 셉니다:
| 표현식 | 의미 | 결과(책 4권) |
|---|---|---|
[0:2] |
처음 두 책 | 1번과 2번 |
[1:] |
두 번째 책부터 끝까지 | 2, 3, 4번 |
[:2] |
처음 두 책 | 1번과 2번 |
[-2:] |
마지막 두 책 | 3번과 4번 |
[::2] |
하나 걸러 | 1번과 3번 |
[::-1] |
역순 | 4, 3, 2, 1번 |
세 함정:
- 간격 0은 불법입니다. RFC 9535는 0이 아닌 간격을 요구하고, 일부 구현은 조용히 빈 집합을 반환합니다.
- 음수 간격이 보편 지원되지 않습니다. 원래 Goessner 구현은 지원하지만 일부 최소 구현은 양수 간격만 받습니다.
- 슬라이스 뒤 필터는 범위를 넓히지 않습니다.
[0:2][?(@.stock)]은 슬라이스가 만든 두 책만 필터하고 전체 배열로 돌아가지 않습니다.
와일드카드 [*]는 슬라이스와 결합할 수 있지만 [*]는 "모든 자식 노드"를 냅니다. 그 자식이 객체면 .title을 붙여 모든 제목을 얻습니다. 자식이 배열이면 .title이 한 단계 더 묵시적으로 평탄화하지 않습니다. 확장이 필요하면 [*]를 다시 쓰세요. 묵시적 평탄화를 기대하지 마세요.
4. 다중 조건 필터: 비교, 논리, 존재
필터 [?(...)]는 네 종류의 구성을 담을 수 있습니다. 비교, 논리 연산자, 존재 검사, 함수. 흔한 다중 조건 쿼리는 이렇습니다:
$..book[?(@.price < 10 && @.stock)]
"가격이 10 미만 이고 재고가 참 같은 값"을 뜻합니다. 여러 의미 디테일에 주목하세요:
@.stock은 존재 검사이지 "값이 true"가 아닙니다. 필드가 존재하는데 값이false여도 조건은 여전히 성립합니다. 불리언 값을 검사하려면@.stock == true를 써야 합니다.&&가||보다 강하게 결합합니다. 섞을 때는 기억에 의존하지 말고 괄호를 넣으세요.- 비교의 양쪽은 같은 타입이어야 합니다. 숫자와 문자열은 묵시적으로 변환되지 않으며, 엄격한 구현에서
@.price < "10"은 거짓이거나 오류입니다. - 없는 필드는 항상 비교를 거짓으로 만듭니다.
@.missing < 10은 오류를 내지 않고 아무것도 매칭하지 않습니다.
필터를 JavaScript처럼 쓸 때 가장 쉽게 넘어지는 지점입니다. JavaScript의 if (obj.stock)은 참 여부를 검사하지만 JSONPath는 존재를 검사합니다. 완전한 표준 문법과 함수는 JSONPath 쿼리 언어 실전을 다시 보세요.
5. 스크립트 표현식: 더 강한 힘, 더 낮은 이식성
원래 Goessner 구현과 jsonpath-plus는 [?(@.price < $.max)] 같은 교차 노드 비교와 임의 스크립트를 허용합니다. RFC 9535는 현재 노드 @ 접근만 명시적으로 허용하고 필터 안의 $를 금지합니다.
// Works in older implementations; not allowed by RFC 9535
$..book[?(@.price < $.expensive)]
// Standard approach: pass the threshold from the caller
query(document, '$..book[?(@.price < threshold)]', { threshold: 10 })
스크립트 표현식은 세 문제를 만듭니다:
- 이식성이 0으로 떨어집니다. 라이브러리를 바꾸면 모든 표현식을 다시 써야 합니다.
- 보안 경계가 사라집니다. 표현식이 사용자에게서 오면 스크립트 평가가 코드 실행을 넘겨줍니다.
- 정적 최적화가 불가능해집니다. 구현은 노드별로 해석만 할 수 있어 느리고 캐시하기 어렵습니다.
교차 노드 비교가 진실로 필요하면 임계값을 쿼리 파라미터로 넘기고 애플리케이션 코드에서 두 번째 필터를 하세요. 확장 문법에 의존하는 것보다 대개 더 안정적입니다.
6. 결과 모양: 값이라고 생각하지만 노드를 받음
같은 표현식이 라이브러리에 따라 적어도 세 모양을 반환할 수 있습니다:
["JSON: The Definitive Guide", "How Parsers Work", "Streaming JSON in Practice"] // value array
[{ "path": "$['store']['book'][0]['title']", "value": "..." }] // path + value
[{ "pointer": "/store/book/0/title", "value": "..." }] // JSON Pointer + value
이것은 세 가지에 직접 영향을 줍니다. 프런트엔드가 결과를 읽는 방식, 결과를 중복 제거하는 방식, 쿼리 결과가 쓰기 연산이 되는 방식. RFC 9535가 정의한 nodelist는 노드 참조 집합이지만, JavaScript 구현은 참조가 프로세스 경계를 넘을 수 없어 대개 값만 반환합니다.
실용 조언:
- 단일 값은 항상
[0]이나value()로 읽으세요. 결과가 스칼라라고 가정하지 마세요. - 다시 써야 할 때는 JSON Pointer를 반환하는 구현을 고르거나 포인터를 직접 만드세요.
- 빈 결과와
[null]결과는 다릅니다. 참 여부가 아니라 길이를 확인하세요.
7. 라이브러리 차이: 이전 전에 적합성 스위트 실행
| 능력 | 원래 Goessner | jsonpath-plus | 엄격한 RFC 9535 |
|---|---|---|---|
필터 괄호 ?() |
필수 | 호환 | 선택 |
교차 루트 참조 $.x |
지원 | 지원 | 금지 |
| 스크립트 표현식 | 지원 | 지원 | 금지 |
| 음수 인덱스와 간격 | 지원 | 지원 | 지원 |
| 경로 반환 | 설정 가능 | 설정 가능 | 명세 없음 |
length() 같은 함수 |
없음 | 확장 | 표준 함수 |
이전 전 최소 네 가지를 확인하세요. 괄호가 필요한지, 필터 안에 $가 나와도 되는지, 음수 간격이 어떻게 동작하는지, 결과 구조. 공식 적합성 테스트 스위트가 대부분 문법 차이를 덮어 "라이브러리를 바꾼 뒤 세 줄이 사라지는" 조용한 실패를 막습니다.
8. 성능과 오용 체크리스트
성능은 한 문장으로 귀결됩니다. JSONPath는 전체 순회이고, ..는 가장 비싼 종류의 전체 순회입니다.
- 노드가 10만 개를 넘는 문서는 명시적 경로로 범위를 먼저 좁힌 뒤 부분집합을 필터하세요.
- 표현식을 한 번 컴파일해 재사용하세요. 루프 안에서 문자열을 재파싱하지 마세요.
- 집계(합계, 그룹화, 정렬)에는
jq나 코드로 전환하세요. JSONPath는 선택만 합니다. - 조건을 가능한 한 앞에 두세요.
$.store.book[?(@.price < 10)]은$..[?(@.price < 10)]보다 무관한 노드를 훨씬 적게 스캔합니다.
오용 체크리스트:
- [ ] 존재 검사를 불리언 참 검사로 취급
- [ ] 알려진 깊이 대신
$..사용 - [ ] 교차 노드 비교에 스크립트 표현식 의존
- [ ] 결과가 항상 값 배열이라고 가정
- [ ] 길이와 중첩 제한 없이 사용자 제어 표현식 허용
FAQ
필드가 false인데 @.stock이 왜 여전히 매칭되나?
필터 안의 맨 경로는 "값이 true"가 아니라 "필드가 존재함"을 뜻하기 때문입니다. 불리언을 검사하려면 @.stock == true로 명시적으로 비교하세요. JSONPath와 JavaScript 참 여부의 가장 큰 의미 차이입니다.
재귀 하강의 순서는 안정적인가?
아니요. RFC 9535는 노드 집합 의미를 규정하고 순회 순서는 구현이 자식을 방문하는 방식에 달려 있습니다. 고정 순서가 필요하면 배열을 찾아 인덱스로 읽거나 애플리케이션 코드에서 결과를 정렬하세요.
슬라이스와 필터를 결합할 수 있나?
예, 하지만 범위를 기억하세요. $.store.book[0:2][?(@.stock)]은 슬라이스가 만든 두 책만 필터합니다. 먼저 필터하고 그다음 슬라이스하려면 $.store.book[?(@.stock)][0:2]를 쓰세요. 순서가 다르면 결과가 다릅니다.
표준이 금지하는데 jsonpath-plus는 왜 $.expensive를 지원하나?
필터가 현재 노드 @만으로 정의되기 때문입니다. 교차 루트 참조는 표현식을 전체 문서 문맥에 의존하게 만들어 정적으로 분석하기 어렵고 언어 간 이식도 어렵습니다. 임계값을 표현식에 내장하지 말고 파라미터로 넘기세요.
재귀 하강이 무관한 노드를 스캔하지 않게 하려면?
깊이를 알 때마다 명시적 경로를 쓰고 범위를 먼저 좁힌 뒤 부분집합만 필터하세요. 알려진 단계 대신 $..를 쓰지 마세요. 조건을 가능한 한 앞에 두세요. $.store.book[?(@.price < 10)]은 $..[?(@.price < 10)]보다 무관한 노드를 훨씬 적게 스캔합니다. 노드가 10만 개를 넘는 문서에서는 이것을 먼저 하세요.