JSONPath, 드디어 명세가 생긴 버전
JSONPath는 17년 동안 블로그 글이었습니다. RFC 9535가 마침내 그 뜻을 정합니다.
이 페이지의 모든 주장은 직접 측정했거나 출처가 있습니다. 둘 다 아닌 경우에는 그렇다고 명시합니다.
JSONPath는 2007년 Stefan Goessner의 블로그 글로 시작했습니다. XPath 비슷한 JSON 질의 언어를 두 화면 남짓한 산문과 JavaScript 참조 구현으로 스케치한 것이었죠. 모두가 구현할 만큼 훌륭했고, 모두가 서로 다르게 구현할 만큼 모호했습니다. 자손 연산자 ..는 루트 노드 자체를 포함하나요, 자식만인가요? [-1]은 마지막 원소인가요, 오류인가요? 대괄호 하나에 여러 셀렉터를 넣을 수 있나요? 검사하는 키가 없을 때 필터는 무엇을 하나요? 스텝이 0인 슬라이스는 무엇을 고르나요? 이 질문들에는 저마다 최소 두 개의 답이 세상에 돌아다녔고, 원문은 그중 어느 것도 매듭짓지 않았습니다. 일부가 마침 손에 잡히는 eval()에 위임되어 있었기 때문입니다.
RFC 9535가 2024년 2월에 이를 해결했습니다. IETF의 Proposed Standard, 즉 표준화 경로의 첫 성숙 단계입니다. 실재하고 안정적이며 규범적 표현으로 쓰인 명세이고, Internet Standard는 아닙니다. 다른 Proposed Standard를 대하듯 대하세요. 새 코드를 쓸 기준으로 삼되, 이미 배포된 코드 상당수가 그보다 앞선다는 것을 알아 두는 겁니다.
대상 문서
아래의 모든 예는 이 문서를 대상으로 합니다.
{
"store": {
"name": "Corner Books",
"book": [
{ "category": "reference", "author": "Nigel Rees",
"title": "Sayings of the Century", "price": 8.95 },
{ "category": "fiction", "author": "Evelyn Waugh",
"title": "Sword of Honour", "price": 12.99 },
{ "category": "fiction", "author": "Herman Melville",
"title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 },
{ "category": "fiction", "author": "J.R.R. Tolkien",
"title": "The Lord of the Rings", "isbn": "0-395-19395-8" }
]
}
}
마지막 책을 보세요. price가 없습니다. 흥미로운 동작의 대부분은 바로 그 부재에 살고 있습니다.
세그먼트와 셀렉터
질의는 $ 뒤에 세그먼트의 연속이 오는 형태입니다. $가 루트입니다. 각 세그먼트는 현재 손에 든 모든 노드에 셀렉터를 하나 이상 적용해 새 노드 목록을 만듭니다. 질의의 결과는 언제나 노드의 목록이고, 노드가 하나이거나 없을 때도 그렇습니다. JSONPath 라이브러리가 배열을 돌려주고 JSON Pointer 라이브러리가 값을 돌려주는 이유가 이것입니다.
$.store.book[0].title 점 표기, 이름 셀렉터의 축약형
$['store']['book'][0] 대괄호 표기, 의미는 동일
$["store"]["book"][0] 큰따옴표도 된다
대괄호 표기는 선택적 장식이 아닙니다. $.first-name은 유효한 이름 셀렉터가 아니므로, 하이픈이나 공백, 점, 앞자리 숫자가 있는 키는 $['first-name']으로 써야 합니다. 작은따옴표를 쓴 대괄호 형태는 RFC 9535가 단일 노드에 대해 정의하는 고유 식별자, 곧 정규화 경로의 모습이기도 합니다. $['store']['book'][0]['title']처럼요.
셀렉터는 이렇습니다.
- 이름:
'title'또는"title". 객체의 멤버를 고릅니다. 배열에서는 아무것도 고르지 않습니다. - 와일드카드
*: 객체의 모든 멤버 값, 배열의 모든 원소.$.store.book[*]과$.store.book.*은 같은 질의입니다. - 인덱스: 0부터 시작하는 정수. 음수는 끝에서부터 세므로
[-1]은 마지막 원소입니다. 이제는 명세된 사항이고, 라이브러리마다의 배려가 아닙니다. - 슬라이스
시작:끝:스텝: 반열린 구간으로 끝은 제외되고, 음수 스텝은 뒤로 걷습니다. 스텝이0이면 오류를 내는 대신 아무것도 고르지 않습니다. RFC가 파이썬과 의도적으로 갈라서는 유일한 지점입니다. - 필터
?식: 아래에서 다룹니다.
자식 세그먼트에는 쉼표로 구분된 여러 셀렉터가 들어갈 수 있고, 종류가 같을 필요도 없습니다. $.store.book[0, -1]은 첫 책과 마지막 책이고, $.store.book[0, 2:4]는 인덱스와 슬라이스를 섞습니다. 결과는 셀렉터 순서대로 돌아오므로, 합집합이 같은 노드를 두 번 돌려주는 것도 정당합니다.
자손 세그먼트는 점 두 개로 씁니다. $..author, $..['author'], $..*, $..[0]. 입력 노드와 그 모든 자손을 방문한 뒤, 각각에 셀렉터를 적용합니다. 옛 모호함은 사라졌습니다. 위 문서에 대한 $..store는 $.store에 일치합니다. 자손 세그먼트가 루트 자신에서 시작하기 때문입니다.
필터, 질문이 몰려 있는 곳
필터 안에서 @는 지금 검사 중인 노드이고 $는 여전히 문서 전체의 루트입니다. 따라서 필터는 값을 문서의 다른 곳에 있는 무언가와 비교할 수 있습니다.
맨 질의를 필터 표현식으로 쓰면 존재 검사가 됩니다. 그 질의가 노드를 하나라도 고르면 참입니다. $.store.book[[email protected]]은 ISBN이 있는 두 책을 고릅니다. 비교 연산자는 ==, !=, <, <=, >, >=이고, 논리 연산자는 &&, ||, 접두 !이며, 묶을 때는 괄호를 씁니다. 표현식 전체를 감싸는 괄호는 허용되지만 더는 필수가 아니어서, [?(@.price < 10)]과 [[email protected] < 10] 모두 유효하고 같은 뜻입니다.
비교의 피연산자에는 제한이 있습니다. 양쪽은 리터럴이거나, 단일 질의(이름과 인덱스 셀렉터만으로 이뤄져 최대 한 노드만 고를 수 있는 것)이거나, 함수 호출이어야 합니다. @.price는 자격이 있습니다. @..price나 @.book[*].price는 아니며, 구현은 추측하는 대신 질의를 거부해야 합니다.
이제 사람들이 걸려 넘어지는 규칙입니다. 아무것도 고르지 못한 질의는 특수한 값 Nothing이 되고, Nothing은 null도, 0도, false도 아닙니다. Nothing하고만 같고 다른 어떤 것과도 같지 않으며, Nothing이 낀 순서 비교는 전부 거짓입니다. 그 결과는 이렇습니다.
$.store.book[[email protected] < 10] 톨킨 책을 제외한다 (price 없음)
$.store.book[[email protected] >= 10] 이것도 제외한다
$.store.book[[email protected] == null] 이것도 제외한다. Nothing은 null이 아니다
$.store.book[[email protected]] 이 책만 고른다
그러니 부재는 존재 검사를 부정해서 확인하고, == null은 멤버가 존재하며 그 값이 null인지를 확인합니다. 거울에 비친 쪽은 진짜로 놀랍습니다. $.store.book[[email protected] == @.discount]는 톨킨 책을 고릅니다. 양쪽이 모두 Nothing이고 Nothing은 Nothing과 같기 때문입니다.
서로 다른 두 타입의 비교는 결코 오류가 아닙니다. 타입을 가로지르는 동등성은 그냥 거짓이고, 순서는 숫자끼리 또는 문자열끼리만 정의되므로 @.price > "10"은 모든 책에 대해 거짓입니다.
함수 확장
다섯 개가 정의되어 있고 타입이 붙어 있어서, length(@.book[*])는 실행 시점의 놀라움이 아니라 타입 오류입니다.
| 함수 | 받는 것 | 주는 것 |
|---|---|---|
length() |
값 | 문자열이면 유니코드 스칼라 값의 수, 배열이면 원소 수, 객체면 멤버 수, 그 외에는 Nothing |
count() |
노드 목록 | 고른 노드의 개수 |
match() |
문자열과 정규식 | 정규식이 문자열 전체에 맞으면 참 |
search() |
문자열과 정규식 | 정규식이 문자열의 어디엔가 맞으면 참 |
value() |
노드 목록 | 목록에 정확히 한 노드가 있으면 그 값, 아니면 Nothing |
count()가 있는 이유는 단일이 아닌 질의를 비교 피연산자로 쓸 수 없기 때문입니다. “ISBN 있는 책이 정확히 두 권”은 count(@.book[[email protected]]) == 2라고 씁니다. value()는 같은 문제를 반대편에서 풉니다. $[?value(@..name) == 'Corner Books']가 동작하는 것은, value()가 여러 노드짜리 질의를 비교 가능한 값 하나로 접거나, 일치가 하나가 아니면 Nothing으로 접기 때문입니다.
정규식 방언은 I-Regexp(RFC 9485)로, XSD 정규식에 대응되는 의도적으로 작은 부분집합입니다. PCRE가 아닙니다. 전방 탐색이나 역참조를 기대하지 마세요. 그리고 match(@.category, 'fic')은 "fiction"에 대해 거짓이고 search(@.category, 'fic')은 참이라는 점을 기억하세요.
언어에 없는 것
사람들이 여전히 찾는 세 가지가 존재하지 않습니다. 스크립트 표현식, 즉 원래 글의 [(...)] 형태는 사라졌고, 그와 함께 eval() 의존도 사라졌습니다. 부모 연산자는 없습니다. 질의는 아래로만 내려가므로, 감싸는 객체가 필요하면 그 객체를 고르고 자식으로 필터하세요. 그리고 유사 프로퍼티 length도 사라졌으니 $.store.book[(@.length-1)]은 질의가 아닙니다. $.store.book[-1]이라고 쓰세요.
풀어 본 예제
| 표현식 | 결과 |
|---|---|
$.store.name |
"Corner Books" |
$.store.book[*].author |
저자 네 명 전부 |
$..isbn |
ISBN 문자열 둘 |
$.store.book[-1].title |
"The Lord of the Rings" |
$.store.book[1:3].title |
"Sword of Honour", "Moby Dick" |
$.store.book[::2].title |
"Sayings of the Century", "Moby Dick" |
$.store.book[0,-1].title |
"Sayings of the Century", "The Lord of the Rings" |
$.store.book[[email protected] < 10].title |
"Sayings of the Century", "Moby Dick" |
$.store.book[[email protected]].title |
"The Lord of the Rings" |
$.store.book[[email protected] > $.store.book[0].price].title |
"Sword of Honour", "Moby Dick" |
$.store.book[?match(@.category, 'fic.*')].author |
Waugh, Melville, Tolkien |
$.store.book[?search(@.author, 'Mel')].title |
"Moby Dick" |
$.store.book[?length(@.title) > 16].title |
"Sayings of the Century", "The Lord of the Rings" |
문서와 위 표현식 중 하나를 JSONPath 테스터에 붙여 넣으면 노드 목록과 각 일치의 정규화 경로를 나란히 볼 수 있습니다. 음수 인덱스와 없는 키에 대해 여러분의 라이브러리가 RFC와 뜻을 같이하는지 확인하는 가장 빠른 길입니다.
다른 것을 써야 할 때
JSONPath는 노드를 고릅니다. 그게 일의 전부이고, 다른 세 가지 질의 도구가 여기에 겹칩니다.
JSON Pointer(RFC 6901)는 정확히 한 노드를 가리킵니다. 와일드카드도 필터도 모호함도 없습니다. /store/book/0/title처럼 쓰고, 문자 그대로의 슬래시는 ~1, 물결표는 ~0입니다. JSON 스키마의 오류와 JSON Patch의 연산이 위치를 가리킬 때 쓰는 것이 이것입니다. 주소를 안다면 Pointer를 쓰세요.
jq는 자체 값 모델, 산술, 변수, 출력 서식을 갖춘 본격적인 스트림 처리 언어입니다. jq는 변환하고, JSONPath는 고르기만 합니다. 표현식이 새 객체를 만들기 시작했다면 원하는 건 jq입니다.
JMESPath는 둘 사이에 있습니다. RFC 9535보다 오래된, 명세가 있는 질의 언어로 투영과 자체 함수 라이브러리를 갖췄고, 문법은 JSONPath와 헷갈릴 만큼 가깝고 깨질 만큼 다릅니다. 코드베이스마다 하나만 고르세요.
일상적인 경우, 즉 페이로드를 관심 있는 필드까지 줄이는 일이라면 필터 도구가 표현식 언어 없이 해 주고, 뷰어는 질의하려는 형태를 보여 줍니다. 두 페이로드 사이에 무엇이 바뀌었는지 알고 싶은 것이라면, 그건 질의가 아니라 비교입니다.