JAVASCRIPT / FIELD NOTES

보낸 JSON에서 필드가 왜 사라졌을까

JSON.stringify는 undefined 속성을 빼고, NaN을 null로, Map과 Set을 빈 객체로 바꾸면서도 오류를 내지 않습니다. Node 24로 로컬 서버에 보내 서버가 받은 본문을 확인하고, 그런 변화를 오류로 드러내는 함수를 만듭니다.

주문 화면에 메모 칸을 새로 붙였다고 해 봅시다. 사용자가 메모를 비워 두면 코드에서는 memo가 undefined인데, 서버 로그를 열어 보면 memo 키 자체가 없습니다. 오류는 어디에도 없었습니다. 서버 입장에서는 “메모를 비웠다”와 “메모 칸이 없는 옛 앱에서 보냈다”를 구분할 수 없습니다. 원인은 fetch가 아니라 본문을 만드는 JSON.stringify입니다. 이 글을 읽고 나면 어떤 값이 사라지고, 어떤 값이 null로 바뀌고, 어떤 값이 모양을 바꾸는지 나눠 볼 수 있고, 그런 변화를 조용히 넘기지 않고 오류로 드러내는 작은 함수를 직접 쓸 수 있습니다.

예제의 주문 1001, 상품 MUG-1, 메모와 할인 값은 모두 가상입니다. 2026년 9월 28일 이 컴퓨터에서 Node.js v24.21.0으로 실행하고, 만든 문자열을 내장 fetch로 로컬 서버(127.0.0.1:4320)에 POST해 서버가 받은 본문을 기록했습니다. 규칙 설명은 2026년 9월 20일 자 ECMAScript 명세 초안과 MDN을 기준으로 했습니다.

오류 없이 바뀌는 값이 세 종류 있습니다

실험에 쓴 주문 객체에는 JSON으로 옮기기 애매한 값을 일부러 모았습니다. memo는 undefined, discount는 NaN, limit은 Infinity, createdAt은 Date, tags는 Set, options는 Map, 저장 버튼용 함수 onSave, Symbol 키 하나, 그리고 배열 items 안에 undefined와 화살표 함수를 넣었습니다.

출력된 문자열에서 memo, onSave, Symbol 키는 흔적도 없이 빠졌습니다. 객체 속성의 값이 undefined이거나 함수이거나 Symbol이면 JSON.stringify는 그 속성을 통째로 건너뜁니다. 같은 값이 배열 안에 있으면 사정이 다릅니다. 배열은 칸 번호가 의미를 가지므로 빼지 않고 null로 채웁니다. items가 ["MUG-1",null,null]이 된 이유입니다.

NaN과 Infinity는 어디에 있든 null이 됩니다. JSON 문법(RFC 8259)에는 이런 수를 쓸 자리가 없기 때문입니다. Date는 자신의 toJSON이 돌려주는 ISO 문자열로 바뀌고, Set과 Map은 빈 객체 {}가 됩니다. JSON.stringify는 객체가 가진 열거 가능한 자기 속성만 보는데, Set과 Map의 내용은 그런 속성이 아니기 때문입니다.

오류로 멈춘 것은 두 경우뿐이었습니다. BigInt 값(12000n)은 TypeError “Do not know how to serialize a BigInt”, 자기 자신을 가리키는 객체는 TypeError “Converting circular structure to JSON”이었습니다. 문구는 Node가 쓰는 V8 엔진의 것이고, 다른 엔진에서는 달라질 수 있지만 TypeError라는 점은 명세에 정해져 있습니다.

Node.js v24.21.0 실행 결과. 직렬화된 문자열에는 memo가 없고 discount와 limit은 null, tags와 options는 빈 객체, items는 MUG-1, null, null이다. 되읽은 결과는 false string null. BigInt는 TypeError Do not know how to serialize a BigInt, 순환 구조는 TypeError Converting circular structure to JSON. 서버 로그의 POST 본문도 같은 문자열이다.
가상 주문 객체를 JSON.stringify로 바꿔 로컬 서버에 보낸 출력과 서버 로그입니다. 오류 없이 memo가 사라졌습니다.
JSON.stringify의 네 가지 처리. 객체 속성의 undefined·함수·Symbol 값과 Symbol 키는 사라진다. NaN·Infinity와 배열 안의 undefined·함수는 null이 된다. Date는 ISO 문자열, Map·Set은 빈 객체가 된다. BigInt와 순환 구조는 TypeError를 던진다.
객체 속성의 undefined는 키째 사라지고, NaN은 null이 되며, Map과 Set은 빈 객체가 됩니다. 오류가 나는 것은 BigInt와 순환 구조뿐입니다.

서버는 사라진 키를 알 수 없습니다

로컬 서버 로그에 찍힌 본문은 출력한 문자열과 한 글자도 다르지 않았습니다. 네트워크로 나가는 것은 이미 만들어진 문자열이라서, 서버가 아무리 꼼꼼해도 memo가 원래 있었는지 알아낼 방법이 없습니다. 검증을 서버에 맡겨도 “memo가 빠졌다”는 오류는 나지 않고, memo가 선택 항목이라면 그대로 저장됩니다.

문자열을 JSON.parse로 되읽어도 원래 객체로 돌아오지 않습니다. 실행 결과 'memo' in back은 false, createdAt의 typeof는 string, discount는 null이었습니다. createdAt을 날짜로 쓰려면 new Date(back.createdAt)처럼 받는 쪽에서 다시 만들어야 하고, tags는 {}라서 처음 들어 있던 "gift"를 되찾을 길이 없습니다. discount가 null이면 “할인 없음”으로 읽히기 쉽지만, 실제로는 계산 실수로 생긴 NaN이었습니다.

세 단계. 1 보내기 전 객체에는 memo undefined, discount NaN, createdAt Date, tags Set이 있다. 2 서버 로그에는 memo 키가 없고 discount는 null, tags는 빈 객체다. 3 JSON.parse 뒤 memo는 없고 createdAt은 문자열, discount는 null이다.
fetch 본문에 넣은 순간 memo는 사라지고 discount는 null이 됩니다. 되읽어도 Date와 Set은 돌아오지 않습니다.

빠진 키와 null은 다른 뜻일 수 있습니다

그렇다고 undefined를 모두 null로 바꾸면 되는 것은 아닙니다. API가 JSON Merge Patch(RFC 7396) 방식의 PATCH를 받는다면, 패치에 없는 키는 “바꾸지 않음”이고 null은 “그 값을 지움”입니다. 이런 API에 memo: null을 보내면 저장된 메모가 지워집니다. 거꾸로 이 경우에는 JSON.stringify가 undefined를 빼 주는 동작이 “건드리지 않음”과 맞아떨어집니다.

그러니 먼저 정해야 할 것은 API의 약속입니다. 주문 전체를 보내는 POST나 PUT에서 키가 없어지는 것은 대개 실수입니다. 일부만 고치는 PATCH에서는 키를 빼는 것이 의도일 수 있습니다. 아래 함수는 앞의 경우, 즉 문서 전체를 보내는 요청에 쓰는 것을 전제로 합니다.

바꿀 것은 바꾸고, 나머지는 오류로 드러냅니다

JSON.stringify의 두 번째 인자인 replacer 함수는 모든 키와 값에 대해 불리고, 돌려준 값이 대신 직렬화됩니다. 여기서 BigInt는 문자열로, Map은 객체로, Set은 배열로 바꿉니다. NaN과 Infinity는 null로 보내는 대신 RangeError를 던집니다. 그다음 최상위 키를 되읽은 결과와 비교해 사라진 키가 있으면 TypeError를 던집니다. 이번 실행에 쓴 코드입니다.

function replacer(key, value) {
  if (typeof value === 'bigint') return value.toString();
  if (value instanceof Map) return Object.fromEntries(value);
  if (value instanceof Set) return [...value];
  if (typeof value === 'number' && !Number.isFinite(value)) throw new RangeError(`${key} is ${value}`);
  return value;
}
function toJsonStrict(obj) {
  const text = JSON.stringify(obj, replacer);
  const lost = Object.keys(obj).filter(k => !(k in JSON.parse(text)));
  if (lost.length) throw new TypeError(`dropped: ${lost.join(', ')}`);
  return text;
}

같은 주문으로 세 번 실행했습니다. 처음에는 “RangeError: discount is NaN”으로 멈췄습니다. discount를 null로 고치자 이번에는 “TypeError: dropped: memo”가 났습니다. memo도 null로 정하자 통과했고, total은 "12000", tags는 ["gift"], options는 {"wrap":true}로 나갔습니다. 받는 쪽은 total이 문자열로 온다는 것을 알고 있어야 합니다.

세 단계. 1 replacer에서 BigInt는 문자열, Map은 객체, Set은 배열로 바꾼다. 2 NaN이나 Infinity면 RangeError를 던진다. 3 최상위 키를 비교해 사라진 키가 있으면 TypeError dropped memo를 던진다. replacer는 toJSON 다음에 불리므로 Date는 문자열로 들어온다.
replacer로 바꿀 것은 바꾸고, NaN과 사라진 키는 오류로 드러내는 toJsonStrict의 순서입니다.
Node.js v24.21.0 실행 결과. 첫 줄 RangeError discount is NaN, 둘째 줄 TypeError dropped memo, 셋째 줄은 memo null, discount null, total 문자열 12000, tags 배열 gift, options 객체 wrap true가 담긴 JSON 문자열.
같은 주문을 toJsonStrict로 세 번 변환한 결과입니다. 조용히 바뀌던 값이 오류로 드러났고, 고친 뒤에는 모든 키가 남았습니다.

두 가지는 알고 쓰는 편이 좋습니다. 사라진 키 검사는 최상위만 봅니다. items 안 객체의 undefined까지 잡으려면 같은 비교를 재귀로 해야 합니다. 또 replacer는 toJSON 다음에 불리므로 Date는 이미 문자열로 들어옵니다. replacer 안에서 value instanceof Date는 false이고, 원래 Date는 this[key]로 봐야 합니다. 이 점도 Node v24.21.0에서 따로 실행해 확인했습니다.

보내기 전에 한 번 되읽어 봅니다

JSON.stringify는 실패하지 않으려고 값을 버리거나 바꿉니다. 그래서 요청 본문에 들어갈 객체에 undefined, NaN, Date, Map, Set, BigInt가 섞일 수 있는지 한 번 훑어보고, 그중 하나라도 있다면 보내기 전에 JSON.parse(JSON.stringify(x))를 원래 객체와 나란히 찍어 보세요. 없어진 키가 보이면, 그 키가 “없음”인지 “바꾸지 않음”인지 API의 약속에 맞춰 null을 넣을지 뺄지를 직접 정하면 됩니다.

END OF NOTE목록으로
COMMENTS BOX

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

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

최신순

로그인 상태 확인 중…

댓글을 불러오는 중…