DEV BOX

'26/10/07 UPDATE

WEB / JAVASCRIPT / FIELD NOTES

API에서 받은 주문 번호의 끝자리가 왜 바뀔까

서버는 9007199254740993을 보냈는데 res.json()으로 받은 값은 9007199254740992입니다. JavaScript 숫자는 2^53-1을 넘는 정수를 정확히 담지 못하기 때문입니다. 127.0.0.1의 가짜 주문 API로 다른 주문이 취소되는 과정을 실제로 재현하고, ID를 문자열로 주고받는 방법과 JSON.parse의 context.source·JSON.rawJSON으로 원래 숫자를 지키는 방법을 Node.js v24.21.0 실행 결과로 정리합니다.

서버 로그에는 주문 번호가 9007199254740993으로 찍혀 있는데, 화면에서 res.json()으로 받은 값은 9007199254740992입니다. 끝자리 하나가 바뀌었고, 그 값으로 취소 요청을 보내면 다른 주문이 취소될 수도 있습니다. 이 글을 읽고 나면 어떤 숫자부터 이런 일이 생기는지 직접 확인할 수 있고, 큰 ID를 문자열로 주고받거나 JSON.parse의 원문 접근으로 정확한 값을 지키는 방법 중 하나를 고를 수 있습니다.

예제는 2026-10-07 macOS 27.0.1에서 Node.js v24.21.0(V8 13.6)으로 실행했습니다. 서버는 같은 컴퓨터(127.0.0.1)에 띄운 작은 Python 3.9.6 가짜 주문 API이고, 주문 A·B와 번호는 모두 설명을 위해 만든 것입니다.

JSON에는 숫자 크기 제한이 없지만 JavaScript 숫자에는 있다

JSON 문법 자체는 숫자의 자릿수를 제한하지 않습니다. 대신 JSON 표준(RFC 8259)은 숫자를 받는 쪽이 범위와 정밀도에 한계를 둘 수 있다고 하고, 널리 쓰이는 IEEE 754 배정밀도(binary64)를 쓰는 구현끼리는 -(253-1)부터 253-1까지의 정수만 값이 정확히 일치한다고 적어 둡니다.

JavaScript의 number가 바로 이 binary64입니다. 그래서 JSON.parse와 res.json()은 JSON 텍스트의 숫자를 binary64로 바꾸는 순간, 표현할 수 없는 정수를 가장 가까운 표현 가능한 값으로 반올림합니다. 경계는 Number.MAX_SAFE_INTEGER, 즉 9007199254740991입니다. 이보다 큰 253부터 254까지는 표현할 수 있는 정수가 2 간격으로만 있고, 그 위로는 4, 8 간격으로 더 벌어집니다.

개발 노트

2^53을 넘으면 정수 사이에 빈자리가 생긴다

JSON.parse가 돌려준 값 · Node.js v24.21.0

  • …991까지 · 그대로

    9007199254740991 → …991

    • Number.MAX_SAFE_INTEGER = 2^53-1
    • isSafeInteger → true
  • 2^53~2^54 · 2 간격

    …993 → …992 · …995 → …996

    • 홀수는 표현할 수 없음
    • 가운데 값은 짝수 쪽으로 반올림
  • 19자리 · 더 넓은 간격

    1234567890123456789 → …800

    • 끝자리 여러 개가 바뀜
    • 서로 다른 ID가 같은 값이 됨

값은 2026-10-07 parse.mjs 실행 출력 그대로입니다. RFC 8259는 ±(2^53-1) 안의 정수만 binary64 구현끼리 정확히 일치한다고 적습니다.

JavaScript number는 2^53-1까지만 모든 정수를 정확히 담습니다. 그 위에서는 JSON 텍스트의 숫자가 가장 가까운 표현 가능한 값으로 바뀝니다.

parse.mjs로 경계 근처 숫자를 하나씩 넣어 보았습니다. 9007199254740991까지는 그대로였고 Number.isSafeInteger도 true였습니다. 9007199254740993은 9007199254740992가 되었고, 9007199254740995는 9007199254740996이 되었습니다. 두 수 모두 표현 가능한 두 값의 정확히 가운데에 있어서, 짝수 쪽으로 반올림하는 규칙에 따라 각각 아래와 위로 갔습니다. 19자리 1234567890123456789는 1234567890123456800으로 출력되었습니다. 그리고 JSON.parse("9007199254740992") === JSON.parse("9007199254740993")은 true였습니다. 서로 다른 두 ID가 같은 값이 된다는 뜻입니다.

오류도 경고도 없이 다른 주문이 취소된다

가짜 API는 주문 A(9007199254740992)와 주문 B(9007199254740993)를 가지고 있습니다. GET /orders/latest는 주문 B를 숫자 id와 문자열 idText로 함께 보내고, POST /cancel은 받은 id로 주문을 찾아 로그에 남깁니다. Python의 json은 큰 정수도 그대로 읽기 때문에 서버 쪽에서는 값이 바뀌지 않습니다.

클라이언트는 흔히 쓰는 방식 그대로 await res.json()으로 받은 id를 JSON.stringify({ id })로 돌려보냈습니다. 받은 id는 9007199254740992였고, 서버 로그에는 {"id":9007199254740992}를 받아 주문 A를 찾았다고 남았습니다. 사용자는 B를 취소하려 했는데 A가 처리된 것입니다. 예외도, 콘솔 경고도 없었습니다.

실행 결과. parse.mjs: Number.MAX_SAFE_INTEGER 9007199254740991, JSON.parse("9007199254740991")은 그대로이고 safe=true, "9007199254740993"은 9007199254740992이고 safe=false, "9007199254740995"는 9007199254740996, 9007199254740992 === ...993은 true. client.mjs: res.json()의 id는 9007199254740992, idText는 9007199254740993, 보낸 {"id":9007199254740992}로 order A. server.log: 9007199254740993을 보냈고 9007199254740992를 받아 order A를 찾음.
경계 근처 숫자를 JSON.parse에 넣은 결과와, res.json()으로 받은 번호를 그대로 돌려보낸 클라이언트·서버 로그입니다. 값은 그대로 두고 일부 줄만 골라 옮겼습니다.

이 문제가 늦게 드러나는 이유도 여기서 보입니다. 개발 데이터의 ID가 1, 2, 3처럼 작을 때는 아무 일도 없습니다. 운영 데이터에서 시간·서버 번호를 섞어 만든 64비트 정수 ID처럼 253을 넘는 값이 나오기 시작해야 증상이 생기고, 그마저 값의 일부만 바뀌기 때문에 "가끔 엉뚱한 항목이 열린다"는 식으로 보고됩니다.

개발 노트

주문 B를 취소했는데 A가 처리되기까지

127.0.0.1의 가짜 주문 API · 주문과 번호는 가짜

  1. 서버가 보냄

    {"id": 9007199254740993}

    • Python json은 큰 정수도 그대로
    • 주문 B의 번호
  2. res.json()

    id = 9007199254740992

    • binary64로 바꾸며 반올림
    • 오류·경고 없음
  3. JSON.stringify

    {"id":9007199254740992}

    • 바뀐 값을 그대로 보냄
  4. 서버가 찾은 주문

    -> order A (made up)

    • B가 아니라 A

2026-10-07 Node.js v24.21.0 클라이언트와 Python 3.9.6 서버의 실행 로그 그대로입니다.

서버는 정확한 번호를 보냈고 받은 번호도 정확히 읽었습니다. 값은 클라이언트가 JSON을 number로 바꾸는 순간 바뀌었습니다.

고치는 방법 1: ID는 문자열로 주고받기

가장 단순하고 어디서나 통하는 방법은 서버가 큰 ID를 JSON 문자열로 보내는 것입니다. "id": "9007199254740993"이면 클라이언트는 숫자로 바꾸지 않고 그대로 보관했다가 그대로 돌려보냅니다. 실행에서도 idText를 돌려보내자 서버는 {"id":"9007199254740993"}을 받아 주문 B를 찾았습니다.

ID는 더하거나 곱할 일이 없는 식별자이므로 문자열이어도 잃는 것이 없습니다. API를 바꿀 수 있다면 이 방법을 먼저 권합니다. 이미 숫자로 보내는 API라면 문자열 필드를 하나 더 추가하고, 클라이언트를 그 필드로 옮긴 뒤 숫자 필드를 정리하는 순서가 안전합니다.

고치는 방법 2: 원문을 읽어 BigInt로 받고 그대로 돌려보내기

API를 바꿀 수 없다면 클라이언트에서 원문 숫자를 붙잡아야 합니다. 여기서 흔한 함정이 하나 있습니다. JSON.parse의 두 번째 인자인 reviver는 숫자가 이미 변환된 뒤에 불립니다. 그래서 reviver 안에서 BigInt(value)를 해도 이미 바뀐 9007199254740992가 BigInt가 될 뿐입니다.

새 JavaScript에는 reviver의 세 번째 인자 context가 있습니다. 문자열·숫자 같은 원시 값일 때 context.source에 JSON 텍스트의 원래 글자가 들어 있습니다. 실행에서는 res.json() 대신 res.text()로 받은 뒤 다음처럼 읽었습니다.

const data = JSON.parse(text, (key, value, context) =>
  typeof value === "number" && !Number.isSafeInteger(value)
    ? BigInt(context.source)
    : value);

결과는 9007199254740993n, 타입은 bigint였습니다. 그런데 이 값을 그대로 JSON.stringify에 넣으면 TypeError: Do not know how to serialize a BigInt가 납니다. 보낼 때는 JSON.rawJSON으로 "이미 JSON인 글자"를 넣습니다.

const body = JSON.stringify(data, (key, value) =>
  typeof value === "bigint" ? JSON.rawJSON(value.toString()) : value);

이렇게 보낸 {"id":9007199254740993}로 서버는 주문 B를 찾았습니다. 숫자 형식을 그대로 유지해야 하는 API에 맞는 방법입니다.

실행 결과. 2 idText 문자열로 {"id":"9007199254740993"}을 보내 order B. 3 reviver로 받은 id는 9007199254740993n, 타입 bigint. JSON.stringify에 BigInt를 넣으면 TypeError: Do not know how to serialize a BigInt. JSON.rawJSON으로 {"id":9007199254740993}을 보내 order B. server.log도 두 요청 모두 order B를 찾았다고 기록.
같은 실행에서 두 가지 고친 방법을 시험한 출력입니다. 첫 번째 시도 줄은 앞 그림에 있어 뺐습니다.

지원 범위는 확인하고 써야 합니다. 이 기능은 TC39의 "JSON.parse source text access" 제안으로 들어왔고, 이번 실행의 Node.js v24.21.0에서는 context.source와 JSON.rawJSON이 모두 동작했습니다. MDN은 JSON.rawJSON을 2025년부터 주요 브라우저 최신 버전에서 쓸 수 있는 기능(Baseline 2025)으로 표시합니다. 오래된 브라우저나 런타임을 지원해야 한다면 방법 1이 더 안전합니다. 브라우저에서는 이번에 실행하지 않았습니다.

어느 방법이든 경계 검사는 남겨 두기

당장 고칠 수 없다면 적어도 조용히 틀리지 않게 만들 수 있습니다. 숫자로 받은 ID에 Number.isSafeInteger(id)를 검사해 false면 요청을 보내지 말고 오류로 기록하는 것입니다. Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2가 true가 되는 영역에서는 비교도 믿을 수 없기 때문입니다.

개발 노트

큰 ID를 지키는 세 가지 방법

실행에서 서버가 찾은 주문과 함께

  • 1 · 문자열로 주고받기

    {"id":"9007199254740993"} -> B

    • API를 바꿀 수 있을 때 먼저
    • 어느 런타임에서나 동작
  • 2 · context.source + rawJSON

    {"id":9007199254740993} -> B

    • 숫자 형식을 지켜야 할 때
    • res.text()로 받아 직접 파싱
  • 3 · 경계 검사

    Number.isSafeInteger(id)

    • 고치기 전까지의 안전장치
    • false면 보내지 말고 기록

1·2의 결과는 2026-10-07 Node.js v24.21.0 실행 로그입니다. 3은 실행 예제가 아니라 권장 검사입니다. BigInt는 JSON.stringify에 그대로 넣으면 TypeError가 납니다.

API를 바꿀 수 있으면 문자열로, 바꿀 수 없으면 원문 숫자를 BigInt로 받아 JSON.rawJSON으로 돌려보냅니다.

정리하면, ID 끝자리가 바뀌는 것은 네트워크나 서버의 문제가 아니라 JSON 숫자를 JavaScript number로 바꾸는 순간의 반올림입니다. 지금 쓰는 API 응답에서 253-1을 넘을 수 있는 숫자 필드를 찾아 보고, 바꿀 수 있으면 문자열로, 바꿀 수 없으면 context.source와 JSON.rawJSON으로 원문을 지키면 됩니다.

끝 · END OF NOTE목록으로
COMMENTS BOX

이 기록에 대화를 더해 주세요.

궁금한 점, 다른 접근, 직접 해 본 결과를 나눠 주세요.

최신순

로그인 상태 확인 중…

댓글을 불러오는 중…