JSON 직렬화와 파싱 과정에서 숫자 정밀도, 타입, 속성, 순서가 어떻게 달라지는지와 안전한 와이어 형식을 설계하는 방법을 살펴봅니다.
TLDR IT - 2026-08-04에 소개됨
브라우저 콘솔에서 다음을 실행해 보세요:
const payload = { id: 9007199254740993 }
console.log(JSON.stringify(payload))
// {"id":9007199254740992}
값은 3으로 끝나지만 직렬화 결과는 2로 끝납니다. 예외도 발생하지 않고 출력도 유효한 JSON이므로, 값이 더 큰 페이로드 안에 묻힌 식별자라면 이 변화를 놓치기 쉽습니다.
이제 좀 더 다양한 객체를 시도해 보겠습니다:
const original = {
id: 9007199254740993,
missing: undefined,
createdAt: new Date('2026-07-21T12:00:00Z'),
score: NaN
}
const copy = JSON.parse(JSON.stringify(original))
console.log(copy)
// {
// id: 9007199254740992,
// createdAt: "2026-07-21T12:00:00.000Z",
// score: null
// }
숫자는 바뀌었고, missing 속성은 사라졌으며, Date는 문자열이 되었고 NaN은 null이 되었습니다. 객체는 JavaScript에서 가장 익숙한 직렬화 왕복 과정을 거쳤지만, 복사본은 더 이상 원본과 같은 데이터나 타입을 담고 있지 않습니다.
JSON은 2001년 무렵 브라우저와 서버 사이에서 구조화된 데이터를 교환하는 가벼운 방법으로 등장했습니다. Douglas Crockford가 이 형식의 이름을 붙이고 대중화했으며, RFC 4627이 2006년에 이를 정식으로 명세했습니다. 현재 표준은 2017년에 발표된 RFC 8259입니다.
당시 웹의 데이터 교환은 XML이 지배하고 있었습니다. JSON은 JavaScript 개발자가 객체와 배열 리터럴에서 이미 익힌 문법을 바탕으로 훨씬 작은 문법을 제공했습니다. JSON 문서는 복잡한 장치 없이도 생성하고 소비할 수 있었고, 따라서 2000년대 초반 점점 상호작용이 많아지던 웹 애플리케이션에 자연스럽게 어울렸습니다.
이 작은 문법은 지금도 JSON의 매력의 원천입니다. 문자열, 숫자, 불리언, null, 배열, 객체를 지원하여 한 언어의 완전한 타입 체계를 들여오지 않고도 서로 다른 언어에 공통 표현을 제공합니다. 설계상 JavaScript 안에서 가능한 모든 값을 정확히 보존하는 것보다 최소한의 이식 가능한 와이어 형식을 우선합니다.
JavaScript에는 이 모델 밖의 값이 많습니다. undefined, BigInt, 심볼, 특수 숫자 값, 프로토타입이 있는 객체, 자체 내부 상태를 지닌 내장 컬렉션이 있습니다. JSON.stringify는 형식으로 표현할 수 없는 값을 바꾸거나 거부해야 하고, JSON.parse는 대부분의 원래 타입을 복원하기에 정보가 너무 부족합니다.
이 차이는 값이 그것을 만든 프로세스를 떠날 때 드러납니다. 캐시나 데이터베이스에 쓰거나, 다른 서비스로 보내거나, 다른 언어에서 읽으면 JavaScript 안에서는 숨겨져 있던 가정이 노출될 수 있습니다. JSON은 완벽하게 유효한 상태로 남아도 전달 과정에서 의미는 바뀔 수 있습니다.
이 글은 그러한 변화를 살펴보고 애플리케이션에 실제로 필요한 정보를 보존하는 와이어 표현을 정의하는 방법을 설명합니다. 편리한 인코딩이 조용히 우연한 데이터 계약이 되지 않도록 JSON 경계를 명시하는 것이 목표입니다.
첫 예제는 직렬화 중 바뀐 값을 보여 주지만, 정밀도 손실은 그보다 먼저 일어납니다. JavaScript는 JSON.stringify가 받기 전에 숫자 리터럴을 평가하면서 9007199254740993을 반올림합니다.
const id = 9007199254740993
console.log(id)
// 9007199254740992
JavaScript는 ECMAScript Number 타입이 설명하는 IEEE 754 binary64 형식으로 일반 숫자를 저장합니다. 이 형식은 Number.MIN_SAFE_INTEGER부터 Number.MAX_SAFE_INTEGER까지, 즉 -(2^53 - 1)부터 2^53 - 1까지의 정수를 정확히 표현할 수 있습니다. 이 범위를 넘으면 인접한 정수가 같은 저장 값에 대응할 수 있습니다.
JSON의 계약은 다릅니다. 숫자 문법은 숫자를 십진 텍스트로 작성하는 방식을 설명하지만, 모든 구현에 사용할 하나의 메모리 내 숫자 타입을 규정하지는 않습니다. 따라서 더 넓은 정수 타입을 가진 시스템은 JavaScript가 정확히 저장할 수 없는 값을 포함하는 유효한 JSON을 생성할 수 있습니다.
const text = '{"id":9007199254740993}'
const parsed = JSON.parse(text)
console.log(parsed.id)
// 9007199254740992
여기서 텍스트에는 정확한 정수가 여전히 들어 있습니다. 정밀도는 JSON.parse가 그 텍스트를 JavaScript Number로 변환할 때 사라집니다. 이후 파싱한 값을 다시 직렬화하면 반올림된 결과가 송신 데이터의 일부가 됩니다.
이는 데이터베이스 식별자, 계좌 번호, 청구서 합계, 최소 화폐 단위로 표현한 금액에 중요합니다. 생산자는 올바른 숫자 자리를 보낼 수 있지만 수신하는 JavaScript 프로그램은 조용히 다른 숫자를 저장할 수 있습니다. 파싱 뒤에 수행하는 검증으로는 이미 그 차이가 사라졌으므로 원래 값을 복구할 수 없습니다.
BigInt는 안전한 Number 범위를 넘는 정수를 담을 수 있지만 JSON에는 대응하는 값 타입이 없습니다. JavaScript는 자동으로 인코딩을 선택하는 대신 변환을 거부합니다.
const id = 9007199254740993n
console.log(id)
// 9007199254740993n
JSON.stringify({ id })
// TypeError: Do not know how to serialize a BigInt
정확한 정수는 와이어를 건너기 전에 합의된 표현이 필요합니다. JSON 구현이 숫자 자리를 정확히 보존할 수 있으므로 십진 문자열이 가장 간단한 선택인 경우가 많습니다.
const json = JSON.stringify({ id: '9007199254740993' })
const parsed = JSON.parse(json)
console.log(parsed.id)
// "9007199254740993"
스키마는 id가 일반 숫자가 아니라 십진 문자열임을 명시해야 합니다. 수신 프로그램은 이를 식별자로 유지하거나 산술 연산이 필요할 때 BigInt로 변환할 수 있습니다. 생산자와 소비자가 명시적으로 합의한다면 다른 인코딩도 사용할 수 있습니다.
undefined는 여러 의미를 가질 수 있다JavaScript는 누락된 속성과 값이 undefined인 속성을 구별합니다. 직접 읽으면 둘 다 undefined를 만들지만 속성 검사는 차이를 드러냅니다:
const value = { present: undefined }
console.log(value.present) // undefined
console.log(value.missing) // undefined
console.log('present' in value) // true
console.log('missing' in value) // false
이 구별은 애플리케이션 코드에서 중요할 수 있습니다. 누락된 속성은 업데이트 요청이 없었다는 뜻일 수 있는 반면, 존재하는 속성은 호출자가 해석할 값은 제공했다는 뜻일 수 있습니다(물론 이는 좋은 애플리케이션 설계가 아니라고 주장할 수 있습니다). undefined를 담은 객체 속성이 직렬화되면 이 구별은 사라집니다.
const value = {
name: 'Alice',
nickname: undefined
}
const json = JSON.stringify(value)
console.log(json)
// {"name":"Alice"}
const copy = JSON.parse(json)
console.log('nickname' in copy)
// false
JSON에는 undefined 값이 없으므로 JSON.stringify는 해당 속성을 생략합니다. 이 동작은 일반 응답 페이로드에는 합리적으로 보이는 경우가 많지만 부분 업데이트, 구성 오버레이, 양식 제출, 캐시 상태의 의미를 바꿀 수 있습니다.
별명 삭제에는 null을 쓰고, 생략은 변경하지 않음을 뜻하는 엔드포인트를 생각해 봅시다:
const leaveUnchanged = {}
const clearNickname = { nickname: null }
const ambiguousUpdate = { nickname: undefined }
console.log(JSON.stringify(leaveUnchanged))
// {}
console.log(JSON.stringify(clearNickname))
// {"nickname":null}
console.log(JSON.stringify(ambiguousUpdate))
// {}
첫 번째와 세 번째 객체는 JavaScript에서는 구별되지만 동일한 JSON이 됩니다. 직렬화한 뒤에는 수신자가 속성이 처음부터 없었던 것인지, 값이 undefined여서 제거된 것인지 판단할 수 없습니다.
배열은 다른 규칙을 따릅니다. 배열 항목을 제거하면 이후 모든 위치가 이동하므로 지원되지 않는 값은 대신 null이 됩니다.
const values = ['first', undefined, 'third']
console.log(JSON.stringify(values))
// ["first",null,"third"]
최상위 수준에서 JSON.stringify(undefined)는 JavaScript undefined를 생성합니다. JSON 텍스트를 반환하지 않으므로, 성공한 모든 호출이 문자열을 반환한다고 가정하는 코드는 놀랄 수 있습니다.
const result = JSON.stringify(undefined)
console.log(result) // undefined
console.log(typeof result) // "undefined"
함수와 심볼도 같은 문맥 의존 패턴을 따릅니다. 객체에서는 생략되고 배열 안에서는 null로 변환되며 최상위 직렬화는 undefined를 반환합니다.
const fn = () => 42
console.log(JSON.stringify({ fn }))
// {}
console.log(JSON.stringify([fn]))
// [null]
API 계약은 JSON이 담을 수 있는 상태인 생략과 null을 직접 정의해야 합니다. JavaScript 코드에 추가 상태가 필요하다면 undefined가 왕복 후에도 남을 것이라 기대하지 말고 직렬화 전에 그 상태를 명시적으로 인코딩해야 합니다.
현대 JavaScript는 JSON.stringify에 안정적인 속성 순서를 부여합니다. 객체 자체가 바뀌지 않는 한 같은 일반 객체를 반복 직렬화하면 같은 키 순서가 나옵니다.
const value = { z: 1, a: 2, m: 3 }
console.log(JSON.stringify(value))
// {"z":1,"a":2,"m":3}
이 동작은 ECMAScript가 정의합니다. JSON.stringify는 Object.keys()와 같은 안정된 정렬 규칙을 사용하여 열거 가능한 자체 문자열 키 속성을 방문합니다. 정수 인덱스 키가 숫자 오름차순으로 먼저 오고, 나머지 문자열 키는 생성 순서대로 옵니다.
const value = {}
value.b = 'second'
value[10] = 'ten'
value[2] = 'two'
value.a = 'first'
console.log(JSON.stringify(value))
// {"2":"two","10":"ten","b":"second","a":"first"}
결과는 JavaScript 내부에서 예측 가능하지만 JSON은 객체 멤버에 더 약한 의미를 부여합니다. RFC 8259는 객체를 순서 없는 컬렉션으로 정의하며, 파싱 라이브러리가 호출 코드에 멤버 순서를 노출하는지는 서로 다르다고 언급합니다.
JavaScript 서비스가 다음 문서를 보낼 수 있습니다. 텍스트 순서가 보이므로 그 순서가 계약의 일부처럼 보일 수 있습니다:
{"z":1,"a":2,"m":3}
Go 서비스는 이 객체를 map[string]any로 디코드할 수 있습니다. Go 언어 명세는 맵 순회 순서를 지정하지 않으므로 디코드한 맵을 순회하는 코드는 원본 순서로 z, a, m을 받는다고 의존할 수 없습니다. 다른 파서나 직렬화기도 같은 JSON 객체를 보존하면서 다른 순서를 노출할 수 있습니다.
이는 애플리케이션이 데이터 모델에는 없는 위치에 의미를 부여할 때 중요합니다. 워크플로가 첫 번째 속성을 최고 우선순위로 처리하거나, 객체에 같은 멤버가 있어도 테스트가 원시 JSON 문자열을 비교할 수 있습니다. 두 설계 모두 다른 런타임이 문서를 처리할 때 사라질 수 있는 순서 보장에 의존합니다.
순서에 의미가 있다면 배열로 표현하세요. 그러면 순서가 데이터 모델의 일부가 됩니다:
[
{"name":"z","value":1},
{"name":"a","value":2},
{"name":"m","value":3}
]
배열은 JSON 명세상 순서가 있으므로 모든 호환 파서는 순서를 보존합니다. 추가 구조는 의도한 계약을 독자와 구현체에 드러냅니다.
해시, 서명, 캐시 키, 콘텐츠 주소 식별자에는 별도 해법이 필요합니다. 두 JSON 텍스트는 속성 순서나 공백이 달라도 같은 객체를 표현할 수 있고, 이 경우 바이트열은 달라집니다.
const first = '{"a":1,"b":2}'
const second = '{ "b": 2, "a": 1 }'
console.log(JSON.parse(first))
// { a: 1, b: 2 }
console.log(JSON.parse(second))
// { b: 2, a: 1 }
console.log(first === second)
// false
JSON에 서명하거나 해시하는 시스템은 바이트를 계산하기 전에 합의된 정준 표현이 필요합니다. JSON.stringify는 같은 속성 순서를 지닌 같은 객체를 받으면 반복 가능한 출력을 만들지만, 동일한 멤버를 가진 두 객체라도 속성이 다른 순서로 생성되었다면 다른 문자열을 만들 수 있습니다. 정준화 방식은 각 생산자가 데이터를 어떻게 구성했는지와 무관하게 동등한 데이터에 하나의 표현을 정의합니다.
JSON 문서는 JavaScript보다 작은 타입 체계를 가집니다. 그 모델에 맞는 값은 경계를 직접 넘을 수 있지만, 다른 값은 JSON이 표현할 수 있는 것으로 변환되어야 합니다. 결과 문서는 변환된 값을 담을 뿐 원래 JavaScript 타입은 기록하지 않습니다.
Date가 흔한 예입니다. 직렬화 중 유효한 Date는 toJSON() 메서드를 통해 ISO 타임스탬프 문자열을 생성합니다.
const createdAt = new Date('2026-07-21T12:00:00Z')
const json = JSON.stringify({ createdAt })
console.log(json)
// {"createdAt":"2026-07-21T12:00:00.000Z"}
JSON에는 일반 문자열이 들어 있습니다. 파싱할 때 JavaScript에 이 값이 Date에서 왔다는 타입 표지는 없습니다.
const copy = JSON.parse(json)
console.log(typeof copy.createdAt)
// "string"
console.log(copy.createdAt instanceof Date)
// false
이 모호성 때문에 JSON.parse는 타입을 자동 복원할 수 없습니다. 타임스탬프처럼 보이는 값은 Date였을 수도 있고 처음부터 문자열이었을 수도 있습니다. 일치하는 모든 문자열을 Date로 변환하면 정당한 텍스트를 바꾸고 다른 종류의 데이터 오류를 일으킵니다.
직렬화는 원래 시간대 문맥도 제거합니다. Date는 한 시점을 저장하고 UTC로 내보내므로 -04:00 같은 입력 오프셋은 변환 뒤에 남지 않습니다.
const scheduledAt = new Date('2026-07-21T09:00:00-04:00')
console.log(JSON.stringify({ scheduledAt }))
// {"scheduledAt":"2026-07-21T13:00:00.000Z"}
시점은 같지만 원래 벽시계 시간과 오프셋은 사라졌습니다. Your JS Date Is Lying to You에서 날짜와 시간대 의도의 이러한 손실을 더 자세히 다룹니다.
다른 내장 타입은 더 많은 정보를 잃습니다. Map, Set, RegExp, Error는 의미 있는 상태를 내부 또는 열거 불가능한 속성에 저장하므로 기본 JSON 표현은 빈 객체입니다.
const original = {
tags: new Set(['json', 'js']),
lookup: new Map([['answer', 42]]),
pattern: /json/i,
error: new Error('failed')
}
const copy = JSON.parse(JSON.stringify(original))
console.log(copy)
// { tags: {}, lookup: {}, pattern: {}, error: {} }
결과의 어떤 빈 객체도 원래 타입을 식별하지 못합니다. 내용도 버려졌으므로 수신자는 JSON만으로 이를 재구성할 수 없습니다.
특수 숫자 값도 다른 변환 규칙을 따릅니다. JSON 숫자는 NaN이나 어느 부호의 무한대도 표현할 수 없으므로 JSON.stringify는 이들을 null로 씁니다.
const measurements = {
current: NaN,
upper: Infinity,
lower: -Infinity
}
console.log(JSON.stringify(measurements))
// {"current":null,"upper":null,"lower":null}
null은 유효한 JSON이고 파싱도 성공하므로 이 변환은 특히 놓치기 쉽습니다. 이후 코드는 사용할 수 없는 측정값과 NaN을 만든 계산, 또는 무한대로 표현한 제한 없는 값을 더 이상 구별할 수 없습니다.
형식화 배열은 인덱스 값은 유지하지만 타입과 이진 해석을 잃습니다. 열거 가능한 인덱스가 일반 객체 속성이 됩니다.
const bytes = new Uint8Array([10, 20, 30])
const copy = JSON.parse(JSON.stringify(bytes))
console.log(copy)
// { 0: 10, 1: 20, 2: 30 }
console.log(copy instanceof Uint8Array)
// false
클래스 인스턴스도 비슷합니다. 열거 가능한 데이터 필드는 남을 수 있지만 파싱한 객체는 원래 클래스, 프로토타입, 비공개 상태와 연결되지 않습니다.
class User {
#role = 'admin'
constructor(name) {
this.name = name
}
greet() {
return `Hello, ${this.name}`
}
}
const original = new User('Alice')
const copy = JSON.parse(JSON.stringify(original))
console.log(copy)
// { name: "Alice" }
console.log(copy instanceof User)
// false
console.log(typeof copy.greet)
// "undefined"
이러한 변환은 코드가 파싱한 값을 사용할 때까지 숨겨질 수 있습니다. Date가 기대된 곳의 문자열은 그럴듯해 보이고, 빈 객체는 폭넓은 객체 검사를 통과하며, null은 여러 계층을 지나갈 수 있고, 일반 객체는 나중 연산이 실패할 때까지 디코드된 이진 데이터처럼 보일 수 있습니다. 가장 안전한 방법은 모든 비 JSON 타입의 직렬화 형식을 명시적으로 정의하고 양쪽이 이해하는 계약을 통해서만 복원하는 것입니다.
JSON.stringify는 저장된 값만 검사하지 않습니다. 직렬화 훅을 호출하고 일반 JavaScript 연산으로 속성을 읽으므로, 객체를 JSON으로 바꾸면 애플리케이션이나 라이브러리 코드가 실행될 수 있습니다.
가장 직접적인 훅은 toJSON()입니다. 객체가 이 메서드를 제공하면 JSON.stringify는 이를 호출하고 원래 객체 대신 반환 값을 직렬화합니다.
const user = {
name: 'Alice',
password: 'secret',
toJSON() {
return { name: this.name }
}
}
console.log(JSON.stringify(user))
// {"name":"Alice"}
toJSON()이 이름만 담은 새 객체를 반환하므로 암호는 사라집니다. 이는 공개 표현을 정의하는 유용한 방법일 수 있지만, 이제 출력은 호출 지점에서 객체를 읽을 때 놓치기 쉬운 동작에 의존합니다.
Date도 같은 메커니즘을 씁니다. toJSON() 메서드는 toISOString()을 호출하므로 이전 절에서는 날짜 필드를 가진 객체 대신 UTC 문자열이 생성되었습니다.
const date = new Date('2026-07-21T12:00:00Z')
console.log(date.toJSON())
// "2026-07-21T12:00:00.000Z"
console.log(JSON.stringify(date))
// "2026-07-21T12:00:00.000Z"
사용자 정의 메서드는 일반 JavaScript에서 가능한 모든 작업을 할 수 있습니다. 값을 계산하거나, 상태를 변경하거나, 외부 상태에 접근하거나, 예외를 던질 수 있으며, 이 모든 동작이 직렬화의 일부가 됩니다.
let serializations = 0
const report = {
value: 42,
toJSON() {
serializations += 1
return { value: this.value }
}
}
JSON.stringify(report)
JSON.stringify(report)
console.log(serializations)
// 2
게터도 toJSON()을 정의하지 않고 비슷한 효과를 만듭니다. 열거 가능한 속성이 게터로 뒷받침되면 직렬화 중 그 속성을 읽을 때 게터가 호출됩니다.
const invoice = {
subtotal: 100,
get total() {
console.log('calculating total')
return this.subtotal * 1.2
}
}
console.log(JSON.stringify(invoice))
// logs "calculating total"
// then {"subtotal":100,"total":120}
직렬화 값에는 total이 데이터 속성으로 저장된 적이 없어도 게터 결과가 들어갑니다. 예외를 던지는 게터는 JSON.stringify도 예외를 던지게 하므로 로깅이나 오류 보고가 또 다른 실패 경로가 될 수 있습니다.
const value = {
get secret() {
throw new Error('secret is unavailable')
}
}
JSON.stringify(value)
// Error: secret is unavailable
호출자는 JSON.stringify의 선택적 두 번째 인수인 대체 함수를 통해 또 하나의 동작 계층을 추가할 수 있습니다. 대체 함수는 루트 값과 방문한 각 속성에 호출되며 반환 값이 직렬화 대상을 결정합니다.
const user = {
name: 'Alice',
password: 'secret',
lastLogin: new Date('2026-07-21T12:00:00Z')
}
const json = JSON.stringify(user, (key, value) => {
if (key === 'password') return undefined
return value
})
console.log(json)
// {"name":"Alice","lastLogin":"2026-07-21T12:00:00.000Z"}
undefined를 반환하면 앞서 다룬 객체 규칙에 따라 password 속성이 제거됩니다. 대체 함수는 Date 객체의 toJSON() 메서드가 변환한 뒤의 lastLogin을 받으므로, 콜백은 원래 Date 인스턴스가 아니라 ISO 문자열을 봅니다.
대체자는 속성 이름 배열일 수도 있습니다. 이 형식에서는 객체 속성의 허용 목록으로 동작합니다.
const user = {
name: 'Alice',
email: 'alice@example.com',
password: 'secret',
role: 'admin'
}
console.log(JSON.stringify(user, ['name', 'role']))
// {"name":"Alice","role":"admin"}
이 훅들은 JSON.stringify를 의도적인 전송 형태와 사용자 정의 인코딩을 지원할 만큼 유연하게 만듭니다. 동시에, 특히 값이 의존성이나 다른 위치에 구현된 클래스에서 올 때 보이는 필드만으로 출력을 항상 예측할 수 없다는 뜻이기도 합니다. 낯선 객체를 직렬화하는 코드는 이 연산을 실행 가능한 동작으로 취급하고 값 변환이나 예외를 예상해야 합니다.
JSON은 중첩 객체와 배열을 기술할 수 있지만 객체 동일성을 기술할 방법은 없습니다. 두 속성이 같은 객체를 가리키면 JSON 표현에는 그 객체 데이터의 독립된 복사본 두 개가 들어갑니다. 객체가 자기 자신을 다시 가리키면 직렬화는 전혀 계속할 수 없습니다.
const shared = { name: 'Alice' }
const original = {
owner: shared,
reviewer: shared
}
const copy = JSON.parse(JSON.stringify(original))
console.log(original.owner === original.reviewer)
// true
console.log(copy.owner === copy.reviewer)
// false
직렬화 전에는 두 속성이 하나의 객체를 공유합니다. JSON은 참조가 아닌 값을 기록하므로 파싱 뒤에는 같은 필드를 지닌 별도 객체가 됩니다.
순환 참조는 이 모델의 한계에 도달합니다. 직렬화기는 순환을 감지하고 TypeError를 던집니다.
const value = { name: 'loop' }
value.self = value
JSON.stringify(value)
// TypeError: Converting circular structure to JSON
순환 구조는 부모 링크, 그래프, 캐시, 프레임워크 객체에서 자연스럽게 나타납니다. 직렬화는 나중에 로깅, 영속화, HTTP 응답 중 일어나므로 예외는 순환을 만든 코드에서 멀리 떨어진 곳에 나타나는 경우가 많습니다.
대체자로 순환 속성을 제거하면 값을 직렬화 가능하게 만들 수 있지만, 그 선택은 데이터 모델을 바꿉니다. 동일성을 보존해야 하는 그래프에는 생성한 식별자와 그 식별자를 가리키는 필드 같은 명시적 참조 형식이 필요합니다.
속성 가시성도 또 하나의 한계입니다. 일반 객체에서 JSON.stringify는 키가 문자열인 열거 가능한 자체 속성만 고려합니다. 상속 속성, 열거 불가능한 속성, 심볼 키 속성, 비공개 필드는 JSON 표현 밖에 남습니다.
const inherited = { inherited: 'from prototype' }
const value = Object.create(inherited)
value.visible = 'included'
Object.defineProperty(value, 'hidden', {
value: 'non-enumerable',
enumerable: false
})
value[Symbol('internal')] = 'symbol value'
console.log(JSON.stringify(value))
// {"visible":"included"}
생략된 각 속성은 JavaScript에서 접근할 수 있지만 열거 가능한 자체 문자열 키 속성 조건을 만족하지 않습니다. 따라서 직렬화는 코드가 읽을 수 있는 모든 것을 복사하는 대신 객체의 속성 기술자를 따릅니다.
클래스 인스턴스도 같은 경계를 드러냅니다. 생성자가 할당한 공개 필드는 대개 열거 가능하여 남지만 프로토타입 메서드와 비공개 필드는 남지 않습니다.
class User {
#role = 'admin'
constructor(name) {
this.name = name
}
greet() {
return `Hello, ${this.name}`
}
}
const user = new User('Bob')
const json = JSON.stringify(user)
console.log(json)
// {"name":"Bob"}
그 텍스트를 파싱하면 name 속성을 가진 일반 객체가 나옵니다. User 프로토타입이 없고 greet()를 호출할 수 없으며 비공개 역할도 포함하지 않습니다.
const copy = JSON.parse(json)
console.log(copy instanceof User) // false
console.log(typeof copy.greet) // "undefined"
이 동작 때문에 임의의 도메인 객체는 전송 데이터의 취약한 원천이 됩니다. 경계에서 일반 전송 객체를 구성하면 직렬화되는 모든 필드를 의도적으로 선택하게 되고 내부 구현 세부 사항을 외부 계약과 분리합니다.
function toUserResponse(user) {
return {
name: user.name
}
}
const response = toUserResponse(user)
console.log(JSON.stringify(response))
// {"name":"Bob"}
이 예제에서는 출력이 암묵적인 클래스 직렬화와 우연히 같지만, 이제 의도는 코드에 보입니다. 열거 가능성, 프로토타입, 비공개 구현 세부 사항의 변경이 응답 형태를 조용히 재정의할 수 없습니다.
프로토타입 오염은 악의적인 페이로드가 흔히 JSON으로 도착하므로 JSON.parse 문제로 자주 설명됩니다. 파싱만으로는 Object.prototype이 바뀌지 않습니다. JSON의 "__proto__" 멤버는 그 이름을 가진 자체 데이터 속성이 됩니다.
const value = JSON.parse('{"__proto__":{"admin":true}}')
console.log(Object.hasOwn(value, '__proto__'))
// true
console.log(value.__proto__)
// { admin: true }
console.log({}.admin)
// undefined
파싱한 객체에는 신뢰할 수 없는 데이터가 있지만 다른 객체는 바뀌지 않습니다. 위험은 이후 코드가 그 데이터를 다른 객체를 수정하는 명령으로 해석할 때 나타납니다.
재귀 병합 도우미는 파싱한 값의 모든 키를 순회하고 대상의 일치하는 속성으로 내려갈 수 있습니다. __proto__, constructor 같은 특수 이름은 그 순회를 일반 애플리케이션 소유 객체 대신 프로토타입으로 이끌 수 있습니다. 그곳에서 수행한 할당은 상속을 통해 보이게 될 수 있습니다.
흐름은 무해한 파싱 단계와 그 뒤의 안전하지 않은 사용을 분리합니다. 관련 순서는 다음과 같습니다:
untrusted JSON
↓
own properties on the parsed object
↓
unsafe recursive merge
↓
prototype mutation
파서는 두 번째 단계에서 보인 자체 속성을 만듭니다. 병합 연산이 그 속성에 위험한 의미를 부여하므로 보호는 신뢰할 수 없는 키를 받아 복사하는 곳에 있어야 합니다.
스키마 검증은 애플리케이션이 예상하는 필드만 허용하므로 유용한 방어가 됩니다. 예를 들어 name과 email을 담은 사용자 업데이트가 __proto__, constructor, 기타 예상하지 않은 키를 받아들일 이유는 없습니다. 병합 유틸리티도 특히 구성이나 요청 데이터를 재귀 처리할 때 프로토타입 오염 방어를 문서화해야 합니다.
객체 전개는 오래된 여러 병합 패턴과 다른 의미를 가집니다. 파싱한 값을 전개하면 기존 프로토타입 설정자를 호출하지 않고 새 객체에 자체 __proto__ 속성을 만듭니다.
const parsed = JSON.parse('{"__proto__":{"admin":true}}')
const copy = { ...parsed }
console.log(Object.hasOwn(copy, '__proto__'))
// true
console.log(Object.getPrototypeOf(copy) === Object.prototype)
// true
이것이 객체 전개의 모든 사용을 안전하게 만드는 것은 아닙니다. 특정 __proto__ 설정자 동작이 할당 및 재귀 병합 구현과 다르다는 뜻입니다. 예상하지 못한 키는 프로토타입을 바꾸지 않아도 애플리케이션 논리에 영향을 줄 수 있으므로 검증은 여전히 필요합니다.
자원 고갈은 별도의 파싱 문제입니다. 유효한 JSON 문서도 대량의 데이터나 극심한 중첩을 담으면 상당한 메모리와 CPU를 소비할 수 있고, 보통 파싱은 애플리케이션 수준 검증이 결과 값을 검사하기 전에 일어납니다.
const deeplyNested = '['.repeat(100_000) + ']'.repeat(100_000)
JSON.parse(deeplyNested)
정확한 한계와 실패 방식은 런타임에 따라 다릅니다. HTTP 서버는 요청 본문을 파싱하기 전에 너무 큰 본문을 거부해야 하며, 복잡한 JSON을 받는 시스템은 중첩, 컬렉션 크기, 총 처리 시간의 제한도 필요할 수 있습니다.
리바이버 함수는 JSON.parse가 파싱 구조 전체에 걸쳐 이를 호출하므로 더 많은 작업을 추가할 수 있습니다. 계산 비용이 큰 리바이버는 신뢰할 수 없는 문서의 값 수에 따라 비용이 곱해지므로 콜백은 작고 예측 가능해야 합니다.
따라서 안전한 JSON 처리는 문법 검사 이상을 뜻합니다. 파싱 전에 입력을 제한하고 결과 형태를 허용 목록에 맞춰 검증하세요. 병합 코드는 신뢰할 수 없는 속성 이름을 객체 그래프의 경로가 아니라 데이터로 취급해야 합니다.
JSON.parse는 두 번째 인수로 선택적 리바이버 함수를 받습니다. 텍스트를 파싱한 뒤 결과 구조를 순회하며 콜백이 각 값을 교체하거나 제거할 기회를 줍니다.
const json = '{"name":"Alice","score":"42"}'
const parsed = JSON.parse(json, (key, value) => {
if (key === 'score') return Number(value)
return value
})
console.log(parsed)
// { name: "Alice", score: 42 }
리바이버는 포함 객체보다 먼저 자식 값을 받고, 이후 빈 문자열 키 아래 루트 값을 받습니다. 대체 값을 반환하면 그 위치에 저장한 값이 바뀌고, undefined를 반환하면 객체 속성을 제거하거나 배열에 구멍을 만듭니다.
이 훅은 직렬화 표현에 충분한 정보가 있을 때 타입을 복원할 수 있습니다. 태그된 객체는 의도한 변환을 명시합니다:
const json = JSON.stringify({
createdAt: {
$type: 'Date',
value: '2026-07-21T12:00:00.000Z'
}
})
const parsed = JSON.parse(json, (key, value) => {
if (value?.$type === 'Date') {
return new Date(value.value)
}
return value
})
console.log(parsed.createdAt instanceof Date)
// true
$type 필드는 생산자와 소비자가 공유하는 작은 직렬화 프로토콜의 일부입니다. 생산자는 원래 타입을 기록하고 소비자는 그 타입을 어떻게 재구성할지 결정합니다.
흔한 지름길은 ISO처럼 보이는 모든 문자열을 Date로 취급하는 것입니다. 이 방법은 텍스트로 남아야 했던 문자열을 바꿉니다.
const json = JSON.stringify({
createdAt: '2026-07-21T12:00:00.000Z',
searchQuery: '2026-07-21T12:00:00.000Z'
})
const parsed = JSON.parse(json, (key, value) => {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
return new Date(value)
}
return value
})
console.log(parsed.createdAt instanceof Date) // true
console.log(parsed.searchQuery instanceof Date) // true
콜백에는 searchQuery가 날짜를 나타낸다는 증거가 없습니다. 문자열 형태만 인식하므로 유효한 텍스트가 다른 동작을 하는 변경 가능한 객체로 변환됩니다.
대체자와 리바이버는 같은 명시적 계약을 구현할 때 가장 신뢰할 수 있습니다. 대체자는 직렬화 전에 태그된 값을 내보내고 리바이버는 파싱 뒤에 해당 태그를 인식할 수 있습니다.
const original = {
requestId: 9007199254740993n
}
const json = JSON.stringify(original, (key, value) => {
if (typeof value === 'bigint') {
return { $type: 'BigInt', value: value.toString() }
}
return value
})
const copy = JSON.parse(json, (key, value) => {
if (value?.$type === 'BigInt') {
return BigInt(value.value)
}
return value
})
console.log(copy.requestId)
// 9007199254740993n
숫자 자리가 문자열로 전달되므로 태그는 앞서 논의한 정밀도 손실을 피합니다. 또한 모든 십진 문자열에 숨은 의미를 부여하지 않고 JSON에서 변환을 드러냅니다.
리바이버는 신뢰할 수 없는 출처가 제공한 데이터를 포함하여 파싱 구조 전체에서 코드를 실행합니다. 알려진 태그만 받아들이고 관련 페이로드를 검증해야 합니다. 값당 작업은 저렴하게 유지하세요. 더 큰 계약에서는 하나의 콜백 안에서 조건 모음이 늘어나는 것보다 전용 코덱이나 스키마 변환이 대개 테스트하기 쉽습니다.
앞 절의 문제들은 하나의 원인을 공유합니다. 바로 암묵적 계약입니다. 송신자는 가진 객체를 무엇이든 직렬화하고 수신자는 파싱 결과가 같은 의미를 담는다고 가정합니다. 신뢰할 수 있는 경계는 데이터가 프로세스를 떠나기 전에 그 의미를 명시합니다.
수신자가 필요한 필드로 전송 객체를 구성하세요. 이렇게 하면 클래스 내부, 임시 상태, 계산된 필드, 우연히 열거 가능한 속성이 외부 표현에 들어가지 않습니다.
function toUserResponse(user) {
return {
id: user.id.toString(),
name: user.name,
createdAt: user.createdAt.toISOString(),
roles: [...user.roles]
}
}
변환은 예외 타입도 문서화합니다. 식별자는 십진 문자열로, 날짜는 UTC 타임스탬프로 전달되고, 역할 집합은 순서 있는 JSON 배열이 됩니다. User 클래스의 향후 변경은 변환 함수도 함께 바뀌지 않는 한 이 응답을 바꾸지 않습니다.
JSON.parse는 문법을 검사하고 JavaScript 값을 반환합니다. 객체에 애플리케이션이 기대하는 필드가 있는지 또는 그 필드가 도메인 제약을 만족하는지는 검증하지 않습니다.
const value = JSON.parse('{"name":42,"createdAt":"yesterday"}')
// valid JSON, invalid user data
TypeScript는 타입이 컴파일 중 제거되므로 이 검사를 제공할 수 없습니다. 정적 어노테이션은 프로그램이 기대하는 값을 설명할 수 있지만 런타임에 받은 바이트를 검사하지는 않습니다.
Zod, Valibot, TypeBox 같은 스키마 라이브러리를 사용하면 애플리케이션이 파싱 데이터의 런타임 규칙을 정의할 수 있습니다. 정확한 API는 다르지만 목적은 같습니다. 비즈니스 코드가 값을 신뢰하기 전에 유효하지 않은 값을 거부하는 것입니다.
import { z } from 'zod'
const UserResponse = z.object({
id: z.string().regex(/^\d+$/),
name: z.string(),
createdAt: z.iso.datetime(),
roles: z.array(z.string())
})
const user = UserResponse.parse(JSON.parse(text))
검증은 파싱 중 이미 사라진 정보를 복원할 수 없습니다. 큰 정수가 JSON 숫자로 JavaScript에 들어왔다면 스키마는 반올림된 결과를 봅니다. 먼저 와이어 표현이 값을 보존해야 하며, 그 뒤에 검증이 형태와 의미를 검사할 수 있습니다.
큰 정수와 이진 데이터는 JSON이 직접 표현할 수 없으므로 문서화된 인코딩이 필요합니다. 날짜와 다른 타입 값도 원래 타입을 유지해야 한다면 명시적 규약이 필요합니다.
{
"requestId": "9007199254740993",
"createdAt": {
"$type": "Date",
"value": "2026-07-21T12:00:00.000Z"
},
"avatarBase64": "iVBORw0KGgoAAAANSUhEUgAA..."
}
계약은 각 표현을 어떻게 검증하고 디코드하는지 설명해야 합니다. 문자열 형태에서 추측하는 것보다 필드 이름이나 타입 태그가 바람직합니다.
객체 멤버 순서가 시스템 사이에서 워크플로 또는 우선순위 의미를 담아서는 안 됩니다. 순서 있는 값은 배열에 넣고 각 요소의 의미를 명시하세요.
{
"steps": [
{ "name": "validate" },
{ "name": "charge" },
{ "name": "confirm" },
{ "name": "notify" }
]
}
바이트 수준 비교에는 배열 변환이 아니라 정준화가 필요합니다. JSON에서 서명, 해싱, 중복 제거, 캐시 키 생성을 한다면 모든 생산자가 공유하는 문서화된 정준 형식을 사용해야 합니다.
애플리케이션 수준 검증은 할당과 파싱이 이미 시작된 뒤 실행되므로 JSON.parse를 호출하기 전에 너무 큰 본문을 거부하세요. 깊이 구조화된 문서를 받는 시스템은 중첩과 컬렉션 크기 제한도 필요할 수 있습니다.
리바이버도 같은 주의가 필요합니다. 그 작업은 파싱 구조 전체의 값에 대해 실행되므로 작은 불필요한 처리도 큰 입력에서는 비싸질 수 있습니다.
직렬화기 테스트는 한 생산자가 무엇을 내보내는지만 증명합니다. 경계 테스트는 수신 구현으로도 표현을 파싱하고 살아남는 의미를 검증해야 합니다.
예를 들어 JavaScript 클라이언트와 Go 서비스는 큰 식별자가 두 런타임을 통과하는 과정을 테스트해야 합니다. 서명된 JSON 테스트는 서로 다른 속성 순서로 동등한 객체를 구성하고 정준화가 같은 바이트를 만드는지 확인해야 합니다.
JSON은 읽기 쉽고 널리 상호 운용 가능한 데이터에 여전히 강력한 기본값입니다. 압축된 이진 출력, 타입 있는 바이트 문자열, 더 넓은 숫자 모델, 확장 메커니즘이 계약의 일부일 때는 다른 형식이 더 잘 맞을 수 있습니다.
MessagePack은 압축된 이진 표현을 제공하고 핵심 모델 밖 값에 대한 확장 타입을 지원합니다. CBOR는 텍스트와 바이트 문자열을 구별하고 추가 의미를 위한 태그를 제공하며 더 넓은 숫자 표현 범위를 지원합니다.
어느 형식도 임의의 JavaScript 객체를 자동 보존하지는 않습니다. 생산자와 소비자는 여전히 확장, 태그, 숫자 처리, 디코딩 뒤 적용할 스키마에 합의해야 합니다. 형식을 바꾸면 이용 가능한 어휘가 늘어날 뿐 계약의 필요성은 사라지지 않습니다.
도입부의 객체는 여러 방식으로 바뀌었지만 각 결과는 정의된 규칙을 따랐습니다. JavaScript는 직렬화 전에 큰 정수를 반올림했고, undefined에는 JSON 표현이 없었으며, Date는 toJSON()을 통해 문자열을 제공했고, NaN은 null이 되었습니다.
const original = {
id: 9007199254740993,
missing: undefined,
createdAt: new Date('2026-07-21T12:00:00Z'),
score: NaN
}
console.log(JSON.parse(JSON.stringify(original)))
// {
// id: 9007199254740992,
// createdAt: "2026-07-21T12:00:00.000Z",
// score: null
// }
JSON은 데이터 모델이 작고 널리 이해되므로 계속 유용합니다. JSON이 망가진 것은 아닙니다. 의도적으로 작은 교환 형식을 JavaScript 상태의 무손실 스냅샷으로 취급하는 것이 실수입니다. JavaScript 객체와 JSON 표현은 명시적 변환으로 연결된 서로 다른 데이터 모델로 취급해야 합니다.
신뢰할 수 있는 접근은 직렬화가 시작되기 전에 어떤 정보가 살아남아야 하는지 결정하는 것입니다. 큰 정수에는 정확한 인코딩이 필요하고, 타입 값에는 문서화된 표현이 필요하며, 순서 있는 데이터는 배열에 속하고, 신뢰할 수 없는 입력은 파싱 후 검증이 필요합니다. 테스트는 생성된 문자열에서 멈추지 말고 생산자와 수신자를 통과하는 전체 경로를 다뤄야 합니다.
따라서 JSON.parse(JSON.stringify(value))는 범용 복제가 아니라 변환입니다. 값이 이미 JSON용으로 설계되었을 때는 작동하지만, 애플리케이션 타입이 우연히 경계를 넘도록 허용되면 신뢰할 수 없게 됩니다.
와이어 형태와 예외 인코딩을 정의한 뒤 수신 측에서 결과를 검증하세요. 이러한 결정이 코드와 스키마에 보이면 애플리케이션이 JSON이 표현할 수 있는 것만 전달하도록 요청하므로 JSON은 다시 예측 가능해집니다.
JSON은 이 시리즈의 다른 글과도 이러한 숨은 가정 패턴을 공유합니다. Your JS Date Is Lying to You는 직렬화 중 사라지는 날짜와 시간대 의도를 살펴보고, Your console.log Is Lying to You는 JSON 복제 역시 불완전한 디버깅 스냅샷인 이유를 설명합니다.
나머지 글들은 같은 개념을 다른 익숙한 추상화에 적용합니다. 각 글은 개발자가 가정하는 보장과 도구가 실제로 제공하는 동작 사이의 간극을 살펴봅니다: