개발 이야기4분 읽기

JSON에서 숫자가 조용히 바뀔 때

64비트 ID를 자바스크립트로 받으면 끝자리가 달라집니다. 오류 없이 값만 틀어지는 이유와 대응 방법을 정리했습니다.

API가 내려준 주문 번호로 조회하면 없는 주문이라고 나오는 경우가 있습니다. 로그를 보면 요청한 번호가 응답에 있던 번호와 끝자리 하나만 다릅니다. 어디서도 오류가 나지 않았고, 값만 바뀌었습니다.

스펙은 정밀도를 정하지 않는다

JSON 표준은 숫자의 문법만 정합니다. 몇 비트로 저장할지, 정수와 실수를 구분할지는 파서에 맡깁니다.

자바스크립트의 number는 IEEE 754 배정밀도 부동소수점입니다. 정수를 정확히 표현할 수 있는 범위가 정해져 있습니다.

Number.MAX_SAFE_INTEGER = 9007199254740991   (2^53 - 1)

이 범위를 넘어가면 표현할 수 없는 값이 생기고, 가장 가까운 표현 가능한 값으로 바뀝니다.

9007199254740993  →  9007199254740992

여기서 문제가 되는 것은 예외가 발생하지 않는다는 점입니다. 파싱은 성공하고, 값만 다릅니다.

어떤 값이 걸리나

64비트 정수를 ID로 쓰는 곳이 전부 해당됩니다.

  • 데이터베이스의 bigint 기본키
  • 스노플레이크 방식 ID (트위터, 디스코드 등)
  • 일부 시스템의 나노초 타임스탬프 (19자리)

2^53은 약 9007조입니다. 자동 증가 정수라면 도달할 일이 없지만, 스노플레이크 ID는 타임스탬프를 상위 비트에 두므로 처음부터 이 범위를 넘습니다.

다른 언어에서는 재현되지 않는다

이 문제의 성가신 점은 환경에 따라 나타나기도 하고 아니기도 한다는 것입니다.

  • 파이썬 — 정수가 임의 정밀도라 그대로 들어옵니다
  • 자바, Goint64로 받으면 문제없습니다. 다만 파싱 결과를 double이나 interface{}로 받으면 같은 문제가 생깁니다. Go에서 json.Unmarshalmap[string]interface{}에 넣으면 숫자가 float64가 됩니다
  • 자바스크립트 — 항상 걸립니다

백엔드에서 테스트하면 통과하고 브라우저에서만 틀리는 상황이 여기서 나옵니다. 그리고 응답 자체는 정확하므로 네트워크 탭의 원문에는 올바른 값이 보입니다. 콘솔에 찍은 값은 이미 파싱된 뒤라 바뀐 값입니다. 원문과 파싱 결과를 나눠 봐야 합니다.

대응

ID는 문자열로 주고받습니다. 가장 확실한 방법입니다. ID는 계산하지 않고 비교와 조회에만 쓰므로 문자열이어도 잃는 것이 없습니다. 트위터 API가 id와 함께 id_str을 함께 내려준 것이 이 대응이었습니다.

이미 숫자로 내려주는 API를 바꿀 수 없다면:

  • 파싱 전에 문자열 단계에서 해당 필드를 따옴표로 감쌉니다. 정규식으로 처리하므로 필드가 정해져 있을 때만 쓸 수 있습니다
  • json-bigint 같은 라이브러리를 씁니다. 큰 정수를 BigInt로 파싱합니다

JSON.parse의 reviver 함수로는 해결되지 않습니다. reviver가 받는 값은 이미 number로 변환된 뒤라 그 시점에는 원래 값을 알 수 없습니다.

소수는 또 다른 문제

정수 범위와 별개로, 소수는 이진 부동소수점으로 정확히 표현되지 않는 값이 많습니다.

0.1 + 0.2 = 0.30000000000000004

금액에 부동소수점을 쓰면 합계가 1원씩 어긋납니다. 대응은 정해져 있습니다.

  • 최소 단위의 정수로 다룹니다. 원 단위, 센트 단위로 저장하고 표시할 때만 나눕니다
  • 또는 문자열로 주고받고 각 언어의 십진 타입(decimal, BigDecimal)으로 계산합니다

표준이 아닌 JSON

파서가 거부하는 입력도 정리해 둘 만합니다. 다음은 JSON이 아닙니다.

  • 마지막 항목 뒤의 쉼표 — {"a": 1,}
  • 주석 — // ..., /* ... */
  • 작은따옴표 문자열 — {'a': 1}
  • 따옴표 없는 키 — {a: 1}
  • NaN, Infinity, undefined

자바스크립트 객체 리터럴과 비슷해 보이지만 규칙이 다릅니다. 설정 파일에서 주석이 필요하다면 JSON5나 JSONC처럼 별도 형식을 쓰는 것이고, 일반 JSON 파서는 읽지 못합니다.

키가 중복된 경우({"a": 1, "a": 2})는 표준이 동작을 정하지 않았습니다. 대부분 마지막 값을 취하지만 구현마다 다르므로 만들지 않는 편이 낫습니다.

확인 순서

  1. 응답 원문에서 값을 확인합니다. 네트워크 탭의 원문이나 curl 결과입니다
  2. 파싱한 값과 비교합니다. 다르면 정밀도 문제입니다
  3. 해당 필드가 2^53을 넘는지 봅니다. 16자리 이상이면 의심합니다

tools.onuel.devJSON 포매터검증기가 있어 구조 오류를 찾을 수 있고, 두 응답을 나란히 놓고 어느 필드가 달라졌는지 보는 JSON 비교도 있습니다.

  • #JSON
  • #정밀도
  • #API