개요
json이 포멧이 복잡해질수록 이를 매 통신마다 검증하는 코드의 양이 급증하게 됩니다.
특히 데이터 형식으로 기술되어야하는 포멧이 알고리즘 형태의 코드로 검증되다 보니 이 후 json이 변화할 때마다 대응하는데 큰 비용이 발생하기 마련입니다.
이러한 json포멧의 검증은 반드시 런타임에 일어나기 때문에 타입스크립트 등을 통해 인터페이스를 선언했다고 해도 런타임에 어떤 이유로 포멧이 맞지 않는지는 재검증해야하는 일입니다.
매우 복잡한 json포멧도 정교하게 해석하고 각 값에 밸리데이션 검증도 걸 수 있는 엔티티 시스템을 개발해보죠. 이 시리즈는 총 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 예외 발생
안녕하세요~ 강의 잘보고 있습니다.
쭈욱 따라서 해보고 있는데요. 수정이 필요한 부분이 있는거 같아서 확인 부탁드립니다. ㅎㅎ (제가 잘못본걸수도 있어요)
Field 클래스에서 if(!self.typeValidation(newValue)) throw “invalid type: ” + newValue; -> if 조건 느낌표 삭제
Field 클래스에서 if(self.validator && !self.validator()) throw “invalid validation: ” + newValue; -> !self.validator(newValue) 파라미터 추가
Member 클래스에서 this.number(“job”).validation(v=> 15 <= v && v <= 30); -> this.number(“age”)로 수정