[개발 깨알 상식_Tips] / 16자리 ID가 바뀌는 JSON 파싱 오류.md

16자리 ID가 바뀌는 JSON 파싱 오류

조회

2026년 7월 10일 | 개발 깨알 상식_Tips


API 응답에는 주문 ID가 9007199254740993으로 들어 있었는데, 브라우저 콘솔에서는 끝자리가 2로 바뀌어 있었다. 백엔드 로그도 맞고 JSON 문법도 멀쩡했다. 나는 처음에는 화면 포맷터나 복사 과정부터 의심했지만, 실제로 값이 달라진 지점은 JSON 숫자를 JavaScript의 Number로 읽는 순간이었다. 계산값이 아닌 식별자를 숫자로 내려보낸 타입 계약이 원인이었다.

긴 정수 ID가 JSON 파싱 과정에서 JavaScript 안전 정수 범위를 넘어 값이 바뀌는 경로

Figure 1. API 원문은 맞아도 Number 변환 뒤에는 다른 ID가 될 수 있다.

그림의 앞쪽 두 숫자는 눈으로 보면 한 자리만 다르지만, 시스템에서는 서로 다른 레코드를 가리키는 키다. 파서가 둘을 같은 Number 표현으로 접으면 조회 실패만 나는 것이 아니라 Map 키 충돌, 캐시 오염, 잘못된 상세 페이지 이동까지 이어진다. 그래서 이 문제는 출력 포맷 문제가 아니라 식별자 보존 문제로 봐야 한다.

어디서 값이 달라졌는지 먼저 자르기

JavaScript의 Number는 IEEE 754 배정밀도 부동소수점 형식이다. 정수를 정확히 표현할 수 있는 범위는 -(2^53-1)부터 2^53-1까지다. 양수 기준 최대 안전 정수는 9007199254740991이다. 이보다 큰 정수는 모두 틀리는 것은 아니지만, 이웃한 정수 하나하나를 빠짐없이 구분하지 못한다.

const raw = '{"order_id":9007199254740993}';
const row = JSON.parse(raw);

console.log(row.order_id);                  // 9007199254740992
console.log(Number.isSafeInteger(row.order_id)); // false

여기서 중요한 점은 네트워크 탭의 원문과 파싱 뒤 값을 따로 보는 것이다. 콘솔에 객체만 찍으면 이미 파싱된 값만 보게 된다. 나는 재현할 때 응답 원문 문자열, 파싱 직후 타입과 값, 안전 정수 판정을 한 줄씩 나눠 기록한다. 세 줄만 있어도 서버가 잘못 보냈는지, 클라이언트가 읽으며 바꿨는지 경계가 바로 드러난다.

reviver와 BigInt를 늦게 붙여도 복구되지 않는다

흔한 우회는 JSON.parse의 reviver에서 ID를 BigInt로 바꾸는 것이다. 하지만 reviver가 값을 받는 시점에는 숫자 토큰이 이미 Number로 변환된 뒤다. BigInt(row.order_id)를 호출해도 바뀐 값 9007199254740992를 큰 정수로 포장할 뿐, 사라진 원래 끝자리를 되살리지는 못한다.

응답 원문을 정규식으로 고치는 방식도 급한 진단에는 쓸 수 있지만, 문자열 안 숫자나 소수, 음수, 중첩 구조를 건드릴 위험이 있다. 범용 파서를 흉내 내기 시작하면 수정 반경이 금방 커진다. 정확한 큰 정수 파서가 꼭 필요한 계약이 아니라면, 생산자가 식별자를 문자열로 보내는 쪽이 훨씬 단순하다.

고치는 위치는 API 직렬화 경계

안전한 응답은 {"order_id":"9007199254740993"}처럼 ID를 문자열로 보낸다. TypeScript 타입도 orderId: string으로 맞추고, URL·Map key·DB 조회 파라미터에서도 문자열을 유지한다. 숫자처럼 생겼다는 이유로 중간에 Number()를 거치지 않는 것이 핵심이다.

서버 내부에서 BigInt를 쓰는 경우에도 JSON 경계는 따로 봐야 한다. JavaScript의 JSON.stringify(1n)은 그대로는 TypeError를 낸다. 특정 필드를 명시적으로 문자열로 바꾸거나, 스키마에 맞춘 serializer를 두는 편이 낫다. 모든 BigInt를 무조건 문자열로 바꾸는 전역 replacer는 편하지만, 계산값과 식별자의 의미 차이를 감출 수 있다.

회귀 테스트는 서로 붙은 두 ID로 만든다

테스트 샘플 하나만 넣으면 운 좋게 표현 가능한 큰 정수가 통과할 수 있다. 나는 경계값 근처와 실제 운영 ID를 섞고, 특히 90071992547409929007199254740993처럼 서로 붙은 값을 한 쌍으로 둔다. 두 값이 파싱 뒤에도 구분되고, 직렬화 왕복 뒤 원문과 같으며, 객체 키나 Map 키에서도 별개로 남는지 확인한다.

  • 응답 JSON에서 ID 토큰이 따옴표로 감싸졌는가
  • 파싱 뒤 타입이 string인가
  • 직렬화 후에도 자리수와 앞자리 0이 보존되는가
  • 인접한 두 ID가 같은 키로 합쳐지지 않는가

이 검사는 단위 테스트만으로 끝내기보다 API schema와 생성된 클라이언트 타입까지 같이 보는 편이 좋다. OpenAPI에서 integer로 선언해 두고 프런트 코드만 string으로 고치면, 다음 SDK 생성 때 숫자 타입이 다시 들어올 수 있다.

DB 드라이버와 ORM도 같은 경계에 있다. PostgreSQL의 bigint를 안전 때문에 문자열로 돌려주는 드라이버가 있는데, 편의를 위해 공통 변환기에서 Number로 바꾸면 API 직전까지 보존한 정밀도를 다시 잃는다. 반대로 DB에는 문자열로 들어 있어도 response DTO가 숫자 타입이면 직렬화 단계에서 문제가 재발한다. 저장소, 도메인 모델, 응답 schema 중 한 군데만 고치는 것으로는 부족하다.

관측 로그에는 원문 전체를 매번 남길 필요는 없지만, 경계별 타입은 남길 만하다. 예를 들어 source_type=string, parsed_type=string, id_length=16 정도만 있어도 개인정보를 과하게 노출하지 않으면서 계약 회귀를 찾을 수 있다. ID 본문을 마스킹하더라도 자리수와 타입이 유지되는지 확인하면 된다. 원문 길이와 파싱 뒤 길이가 다르면 조회 로직보다 앞단의 변환기부터 확인한다.

식별자와 수량을 같은 숫자로 취급하지 않기

가격, 개수, 비율처럼 계산할 값은 숫자가 맞다. 반면 주문 ID, Snowflake ID, 계정 번호, 긴 해시의 숫자 표현은 크기 비교나 덧셈이 목적이 아니다. 값의 표기 자체가 정체성인 필드는 문자열로 두는 편이 언어와 저장소 경계를 넘을 때 덜 흔들린다. CSV에서 우편번호나 고객 코드를 문자열 dtype으로 고정하는 이유와도 같다.

비슷한 장애가 다시 보이면 나는 화면에 찍힌 값부터 고치지 않는다. 원문 응답에서 정확한 숫자를 확인하고, 첫 파싱 지점에서 타입을 확인한 다음, serializer와 schema를 거슬러 올라간다. 이 순서를 지키면 “끝자리 하나가 가끔 바뀐다”는 애매한 증상이 어느 경계에서 식별자 정밀도를 잃었는가라는 재현 가능한 문제로 바뀐다.

댓글

홈으로 돌아가기

검색 결과

"" 검색 결과입니다.