[js] JSON의 toJSON과 reviver

개요

자바스크립트는 자바의 clonneable 같은 직렬화(영속화)에 대한 규격을 별도로 지정하기 보다는 JSON객체를 통해 직렬화를 퉁치는 경우가 많습니다.
이 추세는 사실 거의 모든 언어에 전파되고 있죠. 일반적으로 JSON.stringify는 객체를 문자열로(영속화) 바꿀 때 사용하고, JSON.parse는 문자열로부터 인메모리에 객체로 환원시키기 위해 사용합니다. ECMA-404에 기술된 내용은 이런 직렬화가 가능한 데이터타입의 범위를 엄격하게 제한하고 있습니다.

하지만 실무에선 다양한 커스텀객체를 사용하게 마련이고 이들도 영속화했다가 복원하고 싶은게 사실이죠. 이에 대한 표준 매커니즘을 살펴봅니다.

  • BSIDESOFT는 이제 자바스크립트와 관련된 모든 기술포스팅에서 ECMAscript2015를 기준으로 하는 코드로 설명합니다.

toJSON

이 메소드는 toString이나 valueOf처럼 미리 약속되어있는 메소드이름입니다. toJSON은 ECMAScript스펙의 일부로 stringify절차에 명시되어있습니다.

http://www.ecma-international.org/ecma-262/6.0/#sec-serializejsonproperty

좀 딱딱한데 MDN에 제법 친절한 설명이 나옵니다.

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#toJSON()_behavior

이를 구현한 코어객체로는 Date가 있습니다. Date는 ECMAscript 5.1에서 toISOString과 toJSON을 표준화시켰습니다. 따라서 기존 3.1베이스에서는 Date객체를 JSON에 포함시킬 경우 toString()이 호출되어 ‘Mon Apr 12 2016 10:19:11 GMT-0900 (PDT)’같은 값이 되어버렸습니다만, 현재는 toJSON이 발동하여 toISOString의 값을 가져오게 되므로 ‘2016-04-12T10:19:11.000Z’ 같은 값으로 JSON에 포함되게 됩니다.

toJSON에 대해 가벼운 코드로 실습해보죠.

const test = {
  toJSON(){return 'TEST';}
};

위의 예에서 test는 ‘TEST’라는 문자열을 반환하는 간단한 toJSON메소드를 구상하고 있습니다. 이제 test객체를 JSON에 넣으면 객체로 취급되는 대신 ‘TEST’라는 문자열로 대체되겠죠.

const test = {
  toJSON(){return 'TEST';}
};
console.log( JSON.stringify({target:test}) ); // {"target":"TEST"}

이를 이용하여 커스텀 클래스의 직렬화를 각 클래스로 위임시킬 수 있게 됩니다.

parse시 reviver지정

원래 JSON.parse의 시그니쳐는 JSON.parse(text[, reviver]) 로 평소엔 reviver를 생략한 상태로 사용하고 있는 셈입니다.
reviver는 JSON.parse수준에서는 해당 키와 이미 파싱이 끝난 값을 인자로 받아 이를 가공하여 최종 값을 반환할 수 있는 함수입니다.
기본적으로 아무것도 안하는 reviver는 다음과 같을 것입니다.

const parsed = JSON.parse(data, (k, v)=>v);

좀 더 쓸모있게 Date의 toJSON으로 얻어진 문자열을 복원하여 다시 Date객체를 생성하는 reviver를 만들어보죠.

//날짜를 포함시켜 직렬화시킨 JSON문자열을 얻는다.
const date = new Date();
const text = JSON.stringify( {created:date} ); // {"created":"2016-04-12T10:19:11.000Z"}

//reviver를 통해 Date객체를 복원한다.
const isDate = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}[.][0-9]{3}[A-Z]$/;
const parsed = JSON.parse(text, (k, v)=>v.search && v.search(isDate) > -1 ? new Date(v) : v); //날짜면 Date로 환원시킨다.

위임을 이용한 toJSON

이제 기본은 살펴봤으니 자유롭게 응용할만합니다. 간단한 파일시스템을 모델링하여 이를 JSON으로 바꿨다가 다시 환원하는 과정을 살펴보겠습니다.
우선 파일시스템에서 기본은 폴더와 파일입니다. 폴더는 다른 폴더나 파일을 소유할 수 있죠. 이를 간단한 개념으로 나타내보죠.

const Folder = class{
  constructor(name){
    this.name = name;
    this.children = [];
  }
  add(...arg){
    this.children.push(...arg);
    return this; //귀찮으니 자신을 반환하자.
  }
};

const File = class{
  constructor(name){
    this.name = name;
  }
};

굉장히 간단한 구조입니다. 이제 이를 이용해서 간단히 파일시스템을 묘사해보죠,

const root = (new Folder('root')).add(
  (new Folder('images')).add(
     new File('a.jpg'),
     new File('b.jpg')
  ),
  new File('index.html'),
  new File('main.css')
);
/*
다음과 같은 파일시스템
+[root]
  +[images]
    -a.jpg
    -b.jpg
  -index.html
  -main.css
*/

이제 root를 json으로 바꾼다고 생각해보죠. 자신을 하나의 객체로 표현하여 반환하면 순환되는 toJSON호출을 따라 File과 Folder가 반응하게 해주면 될 것입니다.
toJSON은 문자열을 반환하지 않으면 JSON.stringify에 의해 문자열로 환원될 때까지 반복되어 처리되므로 이후 과정은 시스템에게 맡기면 됩니다.

const Folder = class{
  constructor(name){..}
  add(...arg){...}

  toJSON(){
    return { //간단히 Folder를 묘사함.
      CLASS:'Folder',
      name:this.name,
      children:this.children
    };
  }
};

const File = class{
  constructor(name){...}

  toJSON(){
    return {//간단히 File을 묘사함.
      CLASS:'File',
      name:this.name
    };
  }
};

간단히 toJSON으로 본인을 묘사하도록 하되 나중에 복원절차를 위해 CLASS라는 특수키를 넣었습니다. 이제 JSON.stringify(root)의 결과는 다음과 같을 것입니다.

console.log(JSON.stringify(root));
/*
{
  "CLASS":"Folder",
  "name":"root",
  "children":[
    {
      "CLASS":"Folder",
      "name":"images",
      "children":[
        {
          "CLASS":"File",
          "name":"a.jpg"
        },
        {
          "CLASS":"File",
          "name":"b.jpg"
        }
      ]
    },
    {
      "CLASS":"File",
      "name":"index.html"
    },
    {
      "CLASS":"File",
      "name":"main.css"
    }
  ]
}
*/

굉장히 만족스러운 결과가 되었습니다. 이제 이를 복원해보죠.

reviver를 통한 복원

JSON.parser의 두 번째 인자로 reviver함수를 지정할 수 있다는 사실은 이미 알았습니다. 헌데 다층 구조의 객체에 대해서 실제로는 어떻게 작동하는 걸까요?
간단한 중첩구조의 객체로 실험해보죠.

var test = JSON.stringify({
  a:{
    b:{
      c:3
    }
  }
});

위에서 생성된 문자열의 경우 reviver입장에서 어떤 식으로 작동하는가를 알아보기 위해 간단히 함수를 정의해서 확인해봅니다.

JSON.parse(test, (k, v)=>{
  console.log(k, v, typeof v);
  return v;
});

Screenshot_1
총 4번의 출력이 이뤄지는데 가장 안쪽에 있는 c에서 b, a순으로 처리되고 마지막에 전체 결과값이 k없이 호출된다는 것을 알 수 있습니다.
즉 더 이상의 객체참조가 없는 상태가 될 때까지 파고든 뒤, 올라오는 단계부터 reviver가 적용되는 것이죠.
이 원리라면 굉장히 간단하게 안쪽의 File부터 복원될 것을 확신할 수 있으므로 Folder를 위한 복원절차도 간단해집니다.

const parsed = JSON.parse(text, (k,v)=>{
  switch(v && v.CLASS){
  case'File': return new File(v.name);
  case'Folder':return (new Folder(v.name)).add(...v.children);
  default:return v;
  }
});
console.log(parsed);

원하는대로 이쁘게 복원되었습니다.
Screenshot_2

결론

내장 JSON객체는 굉장히 강력한 영속화와 객체복원기능을 제공합니다. 숨겨진 프로토콜인 toJSON과 parse메소드의 두번째 reviver인자를 잘 활용하면 손쉽게 커스텀객체를 저장했다가 복원하는 과정을 처리할 수 있습니다.
사실 reviver에서 k를 사용하는 경우는 드문 편이라 _ 정도로 안쓴다는 표시를 해두는 것도 나쁘지 않습니다(swift의 영향을 =.=);
전체 코드는 아래와 같습니다(코드가 길어 간단히 줄였습니다)

//classes
const Folder = class{
  constructor(name){
    this.name = name;
    this.children = [];
  }
  add(...arg){
    this.children.push(...arg);
    return this;
  }
  toJSON(){
    return {
      CLASS:'Folder',
      name:this.name,
      children:this.children
    };
  }
};
const File = class{
  constructor(name){
    this.name = name;
  }
  toJSON(){
    return {
      CLASS:'File',
      name:this.name
    };
  }
};

//declarative
const root = (new Folder('root')).add(
  (new Folder('images')).add(
     new File('a.jpg'),
     new File('b.jpg')
  ),
  new File('index.html'),
  new File('main.css')
);

//stringify
const text = JSON.stringify(root);
console.log(text);

//parse
const parsed = JSON.parse(text, (k,v)=>{
  switch(v && v.CLASS){
  case'File': return new File(v.name);
  case'Folder':return (new Folder(v.name)).add(...v.children);
  default:return v;
  }
});
console.log(parsed);