개요
시리즈 1편에서 기본적 값을 모델에 적응시켰고 2편에서는 복잡한 객체나 배열을 비롯하여 다른 엔티티나 특수한 형태의 값을 어떻게 매핑할 수 있는지 살펴봤습니다.
이번 마지막 포스팅에서는 A or B와 같은 선택적인 구조를 갖는 엔티티를 어떻게 제작하는가와 기타 다루지 않았던 실무적이고 소소한 기능을 다룹니다. 코드는 2편의 모든 코드가 다 있다는 가정하에서 수정하거나 추가하면서 진행됩니다.
선택적인 필드를 바라보는 관점
예를 들어 다음과 같은 엔티티를 생각해보죠.
{ "name":"hika", "age":18 } or { "name":"jidolstar", "age":17, "email":"jidolstar@bsidesoft.com" }
위 두 개의 json에서 email은 선택적으로 포함될 수도 있고 아닐 수도 있다는 것을 나타냅니다. 그럼 가장 먼저 떠오르는 해법은 email이라는 필드를 옵셔널로 선언하는 방식일 것입니다. 하지만 옵셔널이라는 것은 null을 할당하는 행위입니다. 따라서 좀 더 좋은 방법이 필요합니다.
앞 서 만들었던 기본값 할당 기능은 이에 대한 좋은 대안이 됩니다. 즉 기본값이 있는 경우는 json에 값이 없어도 기본값으로 대응하는 것이죠. 따라서 다음과 같이 묘사할 수 있습니다.
class Member extends Entity{ constructor(){ super(); this.string("name"); this.number("age"); this.string("email").default("nomail"); } }
이렇듯 기본값을 선언하는 경우 이를 반영하여 파서가 json에 키가 없어도 넘어갈 수 있는 로직을 추가해줘야합니다.
parse(json){ Object.entries(this._fields).forEach(([key, field])=>{ const jsonValue = json[key]; if(jsonValue == undefined){ //json에 없는 키인 경우 if(field.get() === undefined) throw 'no key in json:' + key; //기본값 설정도 안되어있다면 예외 }else this[key] = field.fromJSON(jsonValue); //json에 있는 키만 할당 }); return this; }
옵셔널은 null이냐 아니냐고 기본값은 null대신 지정한 기본값을 사용하는 방식입니다. 후자가 기본값을 관리한다는 점에서 귀찮을 수 있지만 타입을 무력화하는 null을 사용하지 않는다는 안정성에서 지불할만한 귀찮음입니다.
공통 속성이 있고 분기가 되는 속성이 있는 경우
하지만 진정으로 타입계층이 성립하는 경우도 있습니다. 대부분의 컴포지트 패턴을 데이터화하면 그렇게 됩니다. 사이트에서는 주로 다계층 메뉴같은 게 그렇습니다.
{ "title":"개발실", "sub":[ { "title":"프론트엔드팀", "sub":[ { "title":"프론트엔드1팀", "member":["park", "kim"] }, { "title":"프론트엔드2팀", "member":["jung", "sam"] } ] }, { "title":"백엔드팀", "member":["ahn", "joe"] } ] }
위 구조는
- title이라는 속성은 공통으로 갖고 있지만
- 팀을 포함하는 부서를 나타내는 경우 sub속성에 다른 팀을 갖고 있고
- 멤버가 소속된 팀의 경우는 member라는 속성을 갖고 있습니다.
전형적인 컴포지트 패턴의 예로 파일, 폴더 같은 느낌의 구조인 것이죠. 이를 모델링하자면
- title을 갖는 엔티티가 있고
- 이를 상속받는 group과 team이 있으며
- group는 추가로 sub키를 갖고
- team은 추가로 member키를 갖는 구조로 이해할 수 있습니다.
이걸 그대로 모델링하면 다음과 같습니다.
class Div extends Entity{ constructor(){ super(); this.string("title"); } } class Group extends Div{ constructor(){ super(); this.entityList("sub", Div); //Div를 합타입으로 봄 } } class Team extends Div{ constructor(){ super(); this.stringList("member"); } }
위 모델링에서 눈여겨 볼 지점은 Group의 sub속성을 Div의 엔티티리스트로 본다는 점입니다. 타입이론 관점에서 Div는 Group 또는 Team이 될 수 있는 대표 타입의 느낌입니다.
타입스크립트로 표현하면 const div:(Group|Team) 같은 합타입 선언이 되는 셈이죠. 문제는 Div입장에서는 Group과 Team이 서브클래스 이므로 LSP를 위반하게 되긴 합니다만 합타입이란 어짜피 그런 것입니다. 합타입(sumType)이라는 딱딱한 용어보다는 union이라고 부르겠습니다.
class Div extends Entity{ constructor(){ super(); this.string("title"); } } class Group extends Div{ constructor(){ super(); this.entityList("sub", Div); //Div를 합타입으로 봄 } } class Team extends Div{ constructor(){ super(); this.stringList("member"); } } Entity.union(Div, Group, Team); //Div가 Group과 Team의 합타입을 대신한다
Div는 위의 코드에서 union선언을 통해 Group과 Team을 대표하게 되었습니다. 결국 json은 아래와 같이 매핑될 것입니다.
//Group에 대응 { "title":"개발실", "sub":[ //Group에 대응 { "title":"프론트엔드팀", "sub":[ //Team에 대응 { "title":"프론트엔드1팀", "member":["park", "kim"] }, //Team에 대응 { "title":"프론트엔드2팀", "member":["jung", "sam"] } ] }, //Team에 대응 { "title":"백엔드팀", "member":["ahn", "joe"] } ] }
이런 JSON의 여러 배리에이션에 대해 단지 다음과 같이 파싱이 가능해지는 것이죠.
const div = Div.parse(json); //div는 Group, Team 중 하나가 된다.
이 인터페이스를 실현하기 위해 Entity.union를 차근차근 구현해보죠.
union
실제 json을 파싱할 때 최초에는 Div.parse(json)을 할 수 있지만 내부에 중첩되어있는 파서로직에서는 여전히
(new Div).parse(json) 이 사용됩니다.
class Group extends Div{ constructor(){ super(); this.entityList("sub", Div); } }
바로 이 부분인데 여기서 엔티티리스트는 리스트항목을 직접 생성하기 때문에 new Div를 하고 parse를 하기 때문입니다. 좋든 싫든 같은 파서의 로직이 Div.parse와 Div.prototype.parse에 적용된다는 것을 알 수 있습니다.
union(기저클래스, 서브클래스1, 서브클래스2,….)
라는 시그니처에 맞춰 위의 사항을 염두해 union을 작성합니다.
class Entity{ //union함수 static union(base, ...sub){ //base의 서브클래스가 아니라면 오류 if(!sub.every(cls=>cls.prototype instanceof base)) throw "invalid subclass"; //union용 파서 const parse = json=>{ let target; if(!sub.some(cls=>{ //서브클래스의 인스턴스를 생성 target= new cls; //json의 모든키가 만족되면 true 아니면 false를 반환한다. return Object.entries(target._fields).every(([key, field])=>{ const jsonValue = json[key]; if(jsonValue == undefined && field.get() === undefined) return false; else{ target[key] = field.fromJSON(jsonValue); return true; } }); })) throw "no matched sub class"; //결국 sub중 처음으로 만족한 클래스의 인스턴스가 반환됨 return target; }; //같은 parse의 로직을 양쪽에 적용한다. //base.parse 스태틱 함수의 정의 Object.defineProperty(base, "parse", { enumerable:false, writable:false, configurable:false, value:parse }); //base의 parse메소드 재정의 Object.defineProperty(base.prototype, "parse", { enumerable:false, writable:false, configurable:false, value:parse }); } ... }
위 코드에서 union은 첫번째로 들어온 base클래스에 parse스태틱메소드를 추가하면서 동시에 기존의 parse메소드도 덮어써버립니다. 이제 다음의 코드가 성립하게 됩니다.
class Div extends Entity{ constructor(){ super(); this.string("title"); } } class Group extends Div{ constructor(){ super(); this.entityList("sub", Div); //Div를 합타입으로 봄 } } class Team extends Div{ constructor(){ super(); this.stringList("member"); } } Entity.union(Div, Group, Team); const div = Div.parse({ "title":"개발실", "sub":[ { "title":"프론트엔드팀", "sub":[ {"title":"프론트엔드1팀", "member":["park", "kim"]}, {"title":"프론트엔드2팀", "member":["jung", "sam"]} ] }, {"title":"백엔드팀", "member":["ahn", "joe"]} ] }); div instanceof Group //true div.sub[0] instanceof Group //true div.sub[0].sub[0] instanceof Team //true div.sub[0].sub[1] instanceof Team //true div.sub[1] instanceof team //true
enum type
이제 주요한 문제는 모두 해결되었습니다. 매우 복잡한 json도 모델링할 수 있게 되었죠. 약간씩 응용하면서 추가적인 기능을 만들어갈 차례입니다. 첫 번째로 구현할 enum은 실무에서 굉장히 많이 사용하는 타입입니다.
Fruits라는 enum을 정의하고 이 Fruits enum타입이 apple, banana, orange 만 갖을 수 있다고 정의해봅시다. 이렇다면 json입장에서는 더욱 강력한 밸리데이션을 할 수 있을 것입니다.
엔티티 시스템에서 enum은 하나의 엔티티로 인식됩니다. 따라서 enum을 손쉽게 entitiy클래스로 정의할 방법을 찾아야합니다. 대충 상황을 보자면 다음과 같습니다.
//json { "name":"hika", "fruits":["apple", "banana"] } class User extends Entity{ constructor(){ super(); this.string("name"); this.stringList("fruits").validation(arr=>arr.every(v=>v == "apple" | v == "banana" | v == "orange")); } }
enum은 결국 허용된 값을 걸러내는 밸리데이터에 가깝습니다. 우선 Enum 클래스부터 생각해보면 간단히 값의 배열과 어떤 값이 거기에 속해 있는지를 알려주는 isValid메소드를 생각할 수 있습니다. 거기에 더해 각 값을 속성으로도 잡아줍니다.
class Enum{ constructor(...values){ Object.defineProperty(this, "_values", {value:values, enumerable:false, writable:false, configurable:false}); values.forEach(v=>Object.defineProperty(this, v, {value:v, enumerable:false, writable:false, configurable:false})); } isValid(v){return this._values.indexOf(v) > -1;} }
물론 java 등의 언어에서 제공하는 더 많은 메소드를 구현할 수도 있겠지만 그건 각자 필요에 따라 확장하시면 되고, 이제 이걸 바탕으로 Field클래스를 생성합니다(참고로 소문자 enum은 js예약어라 en을 사용합니다)
class EnumField extends Field{ constructor(en){ super(); this.en = en; } typeValidation(v){return this.en.isValid(v);} }; class EnumListField extends Field{ constructor(en){ super(); this.en = en; } typeValidation(v){return v.every(item=>this.en.isValid(item));} }; class EnumMapField extends Field{ constructor(en){ super(); this.en = en; } typeValidation(v){return !(v instanceof Array) && typeof v == "object" && Object.values().every(item=>this.en.isValid(item));} };
각각 Enum의 일반적인 값, 배열, 객체에 대응합니다. 재료가 모두 모였으니 Entity에 도우미 함수를 추가해줍니다.
class Entity{ static union(base, ...sub){ if(!sub.every(cls=>cls.prototype instanceof base)) throw "invalid subclass"; const parse = json=>{ let target; if(!sub.some(cls=>Object.entries((target = new cls)._fields).every(([key, field])=>{ const jsonValue = json[key]; if(jsonValue == undefined && field.get() === undefined) return false; else{ target[key] = field.fromJSON(jsonValue); return true; } }))) throw "no matched sub class"; return target; }; Object.defineProperty(base, "parse", {value:parse, enumerable:false, writable:false, configurable:false}); Object.defineProperty(base.prototype, "parse", {value:parse, enumerable:false, writable:false, configurable:false}); } constructor(){Object.defineProperty(this, '_fields', {value:{}, enumerable:false, writable:false, configurable:false});} parse(json){ Object.entries(this._fields).forEach(([key, field])=>{ const jsonValue = json[key]; if(jsonValue == undefined && field.get() === undefined) throw 'no key in json:' + key; this[key] = field.fromJSON(jsonValue); }) return this; } toJSON(){return this._fields;} define(field, descriptor){ Object.defineProperty(this, field, this._fields[field] = descriptor); return descriptor; } string(field){return this.define(field, new StringField);} number(field){return this.define(field, new NumberField);} date(field){return this.define(field, new DateField);} stringList(field){return this.define(field, new StringListField);} numberList(field){return this.define(field, new NumberListField);} entity(field, targetClass){return this.define(field, new EntityField(targetClass));} entityList(field, targetClass){return this.define(field, new EntityListField(targetClass));} entityMap(field, targetClass){return this.define(field, new EntityMapField(targetClass));} //enum관련 도우미 함수 추가 enum(field, en){return this.define(field, new EnumField(en));} enumList(field, en){return this.define(field, new EnumListField(en));} enumMap(field, en){return this.define(field, new EnumMapField(en));} };
이제 Entity클래스는 제법 방대한 기능을 갖게 되었네요. enum과 관련된 모든 재료가 갖춰졌으니 위의 예제는 다음과 같이 고쳐쓸 수 있습니다.
//enum선언 const Fruits = new Enum("apple", "banana", "orange"); Fruits.apple == "apple" Fruits.banana == "banana" Fruits.orange == "orange" class User extends Entity{ constructor(){ super(); this.string("name"); this.enumList("fruits", Fruits); //enum리스트로 등록 } } const user = new User.parse({ "name":"hika", "fruits":["apple", "banana"] }); //잘됨 const user = new User.parse({ "name":"hika", "fruits":["apple", "banana1"] }); //예외발생! banana1이 Fruits가 아님
decorator
데코레이터는 말그대로 꾸며주는 녀석으로 Field안에 있는 값은 변화시키지 않으면서 실제 값을 얻거나 넣을 때 추가적인 작업이 가능하도록 해주는 필터입니다.
하지만 생각해보면 validator가 이미 존재하여 set하기 전에 값을 필터링하기 때문에 set용 데코레이터는 필요없습니다. 오직 get에만 작동하면 충분합니다. 이를 Field에 구현해보죠.
class Field{ constructor(defaultValue){ const self = this; //데코레이터가 있다면 데코레이터를 거쳐서 반환 self.get =_=>this.deco ? this.deco(self.v) : self.v; self.set =newValue=>{ if(!self.typeValidation(newValue)) throw "invalid type: " + newValue; if(self.validator && !self.validator(newValue)) throw "invalid validation: " + newValue; self.v = newValue; } } typeValidation(v){ throw "must be override!"; } validation(validator){ this.validator = validator; return this; } default(v){this.set(v);} //데코레이터를 선언한다 decorator(v){this.deco = v;} toJSON(){return this.v;} fromJSON(v){return v;} };
이제 필드수준의 데코레이터가 설정되었으므로 다음과 같이 사용할 수 있습니다.
const Fruits = new Enum("apple", "banana", "orange"); Fruits.apple == "apple" Fruits.banana == "banana" Fruits.orange == "orange" class User extends Entity{ constructor(){ super(); //데코레이터 추가 this.string("name").decorator(v=>"hello, " + v); this.enumList("fruits", Fruits).decorator(list=>list.map(v=>"yummy " + v)); } } const user = new User.parse({ "name":"hika", "fruits":["apple", "banana"] }); user.name // "hello hika" user.fruits // ["yummy apple", "yummy banana"]
데코레이터가 적용된 값을 얻게 되는 것을 볼 수 있습니다.
결론
시리즈 마지막 포스팅에서는 보다 발전된 주제와 기능을 다뤘습니다.
- 옵셔널 필드를 기본값 기능으로 대체하기
- union을 이용한 합타입의 처리
- enum타입을 적용하기
- 데코레이터를 이용한 출력값 꾸미기
결국 entity프레임웍의 가장 큰 목적은 데이터에 관한 코드를 데이터 선언에 모아두자는 것입니다. 엔티티의 도움이 없어도 물론 코드를 작성할 수는 있지만 그렇게 되면 통신 후처리 코드 어딘가, 뷰처리 코드 어딘가, 액션코드 어딘가에 데이터의 정합성, 값변조, 파싱로직 등이 분산되어 작성됩니다.
이는 json의 변화에 굉장히 취약하고 유지보수하기 힘든 코드를 양산하게 되죠.
결국 json은 교환포멧이므로 상황이 바뀌고 도메인에 변화가 일어나면 자연스럽게 같이 바뀔 수 밖에 없습니다. 이런 상황에서 엔티티프레임웍은 그 변화를 데이터를 정의하는 곳에서 다 포용할 수 있게 되어 유지보수를 쉽게 해줍니다.
우선 깃헙에 오픈소스화를 했습니다.
https://github.com/entityjs/entityJS
또한 NPM에도 정식으로 등록되었습니다.
https://www.npmjs.com/package/@entityjs/entityjs
오늘까지 다룬 전체 코드는 다음과 같습니다.
mapValue util
const mapValue = (target, block)=>Object.fromEntries(Object.entries(target).map(([key, value])=>[key, block(value)]));
Field
class Field{ constructor(defaultValue){ const self = this; self.get =_=>this.deco ? this.deco(self.v) : self.v; self.set =newValue=>{ if(!self.typeValidation(newValue)) throw "invalid type: " + newValue; if(self.validator && !self.validator(newValue)) throw "invalid validation: " + newValue; self.v = newValue; } } typeValidation(v){ throw "must be override!"; } validation(validator){ this.validator = validator; return this; } default(v){this.set(v);} decorator(v){this.deco = v;} toJSON(){return this.v;} fromJSON(v){return v;} };
ValueField
class StringField extends Field{ typeValidation(v){ return typeof newValue != "string";} }; class NumberField extends Field{ typeValidation(v){ return typeof newValue != "number";} }; class DateField extends Field{ typeValidation(v){return v instanceof Date;} toJSON(){return this.v.toISOString();} fromJSON(v){return new Date(v);} };
ListField
class StringListField extends Field{ typeValidation(v){return v instanceof Array && v.every(item=>typeof item == 'string');} }; class NumberListField extends Field{ typeValidation(v){return v instanceof Array && v.every(item=>typeof item == 'number');} };
MapField
class StringMapField extends Field{ typeValidation(v){return !(v instanceof Array) && typeof v == "object" && Object.values(v).every(item=>typeof item == 'string');} }; class NumberMapField extends Field{ typeValidation(v){return !(v instanceof Array) && typeof v == "object" && Object.values(v).every(item=>typeof item == 'number');} }; class DateMapField extends Field{ typeValidation(v){return !(v instanceof Array) && typeof v == "object" && Object.values(v).every(item=>item instanceof Date);} toJSON(){return mapValue(this.v, v=>v.toISOString());} fromJSON(v){return mapValue(v, v=>new Date(v));} };
EntityField
class EntityField extends Field{ constructor(cls){ super(); this.cls = cls; } typeValidation(v){return v instanceof this.cls;} fromJSON(v){return (new this.cls).parse(v);} }; class EntityListField extends Field{ constructor(cls){ super(); this.cls = cls; } typeValidation(v){return v instanceof Array && v.every(item=>item instanceof this.cls);} fromJSON(v){return v.map(json=>(new this.cls).parse(json));} }; class EntityMapField extends Field{ constructor(cls){ super(); this.cls = cls; } typeValidation(v){return !(v instanceof Array) && typeof v == "object" && Object.values().every(item=>item instanceof this.cls);} fromJSON(v){return mapValue(v, json=>(new this.cls).parse(json));} };
Enum & EnumField
class Enum{ constructor(...values){ Object.defineProperty(this, "_values", {value:values, enumerable:false, writable:false, configurable:false}); values.forEach(v=>Object.defineProperty(this, v, {value:v, enumerable:false, writable:false, configurable:false})); } isValid(v){return this._values.indexOf(v) > -1;} } class EnumField extends Field{ constructor(en){ super(); this.en = en; } typeValidation(v){return this.en.isValid(v);} }; class EnumListField extends Field{ constructor(en){ super(); this.en = en; } typeValidation(v){return v.every(item=>this.en.isValid(item));} }; class EnumMapField extends Field{ constructor(en){ super(); this.en = en; } typeValidation(v){return !(v instanceof Array) && typeof v == "object" && Object.values().every(item=>this.en.isValid(item));} };
Entity
class Entity{ static union(base, ...sub){ if(!sub.every(cls=>cls.prototype instanceof base)) throw "invalid subclass"; const parse = json=>{ let target; if(!sub.some(cls=>Object.entries((target = new cls)._fields).every(([key, field])=>{ const jsonValue = json[key]; if(jsonValue == undefined && field.get() === undefined) return false; else{ target[key] = field.fromJSON(jsonValue); return true; } }))) throw "no matched sub class"; return target; }; Object.defineProperty(base, "parse", {value:parse, enumerable:false, writable:false, configurable:false}); Object.defineProperty(base.prototype, "parse", {value:parse, enumerable:false, writable:false, configurable:false}); } constructor(){Object.defineProperty(this, '_fields', {value:{}, enumerable:false, writable:false, configurable:false});} parse(json){ Object.entries(this._fields).forEach(([key, field])=>{ const jsonValue = json[key]; if(jsonValue == undefined && field.get() === undefined) throw 'no key in json:' + key; this[key] = field.fromJSON(jsonValue); }) return this; } toJSON(){return this._fields;} define(field, descriptor){ Object.defineProperty(this, field, this._fields[field] = descriptor); return descriptor; } string(field){return this.define(field, new StringField);} number(field){return this.define(field, new NumberField);} date(field){return this.define(field, new DateField);} stringList(field){return this.define(field, new StringListField);} numberList(field){return this.define(field, new NumberListField);} entity(field, targetClass){return this.define(field, new EntityField(targetClass));} entityList(field, targetClass){return this.define(field, new EntityListField(targetClass));} entityMap(field, targetClass){return this.define(field, new EntityMapField(targetClass));} enum(field, en){return this.define(field, new EnumField(en));} enumList(field, en){return this.define(field, new EnumListField(en));} enumMap(field, en){return this.define(field, new EnumMapField(en));} };
Member & Partner
class Partner extends Entity{ constructor(){ super(); this.string("name") } }; class Member extends Entity{ constructor(){ super(); this.string("name").validation(v=> 3 <= v.length && v.length <=10 ); this.number("age").validation(v=> 15 <= v && v <= 30); this.date("birth"); this.stringList("dev"); this.define("events", new DateMapField); this.entity("partner", Partner); this.entityList("friends", Member); } }; const member = new Member; member.parse({ "name":"hika", "age":18, "birth":"2003-08-01T15:00:00.000Z", "dev":["java", "js", "kotlin", "swift"], "events":{ "kickoff":"2021-02-05T14:00:00.000Z", "1stmeet":"2021-02-15T14:00:00.000Z" }, "partner":{ "name":"jidolstar" }, "friends":[ { "name":"jidolstar", "age":17, "birth":"2004-09-01T15:00:00.000Z", "dev":["java", "js", "kotlin", "swift"], "events":{}, "partner":{ "name":"hika" }, "friends":[] }, { "name":"easy", "age":16, "birth":"2005-10-01T15:00:00.000Z", "dev":["js"], "events":{}, "partner":{ "name":"hika" }, "friends":[] } ] });
Div & Group & Team
class Div extends Entity{ constructor(){ super(); this.string("title"); } } class Group extends Div{ constructor(){ super(); this.entityList("sub", Div); //Div를 합타입으로 봄 } } class Team extends Div{ constructor(){ super(); this.stringList("member"); } } Entity.union(Div, Group, Team); const div = Div.parse({ "title":"개발실", "sub":[ { "title":"프론트엔드팀", "sub":[ {"title":"프론트엔드1팀", "member":["park", "kim"]}, {"title":"프론트엔드2팀", "member":["jung", "sam"]} ] }, {"title":"백엔드팀", "member":["ahn", "joe"]} ] }); console.log(div);
Fruits & User
const Fruits = new Enum("apple", "banana", "orange"); class User extends Entity{ constructor(){ super(); this.string("name").decorator(v=>"hello, " + v); this.enumList("fruits", Fruits).decorator(list=>list.map(v=>"yummy " + v)); } } const user = (new User).parse({ "name":"hika", "fruits":["apple", "banana"] }); console.log(user);
감사합니다
온라인 강의는 이제 안하시는 건가요?ㅜㅜ89기 이후로 안올라와서요