JSON은 매우 인기가 높지만 결함이 깊다. 이 글은 JSON 설계의 세부, 사용(및 오용) 방식, 그리고 겉보기엔 유용해 보이는 ‘사람이 읽기 쉬움’ 기능이 어떻게 오히려 문제를 낳는지 다룬다. 특히, 전용 도구인 jq 같은 예외를 빼면 스키마 없이 임의의 JSON 문서를 안전하게 다룰 수 있는 JSON 기반 도구는 드물다—흔한 코너 케이스가 데이터 손상으로 이어질 수 있다!
JSON은 매우 인기가 높지만 결함이 깊다. 이 글은 JSON의 설계 세부사항, 그것이 어떻게 사용(그리고 오용)되는지, 그리고 겉보기에는 도움이 되는 “사람이 읽기 쉬움” 기능이 어떻게 오히려 골칫거리를 야기하는지를 다룬다. 무엇보다 중요한 점은, 전용 도구인 jq 같은 예외를 빼면 스키마 없이 임의의 JSON 문서를 안전하게 처리할 수 있는 JSON 기반 도구를 거의 찾기 어렵다는 것이다—흔한 코너 케이스가 데이터 손상으로 이어질 수 있다!
JSON은 엄청나게 단순하기로 유명하다. 사실 전체 문법을 명함 뒷면에 전부 담을 수 있을 정도다. REST API에 너무 흔해서, 아마 당신은 JSON을 이미 꽤 잘 안다고 생각할지 모른다. 십진수 숫자, 따옴표로 둘러싼 문자열, 대괄호의 배열, 중괄호의 키-값 맵(“오브젝트”라고 부른다)이 있다. JSON 문서는 이러한 구성요소 중 아무거나 하나로 이뤄질 수 있다: null, 42, {"foo":"bar"}는 모두 유효한 JSON 문서다.
하지만 JSON의 공식 정의는 꽤 복잡하다. JSON은 IETF 문서 RFC8259로 정의된다(만약 IETF가 무엇인지 모른다면, 인터넷 프로토콜 표준 기구다). 그런데 또한 ECMA, 즉 JavaScript를 표준화하는 표준 기구가 만든 ECMA-404에서도 규범적으로 정의된다1.
JavaScript라고? 그렇다. JSON(JavaScript Object Notation)은 JavaScript와 밀접하게 연결되어 있고 실제로 (거의) 그 부분집합이다. JSON의 JavaScript 혈통이 갖는 기벽이 크지만, 그 외에도 몇 가지 좋지 않은 설계 결정이 불필요한 오류를 더한다.
하지만 JSON의 가장 큰 문제는 특정 설계 결정 하나가 아니라, 언어 생태계 전반과 내부에서 파서 동작과 비준수의 다양성이 상상을 초월한다는 점이다. RFC8259는 이를 특별히 강조한다:
그러나, 최대한의 상호 운용성을 위해 이 명세가 피할 것을 권고하는 여러 관행을 ECMA-404는 허용한다는 점에 유의하라.
RFC는 문서 전반에서 상호 운용성에 관한 관찰을 많이 한다. 아마 가장 노골적이고—무서운—부분은 숫자가 어떻게 동작하느냐다.
JSON 숫자는 십진수로 인코딩되며, 선택적 음수 기호, 소수점 이하, 그리고 과학적 표기법의 지수를 가질 수 있다. 이는 많은 프로그래밍 언어가 자신의 수 리터럴을 정의하는 방식과 비슷하다.
아마도 JSON 숫자는 부동소수점으로 취급하려고 한 것 아니야?
틀렸다.
RFC8259는 답이 불행히도 “원하는 대로”임을 드러낸다.
이 명세는 구현체가 허용하는 숫자의 범위와 정밀도에 제한을 둘 수 있도록 허용한다. IEEE 754 binary64(배정밀도) 숫자를 구현한 소프트웨어는 일반적으로 구할 수 있고 널리 사용되므로, 구현체가 기대하는 정밀도 내에서 JSON 숫자를 근사하는 한, 이들이 제공하는 것 이상을 기대하지 않는 구현체 간에는 좋은 상호 운용성을 달성할 수 있다.
binary64는 흔히 double 또는 float64로 알려진 타입의 “표준 용어”다. 부동소수점은 동적 범위는 훌륭하지만, 정확한 값을 종종 표현하지 못한다. 예컨대 1.1은 부동소수점으로 표현할 수 없다. 모든 부동소수점 값은 정수 n과 m에 대해 n / 2^m 꼴인데, 1.1 = 11/10은 분모에 5가 있기 때문이다. 가장 가까운 float64 값은 다음과 같다.
2476979795053773 / 2^51 = 1.100000000000000088817841970012523233890533447265625
Plaintext
물론 당신은 “모든 JSON 값을 가장 가까운 float64 값에 매핑한다”고 선언하고 싶을지도 모른다. 불행히도, 이 값이 유일하지 않을 수도 있다. 예를 들어 900000000000.00006103515625는 float64로 표현할 수 없고, 정확히 두 개의 float64 값의 가운데에 있다. 반올림 모드에 따라 이는 900000000000 또는 900000000000.0001220703125 중 하나로 반올림된다.
IEEE 754는 기본 반올림 모드로 “짝수에 대한 반올림(round ties to even)”을 권장하므로, 거의 모든 소프트웨어에서 결과는 900000000000이다. 하지만 기억하자. 부동소수점 상태는 하드웨어로 구현된 전역 변수이며, fesetround() 같은 시스템 함수(를 호출하는 어떤 의존성)에 의해 우연히 변경될 수도 있다.
아마 당신은 “그런 까다로운 정밀도 문제 따위는 신경 안 써. 내 숫자에는 소수점도 없어”라고 생각할지 모른다—그렇다면 틀렸다. n / 2^m의 n은 53비트만 쓸 수 있지만, int64 값은 그 범위를 벗어난다. 즉, 난수로 생성한 큰 64비트 정수 같은 값에 대해, 정수를 부동소수점으로 변환하는 JSON 파서는 데이터 손실 을 일으킨다. 예컨대 Go의 encoding/json 패키지가 이런 일을 한다.
무작위로 생성한 숫자에서 이런 일이 얼마나 자주 벌어질까? 작은 몬테카를로 시뮬레이션으로 알아보자.
package main
import (
"fmt"
"math"
"math/big"
"math/rand"
)
const trials = 5_000_000
func main() {
var misses int
var err big.Float
for range trials {
x := int64(rand.Uint64())
y := int64(float64(x)) // Round-trip through binary64.
if x != y {
misses++
err.Add(&err, big.NewFloat(math.Abs(float64(x - y))))
}
}
err.Quo(&err, big.NewFloat(trials))
fmt.Printf("misses: %d/%d, avg: %f", misses, trials, &err)
}
// Output:
// misses: 4970572/5000000, avg: 170.638499
Go
무작위로 분포한 int64 값의 거의 전부가 왕복 변환으로 인한 데이터 손실의 영향을 받는다는 사실을 알 수 있다. 대략, 안전한 숫자는 16자리 이하인 값뿐이다(그마저도 정확하진 않다. 예를 들어 9,999,999,999,999,999는 깔끔하게 10경으로 반올림된다).
이게 당신에게 어떤 영향을 줄까? 어딘가에 사용자 ID와 다른 사용자와의 비공개 메시지 대화록을 포함한 JSON 문서가 있다고 해보자. 반올림으로 인한 데이터 손실은 잘못된 사용자 ID가 그 비공개 메시지에 연결되도록 만들 수 있으며, 이는 PII 유출이나(예: GDPR 요구사항 같은) 프라이버시 동의 관리의 오류로 이어질 수 있다.
이는 당신 의 사용자 ID만의 문제가 아니다. 다른 많은 벤더의 ID도 큼직한 정수이며, JSON 문법은 기술적으로 이를 수용할 수 있지만, 수많은 도구가 이를 망가뜨린다. 예시 몇 가지:
binary64의 “안전한” 범위에 들어간다. 너무 흔해서 안심할 수도 있지만, 모든 신용카드가 16자리인 것은 아니다. 어떤 카드들은 이제 19자리를 지원한다.이처럼, 단지 데이터 직렬화 포맷 때문에 상당히 심각한 컴플라이언스 문제가 생긴다.
주의하면 이 문제는 피할 수 있다. 결국 Go는 리플렉션을 사용해 임의의 타입으로 JSON을 파싱할 수 있다. 예컨대 몬테카를로 시뮬레이션의 내부 루프를 다음과 같이 바꾸면:
for range trials {
x := int64(rand.Uint64())
var v struct{ N int64 }
json.Unmarshal([]byte(fmt.Sprintf(`{"N":%d}`, x)), &v)
y := v.N
if x != y {
// ...
}
}
Go
모든 시행에서 x == y임을 곧바로 확인할 수 있다. 타입 정보가 있으면, Go의 JSON 라이브러리는 정확히 목표 정밀도를 알기 때문이다. 만약 우리가 struct { N int64 } 대신 any로 파싱했다면 큰일 났을 것이다. 바깥 오브젝트는 map[string]any로, N 필드는 float64로 바뀌었을 테니까.
이는 곧, 당신의 시스템은 아마도 필드를 알 수 없는 JSON 문서를 안전하게 처리하지 못한다는 뜻이다. jq 같은 도구는 데이터 손실을 피하려면 숫자 처리에서 극도로 신중해야 한다. 이는 서드파티 도구가 저지르기 쉬운 실수다.
하지만 다시 말하지만, 표준이 float64인 것도 아니다—표준이 없다. 어떤 구현은 32비트 부동소수점만 지원할 수도 있어 상황을 더 악화시킨다. 어떤 구현은 영리한 척 정수에는 int64, 소수에는 float64를 쓰기도 한다. 하지만 여전히 파싱 가능한 값에 임의의 한계를 도입하므로 데이터 손실을 초래할 수 있다.
Python 같은 일부 구현은 bignum을 사용하므로, 문제가 없어 보일 수 있다. 그러나 이는 거짓된 안도감으로 이어질 수 있다. 문제가 너무 늦게, 예컨대 어떤 데이터베이스가 표면상 유효하지만 상호 운용 불가능한 JSON을 이미 포함하게 된 뒤에야 드러나기도 한다.
Protobuf는 이를 꽤 비이식적인 방식으로 다룰 수밖에 없다. 데이터 손실을 피하려고, 큰 64비트 정수는 JSON으로 직렬화할 때 따옴표로 감싼 문자열로 직렬화된다. 즉, {"foo":6574404881820635023} 대신 {"foo":"6574404881820635023"}를 출력한다. 데이터 손실 문제는 해결하지만, Go의 라이브러리 같은 다른 JSON 라이브러리와는 호환되지 않아 다음과 같은 오류가 난다:
json: cannot unmarshal string into Go struct field .N of type int64
Plaintext
특수 부동소수점 값인 Infinity, -Infinity, NaN은 표현할 수 없다. {x:1.0/0.0}에 해당하는 값을 직렬화하려 하면 무슨 일이 벌어질지는 난장판이다.
json: unsupported value: +Inf라고 한다.{"x":"inf"}로 직렬화한다(혹은 그래야 한다—어떤 구현이 올바르게 하는지는 불분명하다).JSON.stringify({x:Infinity})는 {"x":null}을 출력한다.json.dumps({"x":float("inf")})는 {"x":Infinity}를 출력하는데, 이는 RFC8259 기준으로 유효한 JSON조차 아니다.NaN은 어쩌면 더 나쁘다. NaN 페이로드(그렇다, NaN에는 특별한 페이로드가 있다)는 이를 "nan" 등으로 변환할 때 버려진다(라이브러리가 어떻게 표현하든 간에).
이게 당신에게 영향이 있을까? 부동소수점을 조금이라도 쓰고 있다면, 0으로 나누기나 오버플로 한 번이면 직렬화 오류가 터진다. 최선의 경우 “무해한” 데이터 손상(JavaScript)이다. 최악의 경우, 데이터가 부분적으로 사용자에 의해 제어된다면 크래시나 파싱 불가능한 출력으로 이어질 수 있으며, 이는 DoS 벡터가 된다.
이에 비해 Protobuf 직렬화는 string 필드가 비UTF-8이거나 순환 참조가 있을 때를 제외하면 실패하지 않는다. 둘 다 계산 중 NaN이 튀어나오는 것에 비해 훨씬 가능성이 낮다.
결국 수많은 파서가 시간에 따라 특수 부동소수점 값을 위해 별의별 이상한 것을 파싱하게 된다. 포스텔의 법칙 때문이다. RFC8259는 이런 현실 세계의 상황을 다루는 방법에 대해 “운 나빴네, 상호 운용 불가” 이상의 조언을 전혀 하지 않는다.
JSON 문자열은 비교적 온건하며, JavaScript와 고의적으로(하지만 바람직하게) 다른 점이 있다. 구체적으로, JavaScript는(그리고 Java도) 시대가 시대인지라 유니코드 텍스트 인코딩으로 UTF-16을 쓴다. 세계 대부분은 이것이 나쁜 생각임을 깨달았다(ASCII 텍스트의 크기를 두 배로 만든다. 인터넷 트래픽의 거의 전부가 ASCII다). 그래서 JSON은 대신 UTF-8을 사용한다. RFC8259는 문서 전체가 반드시 UTF-8로 인코딩되어야 한다고 명시한다.
하지만 §8.2에서 유니코드 문자에 대해 읽어보면 실망스럽다. 따옴표 문자열이 전적으로 유니코드 문자로만 이루어져 있으면 정말 굉장하다 고만 말하고, 짝이 맞지 않는 서로게이트를 허용한다는 뜻이다. 사실상 스펙은 JSON 문자열이 WTF-8, 즉 짝이 맞지 않는 서로게이트를 허용하는 UTF-8이어야 한다고만 요구한다.
짝이 맞지 않는 서로게이트가 뭔가? 유니코드의 32비트 값 중 U+D800에서 U+DFFF 범위의 값이다. 이 구간은 유니코드 코드포인트의 공백이며, UTF-8의 가변 길이 정수 인코딩으로 표현할 수는 있지만, 이러한 값이 바이트 스트림에 존재하면 그것은 유효한 UTF-8이 아니다. WTF-8은 이러한 값을 허용한다는 점만 빼면 UTF-8이다.
그렇다면 실제로 누가 이를 파싱(또는 직렬화)할까? 짝이 맞지 않는 서로게이트 U+DEAD가 들어 있는 문서 {"x":"\udead"}를 생각해보자.
"\xff" 같은 비UTF-8 문자열은 직렬화할 때 잘못된 바이트를 U+FFFD 대체 문자(이거: �)로 바꿔 "\ufffd"로 만든다.U+FFFD로 바꾸지 않고 그대로 다시 직렬화한다.문자열에는 다른 놀라운 함정도 있다: "x"와 "\x78"은 같은 문자열일까? RFC8259는 오브젝트 키의 동등성 검사 목적에서는 그렇다고 일부러 못을 박는다. 굳이 못을 박는다는 건, 이것 또한 잠재적 문제 원천이라는 뜻이다.
문자가 아니라 바이트를 보내고 싶을 땐 어떻게 할까? 자주 보내는 바이트 블롭의 한 예는 콘텐츠 주소형(blobstore)에서 문서를 식별하는 암호학적 해시, 혹은 디지털 서명(암호화된 해시)이다. JSON에는 바이트 문자열을 표현하는 고유한 방법이 없다.
ASCII와 \xNN 이스케이프(ASCII 범위 밖의 바이트)를 섞어 따옴표 문자열로 보낼 수 있지만, 대역폭을 낭비하고 상호 운용성 문제도 심각하다(위에서 보았듯, Go는 이 경우 데이터를 적극적으로 파괴한다). JSON 숫자의 배열로 인코딩할 수도 있지만, 대역폭과 직렬화 속도 면에서 훨씬 더 나쁘다.
결국 모두가 어떻게든 base64 인코딩에 의존하게 된다. 예컨대 Protobuf는 JSON에서 bytes 필드를 base64 문자열로 인코딩한다. 이로 인해 JSON의 인간 가독성이라는 장점이 사라진다: 블롭이 거의 ASCII로 되어 있어도 사람은 알아볼 수 없다.
이는 JSON의 일부가 아니므로, 사실상 어떤 JSON 코덱도 이를 대신 디코딩해주지 않는다. 특히 스키마가 없으면, base64로 인코딩된 바이트 블롭과 우연히 유효한 base64를 포함한 실제 텍스트 문자열(예: 영숫자 사용자명)을 구분할 방법이 없다.
다른 문제들에 비하면 종이 자르기 수준의 상처일 수 있지만, 불필요하고 복잡성과 상호 운용 문제를 더한다. 그건 그렇고, 서로 호환되지 않는 Base64 알파벳이 여럿 있다는 걸 아는가?
JSON의 덜 드러난 문제 하나는 스트리밍이 안 된다는 것이다. 거의 모든 JSON 문서는 오브젝트나 배열이며, 따라서 각각 닫는 } 또는 ]에 도달하기 전까지는 불완전 하다. 즉, 사후 처리로 합치는 추가 프로토콜 없이, 더 큰 문서의 일부를 이루는 JSON 문서 스트림을 보낼 수 없다.
JSONL은 이 문제를 가능한 한 가장 단순한 방법으로 “해결”한, 세상에서 가장 우스운 스펙이다. JSONL 문서는 줄바꿈으로 구분된 JSON 문서의 연속이다. JSONL은 스트리밍이 가능 하지만, 너무 단순하게 했기 때문에 거대한 배열 스트리밍만 지원한다. 예컨대 오브젝트를 필드 단위로 스트리밍하거나 그 내부의 배열을 스트리밍할 수는 없다.
Protobuf에는 이런 문제가 없다. 한마디로, Protobuf 와이어 포맷은 문서의 최상위 배열 또는 오브젝트에서 중괄호와 대괄호를 제거하고, 같은 키를 가진 값들이 병합되도록 만든 것과 같다. 와이어 포맷에서는 다음 JSONL 문서의 동등물
{"foo": {"x": 1}, "bar": [5, 6]}
{"foo": {"y": 2}, "bar": [7, 8]}
JSON
이 자동으로 하나의 문서로 “병합”된다.
{ "foo": { "x": 1, "y": 2 }, "bar": [5, 6] }
JSON
이는 “메시지 병합” 연산의 기반을 이루며, 와이어 포맷이 설계된 방식과 밀접하게 연결되어 있다. 이 근본 연산은 다음 글에서 자세히 파헤칠 것이다.
JSON 웹 토큰(JWT)과 JSON 웹 서명(JWS)을 정의하는 RFC7519과 RFC7515 덕분에 JSON 문서에 디지털 서명하는 일은 매우 흔하다. 하지만 디지털 서명은 특정 바이트 블롭에만 서명할 수 있으며, 공백이나 키 순서처럼 JSON이 신경 쓰지 않는 것에도 민감하다.
이로 인해 JSON 문서의 정준화(canonicalization) 를 위한 RFC8785 같은 스펙이 등장한다. 이는 기존 JSON 문서가—우연히 상호 운용 불가능하거나(혹은 Python 같은 비준수 구현 덕분에) 아예 유효하지 않은 JSON을 포함했을 때—서드파티 도구에 의해 조작되고 재포맷되어야 하는 새로운 경로를 연다. RFC8785 자체도 숫자 직렬화 방식에 대해 ECMA-262(JavaScript 표준)를 참고하며, 이는 64비트 숫자 값에 대해 데이터 손실을 반드시 유발하도록 요구한다!
단도직입적으로? 아니다. JSON은 너무나도 대중적이기에 고칠 수 없다. 흔한 실수들이 포맷 자체에 굳어져 있다. 주석을 허용하나? 트레일링 콤마는? 숫자 형식은? 아무도 모른다!
당신의 JSON을 건드리는 도구는 무엇인가? 그 도구들은 밟을 수 있는 모든 갈고리를 인지하고 있는가? (Python처럼) 유효하지 않은 JSON을 내보내는가? 이를 어떻게 감사를 시작할 것인가?
다행히 JSON을 꼭 써야 하는 것은 아니다. 대안은 있다—BSON, UBJSON, MessagePack, CBOR 같은 여러 바이너리 포맷이 JSON의 데이터 모델을 복제하려고 한다. 불행히도 그들 중 많은 포맷도 각자 문제를 지닌다.
반면 Protobuf는 이러한 문제가 전혀 없다. 애초에 JSON이 충족하지 못한 요구를 충족하기 위해 설계 되었기 때문이다. Protobuf 같은 강한 타입의 스키마 시스템을 사용하면, 위 모든 문제가 사라진다.
물론, 어떤 영리한 사람은 아마도 json.org를 인용하고 싶어할 것이다. 강조하자면: json.org는 표준이 아니다. 규범적 문서가 아니다. 업계를 대표하는 국제 표준 기구인 IETF와 ECMA가 만든 문서들만이 규범적이다. 브라우저 구현자가 JSON을 글자 그대로 구현하려고 할 때 향하는 곳은 ECMA이지, 90년대 감성의 개인 웹사이트가 아니다. ↩