개요
시리즈 1편에서는 기본 값에 대한 모델매핑과 밸리데이션을 알아봤습니다. 이번에는 보다 복잡한 구조의 json을 어떻게 모델에 매핑할지를 연구해보겠습니다. 복잡한 json이란 뭘까요?
{ "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" } }
이 json에는 date객체, 배열과 객체가 포함되어있습니다. 하나씩 정복해보죠. 그 전에 한 가지 먼저 짚고 갈 것이 있는데..
Field의 기본값 문제
Field의 기본값은 함부로 지정하면 안됩니다. 우리는 엔티티의 필드선언을 한 것이지 기본값을 명시적으로 할당한 것은 아니기 때문입니다. 또한 엔티티 참조의 경우는 기본값을 재귀참조에 의해 생성할 수 없을 수도 있습니다.
따라서 Field 생성자에서 기본값을 할당하는 기능을 제거합니다. 또한 상속받은 StringField 등도 super에 보내던 기본값을 더 이상 보내지 않습니다.
class Field{ constructor(){ 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; } } typeValidation(v){ throw "must be override!"; } validation(validator){ this.validator = validator; return this; } }; class StringField extends Field{ typeValidation(v){ return typeof v != "string";} }; class NumberField extends Field{ typeValidation(v){ return typeof v != "number";} };
대신 기본값을 주고 싶은 경우는 명시적인 default메소드를 통해 지정합니다. 이를 Field에 추가하죠.
class Field{ constructor(){ const self = this; self.get =_=>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);} }; class Member extends Entity{ constructor(){ super(); this.stringValue("name").default("hika"); } }
이제 함부로 기본값이 주어지지 않고 위와 같이 명시적으로 default메소드를 통해 필요한 경우에만 지정합니다.
Field의 toJSON과 fromJSON
birth는 단순한 문자열이 아닙니다. 날짜의 ISOString 표현입니다. json포멧의 한계상 문자열로 표현되어있지만 엔티티입장에서는 Date객체로 바꾸는 것을 의도하는 것이죠. 이는 보다 근본적인 질문인데
- entity에 포함된 객체형 데이터를 어떻게 json형식으로 시리얼라이즈하고
- 반대로 json의 데이터로부터 entity에 지정된 객체형으로 언시리얼라이즈할 것인가
에 대한 구조를 어찌할 것인가의 물음입니다.
지금까지는 string, number등 json에도 있고 js에도 있는 형을 썼기 때문에 눈에 띄지 않았을 뿐입니다.
이는 Field수준에서 시리얼라이즈와 언시리얼라이즈에 관여해야 한다는 뜻이고 보통 js에서 json으로 시리얼라이즈하는 것은 toJSON으로 정해져있으니 언시리얼라이즈는 fromJSON으로 정의하겠습니다.
class Field{ constructor(){ const self = this; self.get =_=>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);} //기본 시리얼라이즈 - 너를 믿는다 toJSON(){ return this.v; } //기본 언시리얼라이즈 - 너도 믿는다 fromJSON(v){ return v; } };
우선 Field수준에서 toJSON과 fromJSON의 기본 구현을 해두고 서브클래스에서는 이것을 override해서 확장하는 것으로 하죠. 기본 구현은 위 코드대로 아무것도 안하는 코드입니다. 이제 DateField를 만들어보죠.
class DateField extends Field{ //Date형인지 검사 typeValidation(v){return v instanceof Date;} //override! toJSON(){ return this.v.toISOString(); //iso문자열로 json에 출력 } //override! fromJSON(v){ //json의 문자열로부터 Date객체 생성 return new Date(v); } };
이제 Field의 구상객체가 특수한 형식을 갖고 있다면 toJSON이나 fromJSON을 override하는 것으로 대응할 수 있습니다. 반대로 StringField나 NumberField는 원래 json형과 js형이 일치하기 때문에 Field에 정의된 기본 구현으로 충분합니다.
이를 Entity가 toJSON과 parse에 반영해주면 됩니다.
class Entity{ 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) throw 'no key in json:' + key; //할당하기 전에 먼저 fromJSON을 거친다. this[key] = field.fromJSON(jsonValue); }); return this; } //이젠 Field가 알아서 toJSON에 대응함 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);} //DateField용 도우미 date(field){return this.define(field, new DateField);} };
이제 Member에 birth를 추가할 수 있게 되었습니다.
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") } }; 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" } }); member.birth // Date객체출력
(원래 parse의 로직이 엔티티에 정의한 키가 있는지 확인하는 것이지 엔티티에 없는 키가 추가로 있는 것을 예외처리하지는 않습니다. 그래서 dev나 events같은 엔티티에 정의되지 않은 키가 있어도 큰 문제는 없습니다. 중요한건 엔티티에 있는 키는 확실히 json에 있어야한다는거죠)
배열 데이터 정복
이제 다음으로 dev와 events를 정복해봅시다.
{ "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" } }
- dev키의 배열은 정확하게는 문자열의 배열입니다. 즉 List<String> 정도로 인식할 수 있습니다.
- events의 객체는 매우 정확하게 표현하자면 Map<String, Date> 로 인식할 수 있습니다.
근데 json의 객체는 언제나 키가 문자열입니다. 즉 Map<String, T> 라고 인식할 수 있습니다. 배열 또한 List<T>로 인식할 수 있죠. 둘다 T형 하나만 알면 된다는 것입니다. 이를 바탕으로 Field객체를 확장하면 stringList, numberList 라던가 dateMap, stringMap, numberMap 정도의 도우미 메소드가 생겨나면 되겠다는 생각을 할 수 있습니다.
이를 위해 Field를 확장하여 각각의 구상 필드 클래스를 정의해보죠.
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');} };
우선 문자열은 원소로 갖는 stringList의 경우 배열이면서 그 원소 전부가 문자열인 것을 확인해주면 됩니다. 마찬가지 방식으로 numberList도 정의했습니다. Entity에도 도우미 클래스를 추가하고 Member에 이를 이용해 dev필드를 추가해줍니다.
class Entity{ 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) 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);} }; 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"); //dev필드 추가 this.stringList("dev"); } }; 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" } }); member.dev //["java", "js", "kotlin", "swift"]
파서는 정의되지 않은 추가적인 json키를 무시하므로 events가 있어도 잘 파싱됩니다. 정상적으로 작동하는 것을 알 수 있습니다. 근데 매번 Field클래스를 만들 때마다 Entity에 도우미 함수를 만들어야하는 것은 아닙니다. 그저 돕기만 하는 것이므로 자주 쓰는게 아니라면 define함수를 사용해도 됩니다.
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"); //define으로 직접 추가 this.define("dev", new StringListField); } };
자주 쓰는 기능을 편하게 하려고 Entity에 도우미 함수를 등록하는 거죠.
객체형 정복
이제 events를 정복할 차례입니다. 이 타입은 DateMapField 라고 정의할 수 있겠죠.
이 구현에서 객체형의 경우 각 키의 값만 변경한 새 객체를 만드는 경우가 많습니다. 이를 위해 간단한 유틸리티인 mapValues를 정의해보죠.
const mapValue = (target, block)=>{ return Object.fromEntries( Object.entries(target).map(([key, value])=>[key, block(value)]) ); };
이 구현은 fromEntries와 entries를 이용해 간단히 각 키의 값만 바꿀 수 있게 돕습니다(fromEntries는 글쓰는 시점에서 아직 stage3지만 거의 모든 브라우저가 구현했습니다)
이제 이 도우미 함수를 이용해 간단히 toJSON과 fromJSON을 구현할 수 있습니다.
class DateMapField extends Field{ typeValidation(v){ return !(v instanceof Array) && typeof v == "object" && Object.values(v).every(item=>item instanceof Date); //모든 값이 Date형이어야 함 } toJSON(){return mapValue(this.v, v=>v.toISOString());} fromJSON(v){return mapValue(v, v=>new Date(v));} };
이제 이걸 멤버에 직접 반영합니다. DateMapField는 굳이 엔티티에 도우미함수를 등록할 정도로 흔하진 않을 것이므로 그냥 define을 이용합니다.
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); } }; 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" } }); member.events //{"kickoff":"2021-02-05T14:00:00.000Z", "1stmeet":"2021-02-15T14:00:00.000Z"}
엔티티형을 처리하기
이제 배열이나 객체를 비롯해 특수한 형을 처리할 수 있게 되었습니다. 하지만 엔티티의 백미는 엔티티를 속성으로 갖는 엔티티입니다. 이제 partner를 추가해보죠
{ "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" } }
이 json은 또 다른 Partner를 Field로 갖는 partner라는 속성이 존재합니다. 이걸 멤버에 추가하기 위해 EntityField라는걸 정의해보죠.
EntityField는 특정 Entity를 생성할 수 있어야 하므로 Entity생성자를 인자로 받아야 합니다.
class EntityField extends Field{ //실제 엔티티 타입의 클래스를 인자로 받아들인다. constructor(cls){ super(); //받아온 클래스를 기억해둔다. this.cls = cls; } typeValidation(v){ return v instanceof this.cls; //받아왔던 클래스의 인스턴스인지 확인 } // toJSON()을 override하지 않아도 this.v가 엔티티이므로 자동으로 그 엔티티의 toJSON이 호출됨 fromJSON(v){ const result = new this.cls; //인스턴스를 생성하고 result.parse(v); //값으로 들어온 json객체로 파싱한다. return result; } };
이건 자주 사용될 것이므로 엔티티에 도우미 함수도 정의합니다.
class Entity{ 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) 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));} }; 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); //partner에 Member타입의 엔티티필드 추가 this.entity("partner", Partner); } }; 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" } }); member.partner //Member의 인스턴스
엔티티 리스트와 엔티티 객체의 처리
이제 마지막으로 엔티티를 원소로하는 배열이나 객체를 처리하는 EntityListField와 EntityMapField만 정의하면 끝납니다. 이미 반복적으로 많은 Field를 만들어왔으므로 이 노하우를 이용해 단숨에 정의합시다.
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)); //parse함수는 원래 자신을 반환함 } }; 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)); } };
생성자에서 엔티티 타입 클래스를 받아들이고 fromJSON에서 parse한다는 점을 제외하면 대동소이한 구현입니다.
이제 friends같은 필드가 있어도 손쉽게 정의하여 파싱할 수 있습니다.
{ "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":[] } ] }
엔티티에 entityList와 entityMap 도우미 함수를 정의하고 Member에 friends를 추가해줍니다.
class Entity{ 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) 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));} }; 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); //friends 추가 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":[] } ] }); member.friends//Member의 배열
결론
이번에는 기본형이 아닌 Date와 같이 전용 타입에 대응하는 방법, 배열이나 객체에 대응하는 방법, 또다른 엔티티를 포함할 뿐 아니라 엔티티의 배열이나 객체를 다루는 방법까지 json내의 데이터 구조에 대한 대응 전반을 정리했습니다.
다음 시리즈 마지막에서는 A 또는 B로 해석되는 데이터를 비롯하여 옵셔널 필드가 존재하는 구조까지 실무에서 발생하는 다양한 문제와 거기에 대응하는 방법을 알아봅니다.
여기까지 등장한 전체 코드는 아래와 같습니다.
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 =_=>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);} 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));} };
Entity
class Entity{ 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) 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));} };
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":[] } ] });
항상 좋은 글 감사합니다.
감사합니다 🙂 최근 공부하면서 고민했던 부분에 대한 실마리가 여기있었습니다!
감사합니다