본문 바로가기
빌드노트 검색

빌드노트

JSON에서 받은 큰 ID의 마지막 숫자가 바뀐다

JSON 숫자로 받은 큰 ID 두 개가 파싱 뒤 같은 값이 되는지 비교한다. 뒤늦은 String·BigInt 변환으로 잃은 숫자가 돌아오지 않는 이유와, 정확한 원본부터 문자열로 전달할 위치를 실제 Node 출력으로 설명한다.

JSON 응답의 ID는 9007199254740993인데, JavaScript에서 읽은 뒤 String(id)를 호출하면 9007199254740992가 될 수 있다. 문자열로 바꾸는 순간에 잘린 것이 아니다. JSON 숫자를 Number로 파싱할 때 이미 다른 값으로 반올림된 것이다. 긴 십진 ID를 그대로 구별해야 한다면 응답을 만드는 쪽에서부터 JSON 문자열로 전달한다.

자기 API를 확인할 때는 개발자 도구 Network에서 응답 원문을 보고, 파싱한 객체의 id와 비교한다. 원문부터 숫자가 다르면 서버의 조회·변환·직렬화 과정을 살펴본다. 원문에는 정확한 숫자가 있는데 파싱한 값이 다르다면 아래 비교로 Number 정밀도 문제인지 확인할 수 있다. 화면에 출력된 객체만 보면 원문과 파싱 결과를 혼동하기 쉽다.

두 ID가 같아지는 시점은 파싱이다

다음은 실제 API 대신 합성 ID 두 개를 읽는 예제다. Node.js가 설치된 환경에서 node --version을 확인하고, 아래 코드를 새 파일 id-check.cjs에 저장한 뒤 그 폴더에서 node id-check.cjs로 실행한다. 별도 패키지는 필요 없다. 서로 다른 JSON 숫자를 읽은 직후 같은 값이 되는지, 다시 내보내도 바뀐 값이 남는지 확인한다.

const first = JSON.parse('{"id":9007199254740992}');
const second = JSON.parse('{"id":9007199254740993}');

console.log(first.id === second.id);
console.log(String(second.id));
console.log(JSON.stringify(second));
console.log(Number.isInteger(second.id), Number.isSafeInteger(second.id));

Windows의 Node.js v22.23.2에서 실행한 출력이다.

true
9007199254740992
{"id":9007199254740992}
true false

첫 줄에서 두 ID가 이미 같아졌다. String()을 호출하기 전의 비교이므로 출력 서식 때문에 생긴 차이가 아니다. JSON.stringify()로 다시 보내도 원래의 끝자리 3은 돌아오지 않는다. 마지막 줄은 정수 판정만으로는 이 문제를 잡을 수 없다는 뜻이다. 값에 소수 부분이 없더라도 다른 정수와 구별되는 안전한 값인지는 별개다. 예제가 다르게 나오면 입력 문자열과 실행한 Node 버전부터 대조한다.

ECMAScript의 JSON.parse 정의 (새 탭에서 열림)에 따르면 JSON 숫자는 JavaScript의 Number가 된다. Number는 IEEE 754 binary64 형식 (새 탭에서 열림)을 사용하며, 모든 크기의 정수를 한 칸씩 구별할 수 있지는 않다. 안전 정수의 양수 상한인 Number.MAX_SAFE_INTEGER는 9007199254740991, 즉 2의 53제곱에서 1을 뺀 값이다. MAX_SAFE_INTEGER와 안전 정수 규칙 (새 탭에서 열림)에서도 위의 두 숫자가 같은 Number가 되는 예를 확인할 수 있다.

별도 경계 비교에서 9007199254740991은 정확히 보존됐고 안전 정수 판정도 true였다. 바로 다음 값인 9007199254740992도 정확히 표현되지만 안전 정수 판정은 false였다. 그 이웃인 9007199254740993과 충돌하기 때문이다. 따라서 상한을 넘으면 모든 값이 틀린다고 읽어서도, 한 값이 맞게 보이니 ID 전체를 숫자로 받아도 된다고 판단해서도 안 된다.

응답을 만들 때부터 ID를 문자열로 보낸다

같은 파일 끝에 아래 코드를 붙이고 다시 실행한다. 앞의 숫자는 그대로 두고 JSON 값에 따옴표만 추가해, ID 두 개가 끝까지 구별되는지 비교한다.

const firstText = JSON.parse('{"id":"9007199254740992"}');
const secondText = JSON.parse('{"id":"9007199254740993"}');

console.log(firstText.id === secondText.id);
console.log(secondText.id);
console.log(JSON.stringify(secondText));

앞 출력 뒤에 다음 세 줄이 추가됐다.

false
9007199254740993
{"id":"9007199254740993"}

이번에는 두 ID가 다르고, 읽은 값과 다시 직렬화한 값 모두 끝자리 3을 유지한다. ID를 조회 키나 URL에 넣는 용도로 쓴다면 이 문자열을 그대로 유지한다. 중간에서 Number(id)나 단항 +로 바꾸면 다시 같은 정밀도 제한을 받는다.

바꿀 곳은 프런트엔드의 마지막 출력 코드만이 아니다. 응답 DTO·스키마에서 ID를 문자열로 정하고, 서버가 가진 정확한 정수나 원본 문자열을 그 필드로 내보내야 한다. DB 드라이버나 변환 코드가 먼저 부정확한 Number를 만들었다면, 서버에서 뒤늦게 따옴표를 붙여도 잘못된 숫자가 문자열로 굳는다. 문자열로 바꾸기 전까지 원본의 정확성이 유지되는지 확인해야 한다.

수정 후에는 응답 원문이 {"id":"9007199254740993"}인지 확인하고, 수신한 필드의 typeof가 string인지와 원래 ID가 일치하는지를 본다. 문자열 응답인데도 다르다면 요청 라이브러리의 변환이나 화면 상태에 넣는 과정에서 다시 숫자로 바꾸는지 조사한다. 작은 ID 한 개만 보지 말고 위처럼 서로 이웃한 큰 ID도 함께 비교한다.

BigInt로 바꿀 때도 원본 문자열이 필요하다

정수 계산이 필요해 BigInt를 쓰더라도 입력 경로가 중요하다. 같은 파일에 다음 코드를 더하면, 이미 파싱한 Number를 변환한 결과와 정확한 문자열을 변환한 결과를 비교할 수 있다. 이어서 BigInt를 JSON으로 보낼 때 필요한 변환도 확인한다.

const late = BigInt(second.id);
const exact = BigInt(secondText.id);
console.log(late.toString());
console.log(exact.toString());
console.log(late === exact);

try {
  JSON.stringify({ id: exact });
} catch (error) {
  console.log(error.name);
}
console.log(JSON.stringify({ id: exact.toString() }));

추가된 출력은 다음과 같다.

9007199254740992
9007199254740993
false
TypeError
{"id":"9007199254740993"}

BigInt(second.id)는 Number에 남은 값인 9007199254740992를 정수로 옮겼다. 원본 숫자를 추측해 복구하지 않는다. 반면 BigInt(secondText.id)는 정확한 십진 문자열에서 출발하므로 9007199254740993을 유지한다. 이 차이는 BigInt의 Number 입력 변환 (새 탭에서 열림)과 문자열 입력 변환 (새 탭에서 열림) 규칙에 대응한다.

BigInt를 얻은 뒤에도 기본 JSON.stringify()에 바로 넣을 수는 없다. 위 예제처럼 별도 replacer나 toJSON 처리가 없는 상태에서는 TypeError가 발생한다. JSON 직렬화 규칙 (새 탭에서 열림)은 변환 뒤에도 BigInt로 남은 값을 거부한다. ID를 문자열로 주고받기로 했다면 마지막 줄처럼 해당 필드를 십진 문자열로 바꿔 보낸다. 이 결과는 JSON 숫자가 아니라 따옴표가 있는 JSON 문자열이다.

JSON.parse의 reviver에서 받은 value만 String(value)나 BigInt(value)로 바꾸는 방법도 별도로 실행했다. 두 경우 모두 이미 반올림된 9007199254740992가 남았다. 다만 현재 명세에는 원시값의 원문을 제공하는 세 번째 인자 context.source가 있다. reviver의 원문 전달 규칙 (새 탭에서 열림)에 따라, 이를 지원하는 환경에서는 이 예제의 ID 토큰을 BigInt(context.source)로 읽을 수 있다. 이번 Node에서도 원문 9007199254740993과 정확한 BigInt를 얻었다. 이는 보관된 JSON 원문을 사용하는 경로다. 원문 없이 반올림된 Number만 남은 상태에서는 원래 ID를 되찾을 수 없다. 서버를 바꿀 수 없어 이 경로를 검토한다면 대상 런타임의 지원 여부와 해당 필드가 BigInt로 읽을 수 있는 정수 토큰인지부터 확인한다.

직접 실행한 범위는 Node.js v22.23.2에서 합성한 십진 정수 ID의 파싱·비교·재직렬화와 BigInt 변환이다. 실제 API, DB 드라이버와 브라우저에서는 실행하지 않았다. 식별자를 숫자로 계산할 필요가 없다면, 정확한 원본에서 시작한 문자열을 생산 단계부터 수신·재전송까지 유지하는 계약으로 해결한다.

← 목록으로 돌아가기