[js] JSON ↔ 모델 변환 entityJS제작 # 1/3

개요

json이 포멧이 복잡해질수록 이를 매 통신마다 검증하는 코드의 양이 급증하게 됩니다.

특히 데이터 형식으로 기술되어야하는 포멧이 알고리즘 형태의 코드로 검증되다 보니 이 후 json이 변화할 때마다 대응하는데 큰 비용이 발생하기 마련입니다.

이러한 json포멧의 검증은 반드시 런타임에 일어나기 때문에 타입스크립트 등을 통해 인터페이스를 선언했다고 해도 런타임에 어떤 이유로 포멧이 맞지 않는지는 재검증해야하는 일입니다.

매우 복잡한 json포멧도 정교하게 해석하고 각 값에 밸리데이션 검증도 걸 수 있는 엔티티 시스템을 개발해보죠. 이 시리즈는 총 3부작으로 구성되며 다음과 같습니다.

  1. 기본값 검증
  2. 배열, 객체 및 복합 객체 검증
  3. union타입 검증

defineProperty 복습

es5에 처음 도입된 Object.defineProperty는 어떤 객체의 속성에 대해 getter, setter를 정의하거나 value와 접근 제어자를 정의하는 두 가지 선언을 허용합니다.

//getter, setter로 쓰는 경우 - accessor descriptor

let data = 1;
Object.defineProperty(target, field, {
  get(){
    return data;
  }
  set(v){
    data = v;
  }
}

//직접 value필드를 정의해서 쓰는 경우 - data descriptor
let data = 1;
Object.defineProperty(target, field, {
  value:data
}

헌데 재밌는 건 세 번째 인자로 넘기는 descriptor객체는 사본이 만들어져 객체에 저장되는 것이지 넘긴 객체가 직접 descriptor가 되는 것은 아닙니다. 아래와 같은 코드로 이를 증명할 수 있습니다.

const descriptor = {value:1};
Object.defineProperty(target, field, descriptor);

console.log( Object.getOwnPropertyDescriptor(target, field) === descriptor ); //false

즉 인자로 전달받은 descriptor에서 적당히 필요한 속성만 빼서 새로운 객체를 만들어 실제 객체의 descriptor에 할당하는 것입니다. entityJS는 전반적으로 이 descriptor의 기능을 이용하므로 한 번쯤 점검해보는 시간을 가졌습니다.

descriptor를 이용한 엔티티의 속성 정의

만약 다음과 같은 json이 있다고 해보죠.

{
  "name":"hika",
  "age":18
}

그럼 이에 대응하는 Member엔티티는 다음과 같이 정의해 볼 수 있을 것입니다.

class Member{
  name; age;
};

하지만 이러면 각 속성에 특수한 기능을 부여할 수 없습니다. 이를 조금 달리 생각하여 defineProperty를 이용한다고 생각하면 다음과 같이 작성할 수 있을 것입니다.

class Member{
  constructor(){
    Object.defineProperty(this, "name", {
      get(){...},
      set(){...}
    });
    Object.defineProperty(this, "age", {
      get(){...},
      set(){...}
    });
  }
};

딱 defineProperty를 해주는 부분만 슈퍼클래스로 분리해보면 다음과 같습니다.

class Entity{
  define(field, descriptor){
    Object.defineProperty(this, field, descriptor);
  }
};

class Member extends Entity{
  constructor(){
    super();
    this.define("name", {
      get(){...},
      set(){...}
    });
    this.define("job", {
      get(){...},
      set(){...}
    });
  }
};

필드별 타입정의하기

헌데 여기서 descriptor에 타입을 부여하고 싶다면 desciptor를 처음부터 타입에 맞는 클래스로 정의할 수 있을 것입니다. 간단히 문자열을 나타내는 StringField를 정의해보죠.

class StringField{
  constructor(){
    const self = this;

    //getter, setter 정의
    self.get =_=>self.v;
    self.set =newValue=>self.v = newValue;

    self.set(""); //기본값
  }
}

이 StringField는 get과 set을 제공하므로 accessor descriptor가 될 자격을 갖췄습니다.

근데 문자열만 받기로 했으므로 setter의 newValue에 문자열이 들어왔는지 간단한 밸리데이션을 걸 수 있습니다.

class StringField{
  constructor(){
    const self = this;
    self.get =_=>self.v;
    self.set =newValue=>{
      if(typeof newValue != "string") throw "invalid String: " + newValue;
      self.v = newValue;
    };
    self.set("");
  }
}

지금은 간단한 예지만 get, set 덕분에 다양한 기능을 추가할 수 있다는 점을 확인할 수 있습니다. 이제 같은 원리로 NumberField도 만들어봅니다.

class NumberField{
  constructor(){
    const self = this;
    self.get =_=>self.v;
    self.set =newValue=>{
      if(typeof newValue != "number") throw "invalid Number: " + newValue;
      self.v = newValue;
    }
    self.set(0);
  }
}

코드를 보면 StringField와 거의 유사합니다. 따라서 Field로 슈퍼클래스를 만들어 병합합니다.

class Field{
  constructor(defaultValue){
    const self = this;
    self.get =_=>self.v;
    self.set =newValue=>{
      if(!self.typeValidation(newValue)) throw "invalid type: " + newValue;
      self.v = newValue;
    }
    self.set(defaultValue);
  }
  typeValidation(){ throw "must be override!"; }
};
class StringField extends Field{
  constructor(defaultValue){super(defaultValue ?? "");}
  typeValidation(v){ return typeof v != "string";}
};
class NumberField extends Field{
  constructor(defaultValue){super(defaultValue ?? 0);}
  typeValidation(v){ return typeof v != "number";}
};

이제 다양한 타입의 Field를 손쉽게 만들 수 있습니다. 같은 방법으로 BooleanField등을 정의할 수 있겠죠.

타입별 필드 생성 메소드

위에서 만든 Field클래스를 이용해 Member를 재정의해 보죠.

class Entity{
  define(field, descriptor){
    Object.defineProperty(this, field, descriptor);
  }
};

class Member extends Entity{
  constructor(){
    super();
    this.define("name", new StringField);
    this.define("job", new NumberField);
  }
};

복잡한 accesor descriptor가 Field클래스로 옮겨 가서 훨씬 쉽게 정의할 수 있지만 짜피 타입별로 정의하는 구문이 반복되므로 도우미 함수를 슈퍼클래스에 정의하는 편이 사용성에서 훨씬 편합니다.

class Entity{
  define(field, descriptor){
    Object.defineProperty(this, fieldName, descriptor);
  }
  string(field, defaultValue){
    this.define(field, new StringField(defaultValue));
  }
  number(field, defaultValue){
    this.define(field, new NumberField(defaultValue));
  }
};

class Member extends Entity{
  constructor(){
    super();
    this.string("name");
    this.number("job");
  }
};

리플렉션을 위한 숨겨진 필드 정보

위의 코드로 엔티티의 속성을 정의하는 것은 문제가 없지만 실제 이 엔티티의 속성이 어떤 타입으로 정의되었나를 역으로 추적하는 것은 어렵습니다. 이유는 처음 설명한 대로 Object.hasOwnDescriptor 메소드가 StringField나 NumberField를 반환하는 게 아니라 그것의 복사본 객체를 갖고 있기 때문에 그저 객체의 속성을 순회하는 것으로는 적합한 타입을 알 수 없습니다. 따라서 field를 정의할 때 부터 이 정보를 별도의 필드별 맵에 담고 있어야 json으로부터 파싱될 때 사용할 수 있습니다.

class Entity{
  constructor(){

    //숨겨진 리플렉션용 _fields맵을 열거불가, 수정불가로 생성함
    Object.defineProperty(this, '_fields', {
      value:{}, 
      enumerable:false, 
      writable:false, 
      configurable:false
    });

  }
  define(field, descriptor){

    //리플렉션 맵에 등록하면서 속성을 정의함
    Object.defineProperty(this, field, this._fields[field] = descriptor);

  }
  string(field, defaultValue){
    this.define(field, new StringField(defaultValue));
  }
  number(field, defaultValue){
    this.define(field, new NumberField(defaultValue));
  }
};

class Member extends Entity{
  constructor(){
    super();
    this.string("name");
    this.number("job");
  }
};

생성시 리플렉션용 맵을 만들어두고 각 속성을 정의할 때 이 맵에도 동시에 등록해 줍니다. 이를 통해 각 필드가 어떤 타입인지 알아낼 수 있게 됩니다.

파서제작

이제 재료가 다 모였으니 이를 바탕으로 json으로부터 엔티티를 구성할 수 있는 파서메소드를 제작해보죠.

class Entity{
  constructor(){
    Object.defineProperty(this, '_fields', {value:{}, enumerable:false, writable:false, configurable:false});
  }
  define(field, descriptor){Object.defineProperty(this, field, this._fields[field] = descriptor);}
  string(field, defaultValue){this.define(field, new StringField(defaultValue));}
  number(field, defaultValue){this.define(field, new NumberField(defaultValue));}

  parse(json){
    Object.entries(this._fields).forEach(([key, type])=>{
       const jsonValue = json[key];
       if(jsonValue == undefined) throw 'no key in json:' + key;
       this[key] = jsonValue;
    });
    return this;
  }
};

파서의 코드는 의외로 간단한데, 리플렉션에 정의되 필드를 돌면서 json에 해당되는 키가 있는 지 검사하여 없으면 예외로 처리하고 있다면 setter로 할당하는 것입니다.

this[key] = jsonValue; 이 부분에서 만약 json에 있는 값이 원하는 타입이 아니라면 Field에 정의된 set메소드에서 타입 예외를 발생시킬 것입니다.

이제 멤버에 적용해보면 됩니다.

class Member extends Entity{
  constructor(){
    super();
    this.string("name");
    this.number("job");
  }
};

const member = new Member;
member.parse({ name:"hika", age:18 });

console.log(member.name); //hika
console.log(member.age); //18

검증되는지는 간단히 parse에 전달되는 객체의 값을 바꿔보면 알 수 있습니다.

const member = new Member;
member.parse({ age:18 }); //no key in json:name 예외 발생
member.parse({ name:15, age:18 }); //invalid type: 15 예외 발생
member.parse({ name:"hika", age:"abc" }); //invalid type: abc 예외 발생

잘 됩니다. 이로서 json이 엔티티와 같은 필드를 갖고 있는지 값이 원하는 타입인지 검증할 수 있게 되었습니다. 하지만 타입을 넘어 추가적인 검증을 해야하는 경우가 실무에서는 보통입니다.

밸리데이션 기능 추가

예를 들어 name은 3~10자 이내의 문자열이라던가 age는 15~30까지의 정수라던가 하는건 어떻게 해야할까요?

밸리데이션을 추가해야 할 것입니다. 이러한 밸리데이션은 Field의 사정이므로 Field객체 수준에서 소유하고 기능을 사용해야 합니다.

class Field{
  constructor(defaultValue){
    const self = this;
    self.get =_=>self.v;
    self.set =newValue=>{
      if(!self.typeValidation(newValue)) throw "invalid type: " + newValue;

      //밸리데이터가 있는 경우 밸리데이터의 결과가 거짓이면 예외발생
      if(self.validator && !self.validator()) throw "invalid validation: " + newValue;

      self.v = newValue;
    }
    self.set(defaultValue);
  }
  typeValidation(){ throw "must be override!"; }

  //밸리데이터를 추가한다.
  validation(validator){
    this.validator = validator;
    return this;
  }

};

이제 Field는 밸리데이터를 추가로 정의할 수 있게 되어 이를 사용할 수 있게 되었습니다. 이를 Member정의시 잘 사용하려면 메소드 체이닝 기법을 지원해야 하므로 Entity의 필드 도우미 함수가 전부 필드를 반환하게 할 필요가 있습니다.

class Entity{
  constructor(){
    Object.defineProperty(this, '_fields', {value:{}, enumerable:false, writable:false, configurable:false});
  }
  parse(json){
    Object.entries(this._fields).forEach(([key, type])=>{
       const jsonValue = json[key];
       if(jsonValue == undefined) throw 'no key in json:' + key;
       this[key] = jsonValue;
    });
    return this;
  }

  define(field, descriptor){
    Object.defineProperty(this, field, this._fields[field] = descriptor);
    return descriptor; //반환
  }
  string(field, defaultValue){
    return this.define(field, new StringField(defaultValue)); //반환
  }
  number(field, defaultValue){
    return this.define(field, new NumberField(defaultValue)); //반환
  }
};

class Member extends Entity{
  constructor(){
    super();
    this.string("name");
    this.number("job");
  }
};

도우미 함수가 Field를 반환하므로 이를 통해 validator를 추가할 수 있습니다.

class Member extends Entity{
  constructor(){
    super();
    this.string("name").validation(v=> 3 <= v.length && v.length <=10 );
    this.number("job").validation(v=> 15 <= v && v <= 30);
  }
};

이젠 더욱 정교하게 json의 값을 검증하여 틀린 값이 들어오면 parse에서 예외를 발생시키게 됩니다.

const member = new Member;
member.parse({ age:18 }); //no key in json:name 예외 발생
member.parse({ name:15, age:18 }); //invalid type: 15 예외 발생
member.parse({ name:"hika", age:"abc" }); //invalid type: abc 예외 발생

//추가된 밸리데이션
member.parse({ name:"hi", age:15 }); //invalid validator: hi 예외 발생
member.parse({ name:"hika", age:10 }); //invalid validator: 10 예외 발생

stringify에 대응하기

entity객체로부터 json문자열을 만들 경우 descriptor의 getter가 그냥 작동하는 것을 기본으로 명시적으로 _fields의 정의된 필드 정의에 따라 json이 생성되도록 toJSON을 직접 구현해보죠.

class Entity{
  constructor(){Object.defineProperty(this, '_fields', {value:{}, enumerable:false, writable:false, configurable:false});}
  parse(json){
    Object.entries(this._fields).forEach(([key, type])=>{
       const jsonValue = json[key];
       if(jsonValue == undefined) throw 'no key in json:' + key;
       this[key] = jsonValue;
    });
    return this;
  }
  define(field, descriptor){
    Object.defineProperty(this, field, this._fields[field] = descriptor);
    return descriptor;
  }
  string(field, defaultValue){return this.define(field, new StringField(defaultValue));}
  number(field, defaultValue){return this.define(field, new NumberField(defaultValue));}

  toJSON(){
    const result = {};
    //명시적으로 _fields에 정의된 필드 객체로부터 json출력용 값을 만든다.
    Object.entries(this._fields).forEach(([key, {v}])=>{  //field.v를 해체한 것
       result[key] = v;
    });
    return result;
  }
};

이제 JSON.stringify(member) 같은 코드에서 toJSON이 작동하게 될 것입니다.

결론

이번 포스팅에서는 entityJS의 기본값 검증을 살펴봤습니다. 다음 포스팅에서는 배열형태의 값, 객체 형태의 값, 복합적인 구조의 다양한 json값을 어떻게 검증할지 살펴보겠습니다. 오늘 다룬 전체 코드는 다음과 같습니다.

class Field{
  constructor(defaultValue){
    const self = this;
    self.get =_=>self.v;
    self.set =newValue=>{
      if(!self.typeValidation(newValue)) throw "invalid type: " + newValue;
      if(self.validator && !self.validator()) throw "invalid validation: " + newValue;
      self.v = newValue;
    }
    self.set(defaultValue);
  }
  typeValidation(){ throw "must be override!"; }
  validation(validator){
    this.validator = validator;
    return this;
  }
};
class StringField extends Field{
  constructor(defaultValue){super(defaultValue ?? "");}
  typeValidation(v){ return typeof v != "string";}
};
class NumberField extends Field{
  constructor(defaultValue){super(defaultValue ?? 0);}
  typeValidation(v){ return typeof v != "number";}
};
//-------------------------------------------------------------------
class Entity{
  constructor(){Object.defineProperty(this, '_fields', {value:{}, enumerable:false, writable:false, configurable:false});}
  parse(json){
    Object.entries(this._fields).forEach(([key, type])=>{
       const jsonValue = json[key];
       if(jsonValue == undefined) throw 'no key in json:' + key;
       this[key] = jsonValue;
    });
    return this;
  }
  define(field, descriptor){
    Object.defineProperty(this, field, this._fields[field] = descriptor);
    return descriptor;
  }
  string(field, defaultValue){return this.define(field, new StringField(defaultValue));}
  number(field, defaultValue){return this.define(field, new NumberField(defaultValue));}
  toJSON(){return Object.entries(this._fields).reduce((acc, [key, {v}])=>(acc[key] = v, acc), {});}
};
//-------------------------------------------------------------------
class Member extends Entity{
  constructor(){
    super();
    this.string("name").validation(v=> 3 <= v.length && v.length <=10 );
    this.number("job").validation(v=> 15 <= v && v <= 30);
  }
};
//-------------------------------------------------------------------
const member = new Member;
member.parse({ name:"hika", age:18 });
JSON.stringify(member); //{"name":"hika","age":18}

member.parse({ age:18 }); //no key in json:name 예외 발생
member.parse({ name:15, age:18 }); //invalid type: 15 예외 발생
member.parse({ name:"hika", age:"abc" }); //invalid type: abc 예외 발생

member.parse({ name:"hi", age:15 }); //invalid validator: hi 예외 발생
member.parse({ name:"hika", age:10 }); //invalid validator: 10 예외 발생
%d 블로거가 이것을 좋아합니다: