코드스피츠 (무료 인터넷 강의) 유튜브의 프로그래밍 101 (JavaScript를 기반으로 프로그래밍 기초를 알려주는 강의)에서 재귀 함수와 반복문에 대한 차이를 설명해주면서 JSON.stringify 함수에 대해서 짜보라는 숙제를 주셨습니다.
stringify 함수는 기본적으로 Object 형태일 경우 트리 형태로 무한한 depth를 가질 수 있기 때문에, 적절한 재귀 함수를 사용해야 하기 때문인데요.
간단하게 stringify 함수를 구현하여 과제는 끝마쳤지만, JSON 객체에 대한 궁금증이 생겨서 JavaScript로 JSON 클래스를 고도화해서 커스텀하게 만들어 보고 싶어졌습니다.
이번 글에서는 JavaScript로 커스텀 JSON 클래스를 만들어, JSON.stringify와 JSON.parse를 나만의 방식으로 구현하는 방법을 소개하려고 합니다. 더 나아가 순환 참조 처리, 심볼 처리와 같은 기능도 함께 구현해보고 설명해드리겠습니다.
1. JSON Object란?
JSON (JavaScript Object Notation)은 데이터를 표현하기 위한 표준 형식입니다. 주로 서버와 클라이언트 간의 데이터 전송에 사용됩니다. JSON 객체는 키-값 쌍으로 이루어진 데이터를 저장하는 데 사용됩니다. 자바스크립트에서 JSON은 내장 객체로 제공되며, JSON.parse()와 JSON.stringify() 메서드를 통해 객체와 JSON 문자열 간의 변환을 지원합니다.
2. JSON 공식문서 참고 사항
JSON은 JavaScript 객체 표기법(JavaScript Object Notation)을 기반으로 하며, 공식 문서에 따르면 JSON은 텍스트 기반의 데이터 형식으로, 언어 독립적입니다. 대부분의 현대 프로그래밍 언어에서는 JSON을 처리할 수 있는 라이브러리나 내장 함수를 제공합니다.
- 키-값 쌍: JSON에서 키는 문자열이어야 하며, 값은 문자열, 숫자, 불린 값, 배열, 객체 등 다양한 형태가 될 수 있습니다.
- 형식: JSON 데이터는 반드시 문자열로 인코딩되어야 하며, 이 형식은 UTF-8을 사용하여 인코딩됩니다.
- JavaScript와의 관계: JSON은 JavaScript의 객체 표기법을 따르지만, 다른 언어에서도 동일한 형식으로 사용할 수 있습니다.
JSON 공식 문서에서는 JSON의 구문과 규칙, 그리고 각 언어에서 JSON을 다루는 방법에 대해 자세히 설명하고 있습니다.
3. JSON.parse() 함수 설명
JSON.parse()는 JSON 문자열을 JavaScript 객체로 변환하는 함수입니다. JSON 데이터는 보통 서버와 클라이언트 간에 텍스트 형식으로 전송되므로, 이를 JavaScript에서 사용할 수 있도록 변환할 때 JSON.parse()를 사용합니다.
const jsonString = '{"name": "John", "age": 30, "isActive": true}';
const obj = JSON.parse(jsonString);
console.log(obj.name); // "John"
console.log(obj.age); // 30
위 예시에서 JSON.parse()는 JSON 문자열을 JavaScript 객체로 변환합니다. 이를 통해 텍스트 형식의 JSON 데이터를 자바스크립트에서 사용할 수 있는 객체로 변환하여 다양한 처리를 할 수 있습니다.
4. JSON.stringify() 함수 설명
JSON.stringify()는 JavaScript 객체를 JSON 문자열로 변환하는 함수입니다. 이를 통해 객체 데이터를 텍스트 형식의 JSON으로 변환하여 서버로 전송하거나 데이터베이스에 저장할 수 있습니다.
const person = {
name: "John",
age: 30,
isActive: true,
};
const jsonString = JSON.stringify(person);
console.log(jsonString);
// 출력: '{"name":"John","age":30,"isActive":true}'
JSON.stringify()를 사용하면 JavaScript 객체를 JSON 형식의 문자열로 변환할 수 있습니다. 이 과정에서 객체 내의 함수나 undefined 값은 제외되며, Date 객체는 ISO 형식의 문자열로 변환됩니다.
5. Custom JSON 클래스 구현
먼저, 기본적인 JSON 클래스의 stringify와 parse 함수를 구현해봅니다. stringify는 객체를 JSON 문자열로 변환하고, parse는 JSON 문자열을 객체로 변환하는 역할을 합니다.
5-1. 기본적인 CustomJSON class
class CustomJSON {
static stringify(value) {
return CustomJSON.serialize(value);
}
static serialize(value) {
if (value === null) return "null";
if (typeof value === "string") return `"${value}"`;
if (typeof value === "number" || typeof value === "boolean") return String(value);
// 배열 처리
if (Array.isArray(value)) {
const arrayValues = value.map(item => CustomJSON.serialize(item));
return `[${arrayValues.join(",")}]`; // JSON 배열 포맷 유지
}
// 객체 처리
if (typeof value === "object") {
const keyValuePairs = Object.keys(value).map(key => {
const serializedValue = CustomJSON.serialize(value[key]);
return `"${key}":${serializedValue}`;
});
return `{${keyValuePairs.join(",")}}`;
}
return "null"; // 변환할 수 없는 값
}
}
우선 Stringify 함수 위주로 작성해보았습니다. 해당 함수의 요구사항 핵심은 'string', 'number', 'boolean' type의 경우 바로 문자열로 전환하고, Array, object 타입 인 경우 요소를 반복하며 stringify 함수를 호출 하는 것 입니다.
기본적인 처리는 끝났지만, MDN 문서에 따르면 심볼 처리와 순환 참조에 대한 언급이 나오는데요. 각각이 무엇을 의미하는지, 어떤 상황에서 문제가 발생하고 어떻게 처리하는지 알아보겠습니다.
5-2. 심볼 처리
심볼은 JavaScript에서 고유하고 변경 불가능한 값을 생성할 수 있는 자료형입니다. 심볼은 객체의 속성 이름으로 주로 사용되며, 기본적으로 JSON.stringify와 같은 메서드에서는 심볼 속성을 무시합니다. 이로 인해 심볼을 객체의 속성으로 사용하더라도 해당 속성은 직렬화 시 제외됩니다.
const sym = Symbol('id');
const obj = { name: 'John', [sym]: 123 };
console.log(JSON.stringify(obj)); // 심볼 속성 제외, {"name":"John"}
심볼을 문자열로 변환하거나, 특정 심볼 속성만을 포함시키는 방법을 추가할 수 있습니다. 아래와 같이요.
- Object.getOwnPropertySymbols()을 사용해 객체의 심볼 속성도 포함할 수 있도록 처리.
- Symbol 값 자체를 문자열로 변환하여 유지 (Symbol(description) → "Symbol(description)").
- 원래 JSON.stringify()에서는 Symbol 키는 무시되지만, 이 구현에서는 옵션에 따라 포함할 수 있도록 지원.
class CustomJSON {
static stringify(value, includeSymbols = false) {
function serialize(obj) {
if (obj === null) return "null";
if (typeof obj === "string") return `"${obj}"`;
if (typeof obj === "number" || typeof obj === "boolean") return String(obj);
if (typeof obj === "symbol") return `"Symbol(${obj.description})"`; // 심볼을 문자열로 변환
// 배열 처리
if (Array.isArray(value)) {
const arrayValues = value.map(item => CustomJSON.serialize(item));
return `[${arrayValues.join(",")}]`;
}
if (typeof obj === "object") {
if (Array.isArray(obj)) {
return `[${obj.map(serialize).join(",")}]`;
} else {
const keys = Object.keys(obj);
if (includeSymbols) {
keys.push(...Object.getOwnPropertySymbols(obj).map(sym => sym.toString()));
}
const keyValuePairs = keys.map(key => {
const val = serialize(obj[key]);
return `"${key}":${val}`;
});
return `{${keyValuePairs.join(",")}}`;
}
}
return "null"; // 변환할 수 없는 값
}
return serialize(value);
}
}
코드는 위와 같은데요. 굳이 serialize라는 함수를 또 정의하여 사용한 이유는 기능적으로는 stringify 를 호출해도 상관없지만, 저는 함수에 재귀를 사용하는 경우 가독성을 위해 새로운 함수를 만드는 편입니다. ( 지금 보면 굳이 필요없는거 같긴 하지만, 습관이 되었습니다. )
5-3. 순환 참조 처리
순환 참조는 객체가 다른 객체를 참조하고, 그 객체가 다시 원래 객체를 참조하는 구조를 말합니다. 즉, 객체 간의 관계가 순환하는 구조를 가진 경우, 이를 순환 참조라고 합니다.
JSON 의 stringify 함수에서는 이러한 순환 참조를 기본적으로 호환하진 않아, 아래와 같은 상황에 오류가 발생합니다.
const objA = {};
const objB = { a: objA };
objA.b = objB;
console.log(JSON.stringify(objA)); // 오류 발생
위 코드에서 objA는 objB를 참조하고, objB는 다시 objA를 참조합니다. 이렇게 되면 JSON.stringify는 objA를 문자열로 변환할 때 끝없이 순환하면서 참조를 따라가게 되며, 결과적으로 무한루프에 빠지게 됩니다. 이로 인해 stack overflow 오류가 발생할 수 있습니다.
JSON 의 원본 클래스를 사용하는 경우 아래 와 같이 replacer function 을 통해 해결 가능한데요.
function getCircularReplacer() {
const ancestors = [];
return function (key, value) {
if (typeof value !== "object" || value === null) {
return value;
}
// `this` is the object that value is contained in,
// i.e., its direct parent.
while (ancestors.length > 0 && ancestors.at(-1) !== this) {
ancestors.pop();
}
if (ancestors.includes(value)) {
return "[Circular]";
}
ancestors.push(value);
return value;
};
}
JSON.stringify(circularReference, getCircularReplacer());
// {"otherData":123,"myself":"[Circular]"}
const o = {};
const notCircularReference = [o, o];
JSON.stringify(notCircularReference, getCircularReplacer());
// [{},{}]
// 출처 : https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value
CustomJSON 클래스에는 이러한 순환참조를 기본적으로 호환되게끔 고도화 해보겠습니다.
class CustomJSON {
static stringify(value) {
const cache = new WeakSet(); // 순환 참조 감지용
return CustomJSON.serialize(value, cache);
}
static serialize(value, cache) {
// 기본 타입 처리
if (typeof value === "string") return `"${value}"`;
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (value === null) return "null";
// 순환 참조 감지 (WeakSet 활용)
if (typeof value === "object") {
if (cache.has(value)) return '"[Circular]"'; // 순환 참조 발생 시 처리
cache.add(value);
}
// 배열 처리
if (Array.isArray(value)) {
return `[${value.map(item => CustomJSON.serialize(item, cache)).join(",")}]`;
}
// 객체 처리
if (typeof value === "object") {
const keyValuePairs = Object.keys(value) // 🔹 Symbol 속성은 제외됨
.map(key => `"${key}":${CustomJSON.serialize(value[key], cache)}`);
return `{${keyValuePairs.join(",")}}`;
}
return "null";
}
}
CustomJSON.stringify는 순환 참조가 있는 경우 "Circular"라는 문자열을 대신 반환합니다. 순환 참조가 발생한 객체는 Set에 추가하여 추적하고, 같은 객체를 두 번 이상 처리할 때 이를 방지합니다.
Set은 중복을 허용하지 않는 컬렉션으로, 특정 객체가 한 번만 저장되도록 보장합니다. 순환 참조를 처리할 때 다음과 같은 이유로 Set이 적합합니다.
Set은 내부적으로 해시 테이블(Hash Table) 구조를 사용하므로, 특정 객체가 이미 처리된 객체인지 빠르게 확인(O(1))할 수 있습니다.
여기서 추가적으로 WeakSet 자료구조를 사용하였는데요. WeakSet을 사용한 이유는
- 메모리 관리가 자동으로 됨 → 객체가 필요 없어지면 WeakSet에서 자동 제거됨 (GC가 수거)
- 메모리 누수를 방지할 수 있음 → Set은 객체가 삭제돼도 참조가 남아 있어 누수 위험이 큼
JavaScript 의 메모리 관리에 대한 부분은 조금 더 공부해야할 것 같습니다.
후기
사실 마지막 최종 코드에서 순환 참조를 처리할 때 [Circular] 라는 문자열을 반환하도록 하였는데요. 이는 사실 함수의 내결함성을 헤치는 안좋은 코드입니다. (Throw 를 반환하는게 맞습니다. ) 역시, JSON 의 원본 클래스가 그렇게 짜여진데는 이유가 있는 것 이겠죠.
그래서, 다음 글에는 원본 JSON 클래스에 대해 조금 더 자세히 공부해보고 원본 클래스의 기능을 모두 포함하는 CustomJSON 을 만들어 보기로 하겠습니다.
'개발' 카테고리의 다른 글
Vanilla Javascript로 React useState Hook 톺아보기 (0) | 2025.03.09 |
---|---|
Axios - Timeout, Retry 설정법 (API Request & Response) (1) | 2023.08.11 |