dev.to

🔢 The JSON Numbers JavaScript Quietly Corrupts

🔢 JavaScript가 조용히 손상시키는 JSON 숫자

JavaScript의 JSON.parse는 큰 정수를 반올림하고, 너무 큰 수를 Infinity로 바꾸며, 4.0 같은 숫자 표기 형식도 보존하지 않습니다. 글은 숫자 토큰을 그대로 보존하는 파서 정책을 제시하고, 왕복 변환 검사만으로 숫자 손상을 판별하기 어렵다는 댓글의 지적도 나옵니다.

에디터 노트

숫자 값의 손실과 문서 표기의 변화는 서로 다른 문제이며, 글의 두 단계 검사는 둘을 구분해 처리합니다. 안전한 정수인지 확인하는 데 그치지 않고 4.0처럼 수치가 정확해도 다시 썼을 때 달라지는 토큰까지 보존한다는 설명이 구체적입니다. 일부 값만 래퍼로 감싸 기존 숫자 처리 코드의 변경 범위를 줄이는 선택도 제시한 제약과 맞닿아 있습니다. 다만 원문과 왕복 결과를 통째로 비교하는 검사는 숫자 변경만 가려내지 않습니다. 댓글에서 든 공백, 이스케이프, 중복 키도 결과를 바꿉니다. 따라서 이 검사는 문서 전체가 그대로 유지되는지를 보는 데는 쓸 수 있지만 숫자 손상 진단에는 별도 토큰 비교가 필요합니다. 댓글의 reviver context.source 제안도 원본 토큰을 읽는 대안이지만, 지원 범위와 런타임 호환성을 확인해야 한다는 조건이 붙습니다. 금액 댓글은 부동소수점 계산에서 생기는 반올림 문제를 보탭니다. 이 문제는 파싱 단계의 큰 정수 손실과 별개이므로 문자열이나 정수 센트로 경계에서 처리하자는 의견은 글의 토큰 보존 정책을 금액 계산 전체의 해법으로 확대하지 않습니다.

AI 요약

JavaScript에서 JSON 숫자를 읽고 다시 쓰면 값과 문서 표기가 달라질 수 있습니다. 글은 정밀도 손실, 오버플로, 숫자 표기 변화라는 세 가지 사례를 보여주고, 원래 숫자 토큰을 보존하는 방법을 설명합니다.

JSON.parse에서 달라지는 숫자

JSON에는 숫자 크기 제한이 없지만 JavaScript의 Number는 IEEE-754 배정밀도 부동소수점입니다. 정수는 2^53-1, 즉 9007199254740991을 넘으면 모든 연속 정수를 표현하지 못합니다. 예를 들어 9007199254740993을 JSON.parse로 읽으면 9007199254740992가 됩니다. Discord나 Twitter/X의 snowflake ID, 데이터베이스 bigint처럼 64비트 정수를 ID로 쓰는 값도 반올림될 수 있습니다. 가까운 숫자로 바뀐 ID는 오류를 내지 않으면서 존재하지 않거나 다른 레코드를 가리킬 수 있습니다.

1e400처럼 Number 범위를 넘는 값은 Infinity가 됩니다. 이를 JSON.stringify로 다시 쓰면 JSON에 Infinity 표기가 없으므로 null로 직렬화됩니다. 4.0은 수치상 4와 같아 파싱 후 다시 쓰면 4가 되고, 1.00은 1, -0은 0, 5e+3은 5000으로 바뀝니다. 설정 파일이나 테스트 픽스처를 왕복 처리하면 값은 같아도 문서가 달라져 diff가 생깁니다.

숫자 토큰 보존하기

글은 수치만 보존하지 말고 원래 숫자 토큰을 보존하라고 제안합니다. 파싱한 숫자를 다시 쓸 때 원래 바이트와 같아질지 검사하고, 그렇지 않은 토큰만 원래 숫자 문자열을 담은 LosslessNumber 래퍼로 저장합니다. 제시한 구현은 먼저 값이 안전한지 확인하고, 이어 String(parseFloat(value))가 원래 문자열과 같은지 검사합니다. 첫 검사는 큰 정수의 값 손실을 잡고, 두 번째 검사는 4.0이 4로 바뀌는 표기 손실을 잡습니다.

1, 42, 0.1, -17.5처럼 정확히 왕복하는 값은 일반 JavaScript 숫자로 둡니다. 모든 숫자를 객체로 감싸면 typeof 검사나 컨테이너 판별을 하는 기존 코드까지 살펴봐야 하기 때문입니다. 보존과 계산은 분리합니다. 표시, 선택, 복사, 재직렬화에서는 숫자 토큰을 보존하고, 산술·집계·비교에서는 부동소수점으로 바꾸며 도구가 그 사실을 알립니다. 작성자는 Prettify의 JSON과 JSONL 처리에 이 정책을 적용하고 jq 엔진에서도 계산 과정의 강제 변환을 표시한다고 설명합니다.

점검 방법과 한계

글은 JSON.parse 후 JSON.stringify한 결과를 원문과 비교하는 간단한 검사를 제안합니다. 다만 이 비교는 숫자 외에도 공백, 유니코드 이스케이프 표기, 중복 키 때문에 달라질 수 있습니다. 댓글은 숫자 토큰만 비교해야 숫자 변화를 따로 확인할 수 있다고 지적합니다. 또한 JSON 처리에 한정된 정책이며 YAML과 CSV는 일반 JavaScript 숫자를 사용합니다. 이미 앞 단계에서 반올림된 값은 복구할 수 없고, 계산 과정에서도 정밀한 원문 표기를 그대로 유지할 수는 없습니다.

dev.to 반응

  • @gautamtalksdev — 돈 계산에서 조용한 오류가 특히 아픕니다. 캐나다 급여 엔진을 만들 때 CPP 항목 하나를 (500.00 - 350.00) x 0.0595로 계산하면 8.925이고, CRA가 공개한 공식은 이를 8.93으로 반올림합니다. 일반 double로 같은 계산을 하면 8.924999999999999가 나와 8.92로 반올림됩니다. 급여명세서에서 1센트가 틀려도 오류는 나지 않습니다. ID와 같은 교훈입니다. 금액은 경계에서 문자열이나 정수 센트로 파싱하고 JavaScript 숫자로 바꾸지 않는 규칙을 추가하겠습니다.
  • @robertpaschal — ID 충돌을 직접 겪었습니다. 운영하던 Node 서비스가 공급자의 64비트 거래 ID를 전달하면서 JSON.parse가 끝자리를 반올림했습니다. 서로 다른 결제 두 건이 같은 반올림 ID로 합쳐져 누락된 레코드를 찾느라 하루를 썼습니다. API 경계에서 문자열 ID를 강제해 해결했지만 공급자를 통제할 수 없어 우리 쪽에서 처리해야 했습니다. 표기 변화는 생각 못 했는데 테스트 설정의 JSON 왕복 처리에서 4.0이 4가 되면서 생긴 가짜 diff를 설명해 줍니다. 왕복 동일성 검사를 API 테스트의 기본 검증으로 넣을까요, 아니면 일상적인 테스트에 잡음이 너무 많을까요?
  • @idanshalem — JSON.parse의 reviver context.source도 선택지입니다. 파싱된 Number가 이미 정밀도를 잃었더라도 원본 숫자 토큰을 보존하므로 원본에서 큰 정수를 복구할 수 있습니다. 표기를 보존해야 한다면 4.0과 4도 구분합니다. 다만 source는 원시값에서만 제공되고, 구형 엔진에서는 지원 여부를 확인하거나 대체 방법을 마련해야 합니다.
  • @efe_genc — JSON.stringify(JSON.parse(original))와 원문을 비교하는 빠른 검사가 세 사례를 모두 잡는다고 했지만, 공백이 있는 예쁜 출력 문서에서도 stringify가 공백을 없애므로 차이가 납니다. \\u00e9 이스케이프도 실제 문자로 바뀌고 중복 키도 합쳐집니다. 결과가 다르다는 사실만으로 숫자가 바뀌었다고 볼 수는 없습니다. 숫자 토큰을 각각 추출해 비교하면 검사 대상을 분리할 수 있습니다. Prettify의 토큰 보존 정책은 숫자에만 적용되나요, 문자열 이스케이프와 키 순서, 중복 키도 보존하나요?

원문: dev.to / 번역·요약: Trawling