JSON의 ID가 자꾸 값이 바뀌는 이유
JSON 숫자에는 상한이 없습니다. IEEE 754 double에는 있습니다. 나머지는 전부 거기서 따라 나옵니다.
이 페이지의 모든 주장은 직접 측정했거나 출처가 있습니다. 둘 다 아닌 경우에는 그렇다고 명시합니다.
브라우저 콘솔에 이걸 붙여 넣어 보세요.
JSON.parse('{"id": 12345678901234567890}')
// { id: 12345678901234567000 }
마지막 세 자리가 바뀌었습니다. 아무것도 던져지지 않았고 아무 경고도 없었습니다. 그리고 그 값이 트위터 스노플레이크나 데이터베이스 기본 키였다면, 이제 여러분이 들고 있는 건 다른 레코드입니다. 이것이 JSON이 데이터를 조용히 망가뜨리는 가장 흔한 경로이고, 하필 사람들이 가장 의심하지 않는 곳에서 벌어집니다. 바로 그들이 믿는 파서 안에서요.
경계는 실제로 어디인가
RFC 8259는 JSON 숫자의 크기에도 정밀도에도 제한을 두지 않습니다. 문법상 자릿수는 얼마든 괜찮습니다. 그래서 12345678901234567890123456789는 완벽하게 유효한 JSON 숫자이고, 소수점 아래 200자리짜리 소수도 마찬가지입니다.
JSON을 다루는 한 JavaScript의 숫자 타입은 하나뿐입니다. IEEE 754 배정밀도 부동소수점입니다. double의 유효숫자부는 53비트라서 2^53-1까지의 정수는 전부 정확히 표현하지만, 그보다 위의 정수를 전부 표현하지는 못합니다. 그 값이 9007199254740991이고, JavaScript는 Number.MAX_SAFE_INTEGER로 공개합니다.
그 위로 가면 double은 듬성듬성해집니다. 표현 가능한 정수 사이의 간격은 2^54까지가 2, 그다음 4, 그다음 8로 매번 두 배가 됩니다. 그래서,
9007199254740992 === 9007199254740993 // true
이 둘은 같은 double입니다. 홀수 쪽에 대응하는 비트 패턴이 없어서 짝수 이웃으로 반올림됩니다. 여러분의 ID는 버그로 망가진 게 아닙니다. 수직선의 구멍에 빠진 것입니다.
RFC는 이걸 예상하고 있습니다. 6절은 어떤 수가 IEEE 754 binary64를 “왕복”할 수 있으면 상호운용 가능하다고 말하고, 그것을 쓰는 구현들은 “일반적으로… 숫자 값에 대해 구현끼리 정확히 일치한다는 의미에서 상호운용 가능하다”고 덧붙입니다. 거기서 일을 다 하고 있는 낱말은 일반적으로입니다.
거대한 정수만의 이야기가 아니다
소수는 훨씬 이른 단계에서, 훨씬 덜 눈에 띄게 정밀도를 잃습니다.
0.1 + 0.2 // 0.30000000000000004
JSON.parse('{"v": 1.005}') // { v: 1.005 } 이지만 1.005 * 100 은 100.49999999999999
돈에 얽힌 고전적인 버그입니다. 1.005로 저장한 가격은 double로 정확히 표현되지 않아서, 소수 두 자리로 반올림하면 1.01이 아니라 1.00이 됩니다. 금융 시스템이 돈을 최소 단위의 정수로 담거나 십진 문자열로 담고, 절대 JSON 부동소수점으로 담지 않는 이유가 그것입니다.
더 조용한 사례도 있습니다. 1.0이 든 JSON 문서는 JavaScript에서 숫자 1이 되고, 다시 직렬화하면 1이 됩니다. 문서가 바뀐 것입니다. 대부분의 용도에서는 상관없지만, 해시를 뜨거나 서명하거나 diff를 뜨는 문서라면 상관있습니다.
언어별 동작
동작은 대부분이 생각하는 것보다 더 크게 갈리고, 자기가 어느 쪽에 있는지가 해법을 결정합니다.
| 언어 | 큰 정수에 대한 기본 동작 | 자릿수를 지킬 수 있나 |
|---|---|---|
| JavaScript | 조용히 가장 가까운 double로 반올림 | 못 합니다. JSON.parse에는 원본 텍스트를 볼 수 있는 훅이 없습니다 |
| Python | 임의 정밀도 int, 정확함 |
됩니다, 자동으로. parse_int와 parse_float가 날 텍스트를 받습니다 |
| Go | 기본은 float64 |
됩니다. Decoder.UseNumber()가 텍스트를 json.Number로 유지합니다 |
| Java(Jackson) | 필요에 따라 Integer, Long, BigInteger |
됩니다. USE_BIG_INTEGER_FOR_INTS로 강제할 수도 있습니다 |
| Rust(serde_json) | u64 / i64 / f64 |
됩니다. arbitrary_precision 기능을 켜면 됩니다 |
| PHP | PHP_INT_MAX까지는 int, 그 위는 float |
부분적으로. JSON_BIGINT_AS_STRING이 문자열로 유지합니다 |
| C# | 파서에 따라 long, decimal, double |
됩니다. System.Text.Json이 날 텍스트를 노출합니다 |
위험한 건 이 비대칭입니다. 파이썬 서비스가 19자리 정수를 정확하게 써 내고, JavaScript 클라이언트가 다른 값을 읽고, 두 시스템은 어느 쪽도 로그에 남긴 적 없는 값을 두고 어긋납니다.
JavaScript만의 문제
JavaScript가 유별난 이유는 JSON.parse가 끼어들 방법을 전혀 주지 않기 때문입니다. reviver 함수는 숫자가 이미 변환된 뒤에 실행됩니다.
JSON.parse(text, function (key, value) {
// 여기서 `value`는 이미 double입니다. 원래 자릿수는 사라졌습니다.
return value;
});
TC39에는 “JSON.parse source text access”라는 제안이 있고, 바로 그것을 더합니다. reviver가 값의 소스 텍스트를 담은 컨텍스트 객체를 받게 되어 거기서 BigInt를 만들 수 있습니다. 아직 어디서나 쓸 수 있는 건 아니라서, 오늘의 선택지는 이렇습니다.
- 텍스트를 직접 토큰화하는 라이브러리로 파싱한다. 이 사이트의 파서가 하는 일이 그것입니다.
- 파싱 전에 정규식으로 텍스트를 전처리해 큰 정수에 따옴표를 씌운다. 위태롭습니다. 정규식은 문자열 안의 숫자와 값으로서의 숫자를 구별하지 못합니다.
- 발생 지점에서 고친다.
진짜 해법 네 가지, 선호 순서대로
큰 ID는 문자열로 보내세요. {"id": "12345678901234567890"}. 이게 해법입니다. 값마다 2바이트가 더 들 뿐이고, 어떤 언어에서도 설정 없이 옳게 동작합니다. 트위터는 2010년에 id 옆에 id_str 필드를 더해 이렇게 했고, 이후 큰 플랫폼은 모두 같은 일을 했습니다. API를 설계하고 있다면 처음부터 그렇게 하세요. 식별자는 수량이 아니고, 그걸로 산술을 하지도 않으며, 숫자 타입을 준다고 얻는 게 없습니다.
돈은 최소 단위로 담으세요. 10.05가 아니라 1005센트로 저장합니다. 2^53 미만의 정수는 어디서나 정확하고, 그러면 소수 문제를 우회한 게 아니라 없앤 것이 됩니다.
정밀도 자체가 핵심인 것에는 십진 문자열을 쓰세요. 가격, 측정값, 의미 있는 좌표. "lat": "51.5074"는 더 볼품없지만 값이 흔들리지 않습니다.
만드는 쪽을 바꿀 수 없다면 파서를 설정하세요. Go의 UseNumber, 파이썬의 parse_int, Rust의 arbitrary_precision, PHP의 JSON_BIGINT_AS_STRING. 효과는 있지만, 지킬 수 있는 건 내가 통제하는 소비자뿐입니다.
BigInt가 해결하는 것과 못 하는 것
JavaScript의 BigInt는 임의 정밀도 정수를 표현하므로 값 자체는 담을 수 있습니다. 파싱에는 도움이 되지 않습니다.
JSON.parse('{"id": 12345678901234567890}') // 이 시점에 정밀도는 이미 사라졌다
BigInt("12345678901234567890") // 문자열만 있으면 정확
직렬화에도 도움이 되지 않습니다. JSON.stringify는 BigInt에 대해, 숫자를 원했는지 문자열을 원했는지 추측하는 대신 오류를 던집니다.
JSON.stringify({ id: 1n })
// TypeError: Do not know how to serialize a BigInt
replacer로 직접 정해야 합니다.
JSON.stringify({ id: 1n }, (k, v) => (typeof v === 'bigint' ? v.toString() : v));
그리고 결국 문자열로 보내는 자리로 되돌아옵니다. 처음부터 그게 정답이었습니다.
이 문제가 나에게 있는지 알아보는 법
실제 페이로드를 검증기에 붙여 넣어 보세요. 안전 범위를 벗어난 정수는 모두 JSON.parse가 대신 돌려줄 값과 함께 표시되고, float64 왕복을 견디지 못하는 소수는 따로 표시됩니다.
개수가 0이 아니라면, 그 페이로드를 받아 쓰는 어딘가는 이미 여러분이 보낸 것과 다른 숫자를 읽고 있습니다. 그리고 그 필드가 생긴 이래로 줄곧 그래 왔습니다.
이 사이트의 서식 도구는 JSON.parse를 거치지 않습니다. 모든 숫자의 원본 소스 텍스트를 그대로 다시 내보내므로, 19자리 ID가 든 문서를 정렬해도 같은 19자리가 돌아옵니다. 낮은 기준입니다. 그리고 대부분의 포맷터가 넘지 못하는 기준이기도 합니다.