JSON은 당신을 속이고 있다
요약
JSON 직렬화 과정에서 발생할 수 있는 데이터 손실과 타입 변환 문제를 다룹니다. 큰 정수(BigInt), undefined, Date 등 JavaScript의 특정 타입들이 JSON 변환 시 어떻게 변경되거나 삭제되는지 설명하고 안전한 데이터 교환 방법을 제안합니다.
핵심 포인트
- JSON은 BigInt, undefined, Symbol 등 JavaScript의 모든 타입을 지원하지 않음
- IEEE 754 정밀도 문제로 인해 큰 정수는 파싱 과정에서 값이 왜곡될 수 있음
- 안전한 데이터 전송을 위해 큰 정수는 문자열로 인코딩할 것을 권장
- 데이터 무결성을 위해 런타임 스키마 검증과 명시적 와이어 계약이 필요함
JSON.parse(JSON.stringify(value))
는 JavaScript 상태를 그대로 복제하지 않으며, 큰 정수·undefined
·Date
·NaN
등이 조용히 변경되거나 삭제 될 수 있음
JSON은 문자열·숫자·불리언·null
·배열·객체만 지원하는 작은 교환 형식이라 BigInt
, 컬렉션, 프로토타입, 객체 동일성 같은 JavaScript 타입 정보 를 보존하지 않음
JSON.stringify
는 toJSON()
, getter, replacer를 실행할 수 있고 순환 참조에서는 예외를 던지므로, 단순한 값 복사가 아니라 코드를 실행하는 변환 과정 임
안전한 경계를 만들려면 큰 정수를 문자열로 인코딩하고 타입 태그와 배열·정규화 형식을 사용하며, 파싱 전 크기 제한과 파싱 후 런타임 스키마 검증 을 적용해야 함
JSON은 고장 난 형식이 아니라 JavaScript의 무손실 스냅샷이 아닐 뿐이며, 송수신자가 공유하는 명시적 와이어 계약 과 전체 왕복 테스트가 있어야 예측 가능하게 사용할 수 있음
JSON의 작은 데이터 모델
JSON은 2001년경 브라우저와 서버 사이에서 구조화 데이터를 가볍게 교환하는 방식으로 등장했으며, Douglas Crockford가 이름을 붙이고 대중화함
당시 지배적이던 XML보다 문법이 작고 JavaScript 객체·배열 리터럴과 비슷해 초기 대화형 웹 애플리케이션에 적합했음
JSON은 문자열·숫자·불리언·null
·배열·객체 만 지원하며, 특정 언어의 전체 타입 시스템보다 이식성 높은 와이어 형식을 우선함
JavaScript의 undefined
, BigInt
, symbol, 특수 숫자, 프로토타입 객체, 내장 컬렉션은 이 모델 밖에 있음
JSON.stringify
는 표현할 수 없는 값을 변경·삭제하거나 거부함
JSON.parse
는 원래 타입을 복원할 정보가 부족함
캐시·데이터베이스 저장, 다른 서비스로의 전송, 다른 언어에서의 처리처럼 값이 생성 프로세스를 벗어나면 유효한 JSON인데도 의미가 달라질 수 있음
JSON이 보기 전부터 틀릴 수 있는 숫자
9007199254740993
이 9007199254740992
로 출력되는 정밀도 손실은 직렬화 시점이 아니라 JavaScript가 숫자 리터럴을 평가할 때 이미 발생함
JavaScript Number
는 IEEE 754 binary64를 사용하며, 정확하게 표현할 수 있는 정수 범위는 -(2^53 - 1)
부터 2^53 - 1
까지임
이 범위를 벗어나면 인접한 정수들이 같은 저장 값으로 매핑될 수 있음
JSON의 숫자 문법 은 십진 텍스트 표현만 정의하고 구현체의 메모리상 숫자 타입은 규정하지 않음
다른 시스템이 {"id":9007199254740993}
을 정확히 보내도 JSON.parse
가 이를 Number
로 변환하면서 9007199254740992
로 반올림할 수 있음
다시 직렬화하면 잘못된 값이 외부 데이터로 확정됨
데이터베이스 ID, 계정 번호, 송장 합계, 최소 화폐 단위로 표현한 금액에서 문제가 되며, 파싱 이후 검증으로는 원래 숫자를 복구할 수 없음
BigInt
는 큰 정수를 정확히 보관하지만 JSON에 대응 타입이 없어 JSON.stringify({ id: 9007199254740993n })
는 TypeError
를 던짐
정확한 정수는 "9007199254740993"
같은 십진 문자열 로 보내고, 스키마에서 해당 필드가 일반 숫자가 아님을 정의해야 함
수신자는 식별자를 문자열로 유지하거나 연산이 필요할 때 BigInt
로 변환할 수 있음
다른 인코딩도 송수신자가 명시적으로 합의하면 사용할 수 있음
undefined
가 만드는 상태 손실
JavaScript에서는 빠진 속성과 값이 undefined
인 속성을 in
연산자로 구분할 수 있지만 JSON에는 undefined
가 없음
객체 속성의 undefined
는 직렬화 과정에서 속성 자체가 삭제 됨
{}
와 { nickname: undefined }
가 모두 {}
가 됨
부분 업데이트, 설정 오버레이, 폼 제출, 캐시 상태에서 의미가 달라질 수 있음
생략을 “변경하지 않음”, null
을 “값 지우기”로 정의한 API에서는 { nickname: undefined }
가 생략과 구분되지 않음
배열에서는 위치 이동을 막기 위해 undefined
가 null
로 바뀜
['first', undefined, 'third']
는 ["first",null,"third"]
가 됨
최상위 JSON.stringify(undefined)
는 JSON 문자열이 아니라 JavaScript undefined
를 반환함
함수와 symbol도 같은 문맥 의존 규칙을 따름
객체에서는 생략됨
배열에서는 null
이 됨
최상위에서는 undefined
가 반환됨
API는 JSON이 전달할 수 있는 생략과 null
의 의미 를 직접 정의해야 하며, 추가 상태가 필요하면 직렬화 전에 명시적으로 인코딩해야 함
객체 순서는 경계를 넘으면 계약이 아님
현대 JavaScript에서 JSON.stringify
의 속성 순서는 안정적이며, 변경되지 않은 같은 일반 객체는 반복 직렬화해도 같은 키 순서를 생성함
ECMAScript 규칙에 따라 열거 가능한 자체 문자열 키를 Object.keys()
와 같은 순서로 방문함
정수 인덱스 키가 숫자 오름차순으로 먼저 나옴
나머지 문자열 키는 생성 순서를 따름
반면 RFC 8259 은 JSON 객체를 순서 없는 컬렉션 으로 정의하며, 파서가 멤버 순서를 노출하는지도 구현마다 다름
Go의 map[string]any
는 맵 순회 순서가 규정되지 않아 입력 JSON의 키 순서를 신뢰할 수 없음
첫 속성을 최고 우선순위로 처리하거나 같은 객체를 담은 JSON 문자열끼리 직접 비교하면 다른 런타임이나 재직렬화 과정에서 깨질 수 있음
순서가 의미를 가진다면 이름과 값을 담은 객체들의 배열 로 표현해야 함
서명, 해시, 캐시 키, 콘텐츠 주소 식별자에는 별도의 정규화가 필요함
같은 객체라도 속성 순서와 공백이 다르면 바이트열과 해시가 달라짐
JSON.stringify
는 동일한 속성 순서의 객체에는 반복 가능한 결과를 내지만, 같은 멤버를 다른 순서로 생성한 객체까지 같은 문자열로 만들지는 않음
정규화 규칙은 동등한 데이터에 하나의 표현을 부여해야 함
직렬화 과정에서 사라지는 타입
JSON 문서는 변환된 값만 담고 그 값의 원래 JavaScript 타입 은 기록하지 않음
Date
는 toJSON()
을 통해 ISO UTC 문자열로 바뀜
파싱 후에는 Date
가 아닌 일반 문자열임
원래부터 문자열이었는지 Date
였는지 알 수 없어 모든 타임스탬프 형태의 문자열을 자동 복원할 수 없음
2026-07-21T09:00:00-04:00
은 2026-07-21T13:00:00.000Z
가 되어 같은 순간은 유지하지만 원래 벽시계 시간과 오프셋은 사라짐
Map
, Set
, RegExp
, Error
의 핵심 상태는 내부 슬롯이나 열거 불가능한 속성에 있어 기본 직렬화 결과가 {}
가 됨
JSON 숫자는 NaN
, Infinity
, -Infinity
를 표현할 수 없어 모두 null
로 변환됨
수신자는 값 없음과 계산 결과인 NaN
, 무한 경계를 구분할 수 없음
Uint8Array
같은 타입 배열은 인덱스 값만 일반 객체 속성으로 남고 타입과 바이너리 해석 을 잃음
클래스 인스턴스에서는 열거 가능한 데이터 필드만 남을 수 있음
파싱 결과는 일반 객체이며 원래 클래스, 프로토타입 메서드, private 필드를 갖지 않음
문자열·빈 객체·null
·일반 객체가 겉보기에는 그럴듯해 문제가 실제 사용 시점까지 숨을 수 있으므로, 비JSON 타입마다 직렬화 형식을 정의해야 함
JSON.stringify
는 코드를 실행함
JSON.stringify
는 저장된 값만 읽는 것이 아니라 직렬화 훅과 속성 접근 코드 를 실행할 수 있음
객체에 toJSON()
이 있으면 원본 대신 그 반환값을 직렬화함
공개 표현을 정의하는 데 쓸 수 있지만 호출 지점에서 보이는 필드만으로 결과를 예측하기 어려움
계산, 상태 변경, 외부 상태 접근, 예외 발생도 가능함
Date
역시 toJSON()
에서 toISOString()
을 호출함
열거 가능한 getter 속성은 직렬화 중 읽히면서 getter가 실행됨
저장되지 않은 계산 결과가 JSON에 포함될 수 있음
getter가 예외를 던지면 로깅이나 오류 보고용 직렬화도 실패할 수 있음
replacer 함수는 루트와 방문하는 각 속성에 호출되며 반환값으로 결과를 변경함
undefined
를 반환하면 객체 속성이 제거됨
Date
의 toJSON()
이 먼저 실행되므로 replacer는 원래 Date
가 아니라 ISO 문자열을 받음
replacer에 속성 이름 배열을 전달하면 객체 속성 허용 목록 으로 동작함
의존성이나 외부 클래스에서 온 익숙하지 않은 객체를 직렬화할 때는 값 변환이나 예외가 가능한 실행 동작으로 취급해야 함
객체 동일성·순환·속성 가시성의 한계
JSON에는 객체 동일성이나 참조 를 표현하는 방법이 없음
두 속성이 같은 객체를 가리켜도 왕복 후에는 동일한 필드를 가진 별도 객체가 됨
자기 자신을 가리키는 순환 참조는 TypeError: Converting circular structure to JSON
을 발생시킴
부모 링크, 그래프, 캐시, 프레임워크 객체에서 자연스럽게 생길 수 있음
replacer로 순환 속성을 제거하면 직렬화할 수 있지만 데이터 모델이 달라짐
동일성을 보존해야 하는 그래프에는 생성 ID와 ID 참조 필드 같은 명시적 참조 형식이 필요함
일반 객체에서는 열거 가능한 자체 문자열 키 만 직렬화 대상임
상속 속성, 열거 불가능한 속성, symbol 키, private 필드는 제외됨
클래스의 생성자 할당 public 필드는 대개 남지만 프로토타입 메서드와 private 필드는 사라짐
임의의 도메인 객체를 그대로 보내기보다 경계에서 일반 전송 객체를 구성해야 함
포함되는 필드가 코드에 드러남
열거 가능성, 프로토타입, 내부 구현 변경이 응답 형식을 조용히 바꾸지 못함
파싱 이후에 생기는 보안 문제
JSON.parse
자체는 Object.prototype
을 변경하지 않으며, JSON의 "__proto__"
는 해당 이름을 가진 자체 데이터 속성 이 됨
위험은 이후 코드가 신뢰할 수 없는 키를 다른 객체를 수정하는 명령처럼 처리할 때 발생함
재귀 병합이 __proto__
나 constructor
를 따라가면 일반 객체가 아니라 프로토타입에 값을 쓸 수 있음
흐름은 신뢰할 수 없는 JSON → 파싱 객체의 자체 속성 → 안전하지 않은 재귀 병합 → 프로토타입 변경
순서임
방어는 파서가 아니라 신뢰할 수 없는 키를 받아 복사하는 지점에 적용해야 함
스키마 허용 목록으로 예상한 필드만 수용함
재귀 병합 도구가 프로토타입 오염을 방어하는지 확인해야 함
객체 spread는 기존의 일부 병합 방식과 다르게 __proto__
setter를 호출하지 않고 새 객체의 자체 속성으로 만듦
이 차이가 모든 spread 사용을 안전하게 만들지는 않으며, 예상하지 않은 키가 애플리케이션 로직에 영향을 줄 수 있어 검증은 여전히 필요함
자원 고갈과 입력 제한
문법적으로 유효한 JSON도 데이터가 크거나 중첩이 극단적이면 많은 메모리와 CPU 를 소비할 수 있음
애플리케이션 검증은 보통 파싱과 할당 이후 실행되므로 HTTP 서버는 JSON.parse
전에 과대 요청 본문을 거부해야 함
복잡한 JSON을 받는 시스템은 중첩 깊이, 컬렉션 크기, 총 처리 시간도 제한할 필요가 있음
reviver는 전체 파싱 구조를 순회하므로 값마다 비싼 작업을 하면 비신뢰 입력의 원소 수에 비례해 비용이 커짐
안전한 처리는 구문 검사에 그치지 않으며, 파싱 전 입력 제한 , 파싱 후 허용 목록 기반 형태 검증, 안전한 병합을 함께 요구함
reviver가 타입을 자동 복원하지 못하는 이유
JSON.parse
의 reviver는 자식 값부터 컨테이너를 거쳐 마지막에 빈 문자열 키의 루트 값을 받음
다른 값을 반환하면 해당 위치를 교체함
undefined
를 반환하면 객체 속성을 삭제하거나 배열에 빈 슬롯을 만듦
타입 복원은 직렬화 표현에 충분한 정보가 있을 때만 가능함
{ "$type": "Date", "value": "..." }
같은 태그 객체 는 생산자와 소비자가 공유하는 작은 직렬화 프로토콜이 됨
ISO 형식처럼 보이는 모든 문자열을 Date
로 바꾸면 실제로 텍스트여야 하는 검색어까지 변경됨
문자열 모양만으로는 원래 타입을 증명할 수 없음
replacer와 reviver는 같은 명시적 계약을 구현할 때 가장 안정적임
BigInt
를 { "$type": "BigInt", "value": "9007199254740993" }
로 보내면 숫자는 문자열로 정확히 이동함
reviver는 알려진 태그만 허용하고 연결된 페이로드를 검증해야 함
큰 계약에서는 하나의 reviver에 조건을 계속 추가하기보다 전용 코덱이나 스키마 변환 이 테스트하기 쉬움
신뢰할 수 있는 JSON 경계 설계
와이어 형태를 직접 정의하기
수신자가 필요한 필드로 전송 객체를 만들어 클래스 내부 상태, 임시 상태, 계산 필드, 우연히 열거 가능한 속성을 외부 표현에서 제외해야 함
예를 들어 사용자 응답에서 ID는 십진 문자열, 날짜는 UTC 타임스탬프, 역할 Set
은 배열로 변환할 수 있음
변환 함수가 바뀌지 않는 한 도메인 클래스 변경이 응답 형태를 바꾸지 않음
파싱 후 런타임 검증하기
JSON.parse
는 구문만 검사하며 필드 존재 여부, 타입, 도메인 제약은 검증하지 않음
TypeScript 타입은 컴파일 때 제거되므로 외부에서 받은 바이트를 런타임에 검사할 수 없음
Zod , Valibot , TypeBox 같은 스키마 라이브러리로 비즈니스 코드가 값을 신뢰하기 전에 검증할 수 있음
검증은 이미 사라진 정보를 복원하지 못함
큰 정수를 JSON 숫자로 받은 뒤에는 반올림된 값만 검증할 수 있으므로, 먼저 와이어 표현이 원본을 보존해야 함
예외 값을 명시적으로 인코딩하기
큰 정수와 바이너리 데이터는 JSON에 직접 대응 타입이 없으므로 문서화된 인코딩이 필요함
날짜처럼 타입이 살아남아야 하는 값은 $type
같은 태그와 값 필드를 사용할 수 있음
문자열 모양을 추측하기보다 필드 이름이나 타입 태그 로 검증·디코딩 규칙을 드러내야 함
순서와 바이트 표현을 구분하기
작업 순서나 우선순위는 객체 멤버 순서가 아니라 배열에 담아야 함
서명, 해시, 중복 제거, 캐시 키는 배열 변환이 아니라 모든 생산자가 공유하는 정규 JSON 표현 을 사용해야 함
실제 수신 경로까지 테스트하기
직렬화 테스트는 한 생산자가 무엇을 출력하는지만 확인함
경계 테스트는 수신 구현으로 다시 파싱해 실제 의미가 보존되는지 확인해야 함
JavaScript 클라이언트와 Go 서비스 사이에서 큰 식별자가 양쪽 런타임을 통과하는 과정을 테스트함
서명 JSON은 속성 생성 순서가 다른 동등한 객체가 정규화 후 같은 바이트를 만드는지 확인함
필요하면 다른 형식 선택하기
JSON은 읽기 쉽고 상호운용성이 넓은 기본 형식이지만, 작은 바이너리 출력·타입 있는 바이트 문자열·더 넓은 숫자 모델·확장 메커니즘이 계약의 일부라면 다른 형식이 더 적합할 수 있음
MessagePack 은 압축된 바이너리 표현과 코어 모델 밖 값에 대한 확장 타입을 지원함
CBOR 는 텍스트와 바이트 문자열을 구분하고 추가 의미를 위한 태그와 더 넓은 숫자 표현을 지원함
두 형식도 임의의 JavaScript 객체를 자동 보존하지 않으며, 확장·태그·숫자 처리·디코딩 후 스키마에 대한 합의가 필요함
JSON 왕복은 복제가 아니라 변환
큰 정수 반올림, undefined
삭제, Date
의 문자열 변환, NaN
의 null
변환은 모두 정의된 규칙에 따른 결과임
JSON은 작은 데이터 모델 덕분에 널리 이해되는 유용한 형식이지만, JavaScript 상태의 무손실 스냅샷은 아님
JSON.parse(JSON.stringify(value))
는 범용 복제가 아니라 변환이며, 값이 처음부터 JSON용으로 설계됐을 때만 안정적으로 동작함
직렬화 전에 보존할 정보를 결정해야 함
큰 정수에는 정확한 인코딩이 필요함
타입 값에는 문서화된 표현이 필요함
순서가 있는 데이터는 배열에 넣어야 함
비신뢰 입력은 파싱 전 제한하고 파싱 후 검증해야 함
JavaScript 객체와 JSON 표현을 서로 다른 데이터 모델로 취급하고, 명시적 와이어 형태와 예외 인코딩 을 정의한 뒤 수신 측에서 검증해야 JSON을 예측 가능하게 사용할 수 있음
댓글과 토론
AI 자동 생성 콘텐츠
본 콘텐츠는 GeekNews의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기