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

빌드노트

BigInt가 든 객체를 JSON으로 보내려니 TypeError가 난다

BigInt가 든 객체의 JSON.stringify 오류를 재현하고, ID만 십진 문자열로 변환해 정확한 값을 보존한다. 명시 필드 구성과 제한된 replacer를 비교하며, 파싱 후 원래 타입은 필드 계약 없이 자동 복원할 수 없음을 확인한다.

큰 정수 ID를 BigInt로 보관했는데 JSON.stringify(order)에서 TypeError가 난다면, 요청 본문에 넣을 타입부터 정해야 한다. 기본 JSON 직렬화는 BigInt를 그대로 처리하지 못한다. 정확한 ID가 필요하다면 Number로 바꾸어 오류만 없애지 말고, 해당 ID 필드를 십진 문자열로 보내도록 API 계약을 맞춘다.

먼저 요청 본문을 만드는 JSON.stringify 호출에서 어떤 필드에 BigInt가 들어가는지 확인한다. 아래는 실제 요청을 보내지 않고 합성 주문 ID 하나로 비교하는 예제다. Node.js가 설치된 환경에서 node --version을 확인하고, 새 파일 bigint-json.cjs에 코드를 저장한 뒤 그 폴더에서 node bigint-json.cjs로 실행한다. 추가 패키지는 필요 없다. 기본 직렬화 오류와 Number로 바꾼 결과를 함께 확인한다.

const order = {
  orderId: 9007199254741017n,
  memo: '9007199254741017',
  quantity: 2,
};

try {
  JSON.stringify(order);
} catch (error) {
  console.log(error.name + ': ' + error.message);
}
console.log(JSON.stringify({ ...order, orderId: Number(order.orderId) }));

Windows의 Node.js v22.23.2에서 다음 두 줄이 출력됐다.

TypeError: Do not know how to serialize a BigInt
{"orderId":9007199254741016,"memo":"9007199254741017","quantity":2}

첫 줄은 JSON 문자열을 만들지 못한 결과다. MDN의 BigInt 직렬화 오류 설명 (새 탭에서 열림)에서도 기본 직렬화의 실패와 엔진별 오류 문구를 확인할 수 있다. 다른 엔진에서는 메시지가 다를 수 있다. 별도 실행에서 작은 값 1n도 같은 오류를 냈으므로, BigInt 값이 너무 커서 생기는 오류로 해석해서는 안 된다.

둘째 줄에서는 오류가 없어졌지만 ID 끝자리가 1017에서 1016으로 바뀌었다. 이 입력은 Number로 변환할 때 정확히 표현되지 않는다. 큰 BigInt를 Number로 바꾸면 정밀도를 잃을 수 있다는 MDN의 설명 (새 탭에서 열림)과 일치하는 관찰이다. 예제에서 오류가 나지 않는다면 기존 toJSON이나 replacer가 이미 변환하는지 확인한다. 오류 문구가 다르면 BigInt 외의 직렬화 실패도 따로 살펴본다.

orderId만 십진 문자열로 내보낸다

이 주문의 전송 계약을 orderId는 십진 문자열, memo는 원래 문자열, quantity는 작은 정수로 정하자. 예제의 ID는 음수가 아닌 BigInt다. API 스키마와 수신 코드도 문자열 ID를 받아야 한다. 서버가 숫자만 허용한다면 클라이언트에서 따옴표를 붙이는 것만으로 계약이 맞아지는 것은 아니다.

앞 파일 끝에 다음 코드를 붙이고 같은 명령으로 다시 실행한다. 전송할 필드를 직접 구성해 ID만 바꾸고, 만들어진 JSON을 읽은 뒤 값과 타입이 남는지 확인한다.

const body = JSON.stringify({
  orderId: order.orderId.toString(10),
  memo: order.memo,
  quantity: order.quantity,
});
const received = JSON.parse(body);

console.log(body);
console.log(typeof received.orderId, received.orderId === order.orderId.toString(10));
console.log(typeof received.memo, received.memo === order.memo);

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

{"orderId":"9007199254741017","memo":"9007199254741017","quantity":2}
string true
string true

이번에는 ID 값에 따옴표가 있고 끝자리 1017이 유지된다. JSON.parse 후에도 ID와 메모는 각각 정확한 문자열이다. BigInt의 toString (새 탭에서 열림)에 진수 10을 주면 십진 문자열이 나오며, BigInt 리터럴 뒤의 n은 붙지 않는다. 이 코드는 별도의 전송 객체를 만들므로 원래 order.orderId는 BigInt로 남는다.

수정할 위치는 요청 본문을 만드는 경계다. 실제 적용 후에는 전송 직전 문자열과 개발자 도구 Network의 요청 본문에서 ID가 문자열인지, 원본 십진 값과 같은지 확인한다. 그다음 수신 코드나 DTO 바인딩에서도 문자열로 유지되는지 본다. 이 예제는 이미 정확한 BigInt에서 출발한다. 앞 단계에서 부정확한 Number로 만든 ID라면 문자열 변환으로 잃은 자릿수를 복구할 수 없다.

replacer는 필드 이름뿐 아니라 변환할 객체도 제한한다

객체를 그대로 넘기면서 특정 필드만 바꾸려면 JSON.stringify의 두 번째 인자에 replacer 함수를 둘 수 있다. ECMAScript의 직렬화 절차 (새 탭에서 열림)는 replacer의 반환값을 처리한 뒤, 여전히 BigInt이면 TypeError를 던진다. 따라서 오류가 나기 전에 합의한 문자열로 바꿀 수 있다.

다음 코드를 같은 파일 끝에 붙여 실행하면, 이 주문의 orderId만 변환한 결과와 계약 밖의 중첩 BigInt가 추가된 결과를 비교할 수 있다.

function orderReplacer(key, value) {
  if (this === order && key === 'orderId' && typeof value === 'bigint') {
    return value.toString(10);
  }
  return value;
}

const replaced = JSON.stringify(order, orderReplacer);
console.log(replaced === body);

order.audit = { orderId: 7n };
try {
  JSON.stringify(order, orderReplacer);
} catch (error) {
  console.log(error.name);
}

추가 출력은 true, TypeError였다. 처음 결과는 필드를 직접 구성한 body와 같다. 중첩된 audit.orderId를 추가하면 오류가 난다. 해당 값은 합의한 변환 대상에서 제외했기 때문이다.

위 명세에서 replacer를 호출할 때 this는 현재 속성을 담고 있는 객체다. 여기서는 일반 함수의 this === order로 대상 객체를, key === 'orderId'로 필드를 제한했다. 화살표 함수는 이 방식으로 this를 받지 못하므로 같은 조건으로 바꾸어 쓰지 않는다. 키 이름만 검사하면 중첩된 audit.orderId도 문자열로 바뀐다. 별도 비교에서도 그렇게 변환된 값 '7'을 확인했다.

이 replacer는 이 order 객체를 위한 코드이며 전체 요청 스키마를 검사하는 도구는 아니다. 다른 요청에 재사용하려면 그 요청의 대상과 계약을 정해야 한다. 모든 BigInt를 문자열로 바꾸는 공통 규칙은 계약 밖 필드까지 조용히 통과시킬 수 있다. 전송 필드가 정해져 있다면 앞의 명시적 객체 구성이 변환 범위를 읽기 쉽다. 여기서는 전역 BigInt.prototype을 변경하지 않았다.

다시 BigInt로 만들지는 수신 계약이 정한다

파싱한 received.orderId와 received.memo는 모두 '9007199254741017'이다. JSON에는 하나가 BigInt에서 왔다는 표시가 없다. 처음부터 ID도 문자열인 객체를 직렬화해도 정확히 같은 JSON을 만들 수 있었다. 십진 값을 보존한 것과 원래 JavaScript 타입을 복원한 것은 다르다.

숫자 모양 문자열을 모두 BigInt로 바꾸는 reviver도 별도로 실행했다. ID뿐 아니라 원래 문자열인 메모까지 BigInt로 바뀌었다. MDN의 BigInt와 JSON 설명 (새 탭에서 열림)도 구조를 아는 경우 문자열로 보내고 필드에 맞춰 복원하는 방법을 설명한다. 값의 모양만 보고 원래 타입을 추측하지 않는다.

수신 후 ID로 계산해야 한다면 합의한 필드만 검증해 변환한다. 다음은 orderId를 부호와 불필요한 앞자리 0이 없는 십진 문자열로 받기로 한 경우다. 앞 파일 끝에 붙여 실행하면 ID만 BigInt로 만들고 메모는 문자열로 유지하는지 확인할 수 있다.

if (
  typeof received.orderId !== 'string' ||
  !/^(0|[1-9][0-9]*)$/.test(received.orderId)
) {
  throw new TypeError('orderId must be an unsigned decimal string');
}
const restoredId = BigInt(received.orderId);
console.log(typeof restoredId, restoredId === order.orderId);
console.log(typeof received.memo, received.memo);

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

bigint true
string 9007199254741017

이 검사는 이 예제에서 정한 표현 규칙이다. 실제 API의 길이·범위·필수 여부 조건은 별도로 적용해야 한다. 조회 키나 표시 용도로만 쓴다면 수신한 ID를 문자열로 유지해도 된다. 서버가 다시 BigInt로 계산한다는 이유로 전송 문자열 자체에 BigInt 타입이 보존됐다고 보지는 않는다.

직접 실행한 범위는 Node.js v22.23.2의 합성 객체에서 기본 오류, Number 변환, 명시 필드 변환, 제한된 replacer와 파싱 후 복원 비교다. 사용자 정의 toJSON이 없는 상태에서 실행했다. 브라우저, 요청 라이브러리, 실제 서버의 DTO 바인딩과 외부 전송은 실행하지 않았다. 실제 적용에서는 같은 십진 문자열이 요청 본문과 수신 필드까지 유지되는지 확인해야 한다.

← 전체 글 보기