Convert to JSON, from JSON
웹 데이터 교환 형식인 JSON의 구조와 JavaScript에서 JSON을 직렬화·역직렬화하는 방법.
클라이언트와 서버가 데이터를 주고받기 위한 통신을 하려면 데이터를 담는 컨테이너의 형태 혹은 규격이 일정해야 한다.
예를 들면 클라이언트는 서버에게 구 모양의 상자에 데이터를 담아 건네주고, 서버는 클라이언트에게 정육면체의 상자에 데이터를 담아서 전달해 주었다고 해보자. 컴퓨터는 사람의 직관을 갖고 있지 않기 때문에, 서로는 전달받은 데이터를 담은 상자를 어느 방향으로, 어떻게 열어야 하는지 알 수 없다. 심지어, 상자를 열더라도 어떤 것들이 무엇을 의미하는 데이터인지 알기도 어렵다.
그래서 이를 방지하기 위해 사람들은 규격을 통일했다. 데이터의 구조를 기술하는 언어를 통해 주고받자고 약속한 것이다. 이것의 시작은 SGML(Standard Generalized Markup Language) 이었다. 그리고 인터넷의 등장 후, 변화한 인프라에 걸맞게 확장된, SGML에서 파생되어 아직까지도 사용되는 XML(eXtensible Markup Language)이 1996년 경 등장하게 되었다.
그리고 20년이 넘게 흐르며, 기술과 인프라는 이전에 비해 엄청나게 발달하고 변화했다. XML 역시나 가독성 문제와 용량이 크다는 단점이 지적되었다. 그래서 등장한 것이 현재 가장 많이 사용되고 있는 JSON(JavaScript Object Notation)이다. ‘자바스크립트는 왜 그 모양일까?’의 저자이기도 한 더글라스 크락포드가 최초로 규정했다.
// XML
<?xml version="1.0" encoding="UTF-8"?>
<person>
<name>Lee</name>
<age>20</age>
<data>
<job>programmer</job>
<major>front-end</major>
<isWorking>false</isWorking>
<company>null</company>
</data>
</person>
// JSON
{
name: "Lee",
age: 20,
data: {
job: "programmer",
major: "front-end",
isWorking: false,
company: null,
},
}‘자바스크립트 객체 표기법’ 이름 그대로, 쓰기 쉽고, 직관적이다. 언어가 아닌 텍스트 기반의 형식이기 때문에 사람이 읽기에도 쉽고 장황하지 않으며, 자바스크립트에서 야기되었지만 다른 언어에서도 사용할 수 있다. 물론, 여전히 XML도 사용되고 있지만 웹 환경에서는 꽤 가시적인 차이가 있다.
XML은 HTML과 구조가 유사한 마크업 언어다. 브라우저는 XML 문서를 문자열로 변환하고, XML 파서를 이용해 구문 분석을 하기 시작한다. 브라우저가 이해할 수 있는 구조로 변환해야 하기 때문이다. 그리고 변환된 데이터를 브라우저에서 이용할 수 있도록 문서의 구조를 나타내는 객체로 생성한다. 이후 Javascript로 하여금 이 객체에 접근하고 조작하여 화면을 업데이트하게 된다.
반면, JSON은 V8 같은 Javascript 엔진을 사용해 JSON 문자열을 읽고 이를 Javascript 객체, 혹은 배열로 직접 변환하여 Javascript로 곧바로 접근 및 조작이 가능해진다. JSON은 Javascript의 하위 집합이므로 별도의 파서 없이 엔진에서 곧바로 해석해 빠르게 화면을 업데이트할 수 있다.
이로 인해 웹 환경에서는 JSON을 이용한 클라이언트와 서버 간의 통신이 XML를 이용하는 것보다 더 빠르고 가벼우며, 네이티브하다.
이 글의 본론을 위한 사전 지식은 여기까지 해두고, 본론을 꺼내보려 한다. Javascript는 JSON 객체를 Javascript 객체로 해석하고, 다시 Javascript 객체를 JSON 객체로 변환할 수 있도록 JSON 전역 객체를 통해 내장 함수들을 지원한다.
const obj = {
name: 'Lee',
age: 20
}
const jsonString = JSON.stringify(obj);
const jsonStringType = typeof jsonString;
// '{"name":"Lee","age":20}'
// 'string'
const jsObj = JSON.parse(jsonString);
const jsObjType = typeof jsObj;
// {name: 'Lee', age: 20}
// 'object'그리고 문득, 정말 엉뚱하게도 내부 구조가 궁금해졌다. 물론 일반적인 자바스크립트 엔진의 JSON 해석 기능은 C++ 언어로 구현되어 있을 테지만, C나 C++ 둘 다 기억이 나질 않으며, Java는 배우고 싶은 언어고, Python은 배운 지 얼마 되지 않아 익숙지 않다. 그러므로 Javascript를 통해 구현해 보기로 했다.
먼저 Javascript 객체를 JSON 문자열로 바꾸는 함수를 구현해 보자. JSON 전역 객체의 stringify 메서드에 해당하는 부분이다. 구현에 앞서 고려할 문제들을 구체적으로 쪼개볼 필요가 있다. 이는 프로그래밍의 기본이다.
인자로 받은 객체를 순회한다. 이때, 자바스크립트의 객체는 for … in 문으로 순회할 수도 있지만, 프로토타입이라는 언어 특성으로 인해 같은 프로토타입 체인 상의 다른 프로퍼티에 접근할 수도 있다. 그러므로 객체 자체에 정의된 프로퍼티 키 배열을 반환하는 Object.keys 메서드를 사용해 순회하는 것이 보다 낫다. 빈 문자열에서 중괄호로 시작한다. 큰 따옴표를 시작하고, 프로퍼티 키 문자열을 넣은 다음, 큰 따옴표를 닫아준다. 읽은 프로퍼티 키에 해당하는 값 부분의 자료형을 체크하고, 자료형에 맞게 처리한다. 전부 순회한 후, 중괄호를 닫아준 문자열을 반환한다.
간단하게 정의하자면 이렇다. 구현하며 어려운 것은 없었으나, 자료형이 문자열 타입일 때 이스케이프 처리하는 것이 조금 까다로웠다. 정규 표현식 객체는 빈 객체로 처리되는 것은 조금 신기했다. 주석으로 각 과정에 대한 부분을 정리했다.
function stringifying(obj) {
// 먼저, 인자의 타입을 체크한다.
const type = typeof obj;
// undefined라면, undefined를 반환한다.
if (type === "undefined") {
return undefined;
}
// NaN/Infinify/-Infinity라면, null을 문자열로 반환한다.
else if (
type === "number" &&
(Number.isNaN(obj) || !Number.isFinite(obj))
) {
return "null";
}
// NaN이 아닌 숫자, 불리언, null이라면, 문자열로 변환해 반환한다.
else if (type === "number" || type === "boolean" || obj === null) {
return String(obj);
}
// 문자열이라면, 큰 따옴표를 앞뒤로 붙여준다.
// 큰따옴표와 백슬래시의 경우를 대비해 이스케이프 문자 처리가 필요하다.
else if (type === "string") {
return (
'"' +
[...obj].reduce(
(pre, cur) => pre + (/[\\\"]+/.test(cur) ? "\\" + cur : cur),
""
) +
'"'
);
}
// 자바스크립트의 배열은 객체이므로 isArray 메서드로 체크한다.
// 배열이라면, 이 함수를 재귀적으로 호출해 요소들을 처리하고,
// 쉼표로 구분해 대괄호로 감싸준다.
else if (type === "object" && Array.isArray(obj)) {
return (
"[" + obj.map((element) => stringifying(element)).join(",") + "]"
);
}
// 객체라면, 해당 객체를 순회하며 이 함수를 재귀적으로 호출해 요소들을 처리하고,
// 쉼표로 구분해 중괄호로 감싸준다.
else if (type === "object") {
return (
"{" +
Object.keys(obj)
.map((key) => '"' + key + '":' + stringifying(obj[key]))
.join(",") +
"}"
);
}
// 그 외 함수, 심벌 등의 타입은 넘어간다.
else {
return undefined;
}
}이번엔 JSON 문자열을 다시 Javascript 객체로 만들어보자. JSON 전역 객체의 parse 메서드에 해당하는 부분이다.
JSON 문자열 객체를 읽을 인덱스가 필요하다. 자료형의 타입에 따라 괄호, 따옴표 등을 처리하려면 인덱스 변수를 만들어 조작하는 편이 편리할 것이다. JSON 문자열 객체 내부에 담긴 키와 값 쌍은 여러 자료형의 형태로, 여러 개가 존재할 수 있을 것이다. 재귀적 호출이 필요하다.
이정도가 키 포인트일 것이다. 이외의 부분은 위와 비슷한 부분이 있다. 이번에도 주석으로 과정에 대한 부분을 정리했다.
function parsing(jsonStr) {
// JSON 문자열을 읽기 위한 인덱스
let index = 0;
// JSON 문자열 객체 내의 여러 값 처리를 위해 재귀 호출이 필요하므로 헬퍼 함수를 정의한다.
function parseValue() {
// 현재 인덱스의 문자
const char = jsonStr[index];
// 중괄호: 객체 타입의 경우
if (char === "{") return parseObject();
// 대괄호: 배열 타입의 경우
if (char === "[") return parseArray();
// 큰따옴표: 문자열의 경우
if (char === '"') return parseString();
// true와 false(boolean)의 경우는 특별히 감싸고 있는 브라켓같은 것이 없다
if (char === "t" || char === "f") return parseBoolean();
// null 또한 감싸고 있는 브라켓이 없다.
if (char === "n") return parseNull();
// 남은 타입은 숫자 타입 하나.
return parseNumber();
}
// 객체 타입 파싱
function parseObject() {
index++; // 객체를 여는 중괄호를 넘기기 위해 인덱스 조정
const obj = {};
// 객체를 닫는 중괄호가 나올 때까지 반복한다.
while (jsonStr[index] !== "}") {
const key = parseString(); // 객체의 키 부분은 문자열이다.
index++; // 콜론을 넘기기 위해 인덱스 조정
obj[key] = parseValue(); // 객체의 값 부분은 어떤 타입이 올 지 모른다.
// 다음 프로퍼티로 인한 콤마를 넘기기 위해 인덱스 조정
if (jsonStr[index] === ",") index++;
}
index++; // 객체를 닫는 중괄호를 넘기기 위해 인덱스 조정
return obj;
}
// 배열 타입 파싱
function parseArray() {
index++; // 배열을 여는 대괄호를 넘기기 위해 인덱스 조정
const arr = [];
// 배열을 닫는 대괄호가 나올 때까지 반복한다.
while (jsonStr[index] !== "]") {
arr.push(parseValue()); // 배열의 요소에는 어떤 타입이 올 지 모른다.
// 다음 요소로 인한 콤마를 넘기기 위해 인덱스 조정
if (jsonStr[index] === ",") index++;
}
index++; // 배열을 닫는 대괄호를 넘기기 위해 인덱스 조정
return arr;
}
// 문자열 타입 파싱
function parseString() {
index++; // 문자열을 여는 큰따옴표를 넘기기 위해 인덱스 조정
let str = "";
// 문자열을 닫는 큰따옴표가 나올 때까지 반복한다.
while (jsonStr[index] !== '"') {
// 슬래시 이스케이프 처리로 인해 인덱스 조정
if (jsonStr[index] === "\\") index++;
str += jsonStr[index];
index++;
}
index++; // 문자열을 닫는 큰따옴표를 넘기기 위해 인덱스 조정
return str;
}
// 불리언 타입 파싱
function parseBoolean() {
// true인 경우
if (jsonStr[index] === "t") {
index += 4; // true 문자는 4자리를 차지한다.
return true;
}
// false인 경우
else {
index += 5; // false 문자는 5자리를 차지한다.
return false;
}
}
// null 타입 파싱
function parseNull() {
index += 4; // null 문자는 4자리를 차지한다.
return null;
}
// 숫자 타입 파싱
function parseNumber() {
let numString = "";
// 정규식으로 숫자를 잡아낸다. 혹은 음수의 빼기 기호를 잡아낸다.
// NaN, Infinity의 경우, JSON은 null로 파싱된다.
while (/\d/.test(jsonStr[index]) || jsonStr[index] === "-") {
numString += jsonStr[index];
index++;
}
return Number(numString);
}
return parseValue();
}이제 다양한 자료형을 가진 객체로 종합적인 테스트를 해보자.
const obj = {
name: "Lee",
age: 20,
data: {
job: "programmer",
major: "front-end",
wannaHiring: true,
isWorking: false,
company: null,
},
limit: Infinity, // null
money: NaN, // null
regexp: /[0-9a-zA-Z]/, // {}
escape: '"\\',
};
const byCustom = stringifying(parsing(stringifying(obj)));
const byJSON = JSON.stringify(JSON.parse(JSON.stringify(obj)));
console.log(byCustom === byJSON); // trueJSON 전역 객체에서 제공하는 메서드를 사용해 변환한 것과, 커스텀 정의 함수를 통해 변환한 것이 일치한다.
정말 사소하고 단순한 호기심에서 시작한 것에 비해, 함수를 정의하는 과정은 쉽지 않았다. 구현체의 언어적 차이와 로직 차이 등을 고려하면 당연히 JSON 전역 객체가 제공하는 메서드가 훨씬 빠를 수밖에 없다. 하지만, 이 사소한 호기심은 생각보다 쉽지 않은 도전이었고, 배우는 점이 꽤 많았다.
다양한 자료형의 분석과 처리라던가, JSON 객체는 함수 타입, Infinity, NaN 등을 지원하지 않는다던가, 정규 표현식 객체 또한 객체이지만, 빈 객체로 처리된다던가.
그리고 결정적으로 이런 사소한 호기심을 해결하기 위해서 역사적인 부분을 다시 찾아보고, 체득하는 과정에서 JSON이 XML에 비해 얼마나 작성하고 사용하는 데 있어 간편한 지도 알게 되었다.
References
https://ko.wikipedia.org/wiki/XML https://namu.wiki/w/XML https://ko.wikipedia.org/wiki/JSON
원문: Medium