86-3

코드스피츠 86 객체지향 자바스크립트 - 3회차

저번 시간에는 모델의 변화를 수동으로 Binder를 호출하여 렌더링했었다. 그렇다면 자동으로 모델의 변화를 감지하여 Binder가 호출될 수 있게 하려면? Observer 패턴을 이용해야 한다. 이번 강의에선 Observer 패턴과 함께 전략 패턴 Strategy Pattern을 알아볼 것이다.


전략 패턴Strategy Pattern

전략패턴에서 전략이란 Strategy를 직역한 잘못 번역된 언어에 가깝다. Strategy는 범용적으로 문제를 해결하기 위한 도메인 또는 지식을 가리킨다. 어떠한 문제를 추상적진 개념에서 범용적으로 정의할 수 있다면 지식/ 도메인 부분만 변경하면 문제를 처리할 수 있다는 개념이다.
개념적으로 파악하는 것보다는 코드를 보고 이해하는 것이 더 빠를 것이므로 우선 지난 강의에서 작성한 Binder 클래스를 다시 살펴보자

export const Binder = class {
    #items = new Set; // 객체지향에서 객체의 컨테이너는 언제나 Set이다. Identifier Context에서 중복된 값을 사용하지 않기 때문.
    add(v, _ = type(v, BinderItem)) {
        this.#items.add(v); 
    }

    render(viewmodel, _ = type(viewmodel, ViewModel)) {
        this.#items.forEach(item => {
            const vm = type(viewmodel[item.viewmodel], ViewModel), el = item.el; //viewmodel의 subkey도 ViewModel이여야 함. BinderItem에 대한 형검사는 add메소드에서 수행하였기 때문에 다시 수행할 필요가 없다
            Object.entries(vm.styles).forEach(([k, v]) => el.style[k] = v);
            Object.entries(vm.attributes).forEach(([k, v]) => el.setAttribute(k, v));
            Object.entries(vm.properties).forEach(([k, v]) => el[k] = v);
            Object.entries(vm.events).forEach(([k, v]) => el["on" + k] = (e) => v.call(el, e, viewmodel)); //this를 확정하고 매개인자로 e와 viewmodel의 2가지를 전달
        });
    }
}

render 메소드에서는 바인더의 필드인 #items와 필드의 자료구조인 Set을 루프를 돌고 있다. 이는 바인더라는 객체가 이러한 자료구조를 가지고 있기 때문에 발생하는 일로써 이를 Structure&Control 이라고 한다. 구조에 의하 제어가 결정되는 것이다. 또한, render의 매개변수인 viewmodel의 객체 구조에 따라 styles, attributes, properties, events 등의 프로퍼티등을 가지므로 해당 프로퍼티들의 Object.entries를 순회하는 것이다. 이러한 구조에 의한 제어는 Strategy라고 할 수 없다. 문제 해결을 위한 전략이 아니기 때문이다. 그렇다면 여기서 Strategy란 무엇일까?

바로 아래 부분이다. (파란색으로 블락된 부분)

render(viewmodel, _ = type(viewmodel, ViewModel)) {
        this.#items.forEach(item => {
            const vm = type(viewmodel[item.viewmodel], ViewModel), el = item.el; //viewmodel의 subkey도 ViewModel이여야 함. BinderItem에 대한 형검사는 add메소드에서 수행하였기 때문에 다시 수행할 필요가 없다
            Object.entries(vm.styles).forEach(([k, v]) => `el.style[k] = v);`
            Object.entries(vm.attributes).forEach(([k, v]) => `el.setAttribute(k, v));`
            Object.entries(vm.properties).forEach(([k, v]) => `el[k] = v);`
            Object.entries(vm.events).forEach(([k, v]) => `el["on" + k] = (e) => v.call(el, e, viewmodel));` //this를 확정하고 매개인자로 e와 viewmodel의 2가지를 전달
        });
    }

이 부분이야 말로 특정 도메인을 해결하기 위한 지식/알고리즘/ 도메인에 해당되므로 Strategy이다. 바로 이 Strategy 부분을 외제화 시킴으로써 문제를 해결하기 위한 도메인 Strategy를 외부에서 주입받을 수 있다.

하지만 StrategyStructure에 관여하게 된다. 객체 지향에서 객체는 자신만의 상태를(Structure) 가지고 외부에 행위(Strategy)를 표현하기 때문에 현재 DOM element를 렌더링하는 Strategy는 viewmodel이라는 자료구조에 따라 이루어지고 있다. 즉 특정 자료구조가 특정 알고리즘에 맵핑되므로 Strategy는 특정 데이터 구조를 지칭하는 포인터를 가지고 있어야 한다.

위의 Strategy를 도메인에 따라 변화를 주기 위해서는 어떤 방법을 사용해야 할까? 만약 코드를 고치게 된다면, 이는 곧 해당 객체에 의존하는 모든 객체를 테스트하고 해당 객체의 안전성을 검사하는 회귀테스트를 시행해야한다는 것과 같다. OCP를 위반하게 되는 것이다. 따라서 객체지향에서는 이 부분을 Composition을 통해 해결한다. Composition이란 코드를 객체로 바꾸는 것이다. 코드에서 Strategy를 도출했으면 Strategy와 Structure가 맺는 관계를 고려하여 객체를 만든다. 이 때 코드와 일반화된 관계를 맺기 위해서는 임의의 객체가 아닌 인터페이스 또는 클래스 등의 타입으로 도출해야 타입의 규격을 코드에 반영할 수 있게 된다. 타입이란 곧 코드가 관계를 맺는 객체가 어떤 객체인지를 아는 것을 말한다.

타입을 정의해서 객체를 받아들인다는 것은 바로 의존성을 맺게 되는 것이다. 위의 Strategy 코드를 Composition으로 객체로 합성하고 타입을 정의하여 받아들이게 되면 BInder class는 해당 객체와 의존성을 갖게 된다.

의존성을 가지는 맺는 타입의 서브타입을 내부에서 생성하는 것과 공급받는 것 둘 중에서는 당연히 외부에서 공급받는 편이 훨씬 유연하다. 외부에서 서브타입을 공급받는 것은 곧 의존성 역전 (Dependency Injection, DI) 이며, 의존성 역전이 이루어져야만 코드 내부에 타입에 대한 의존성을 제외한 나머지 의존성을 제거할 수 있다. 내부에서 생성하게 되면 또다른 의존성만 생성하게 되므로 타입의 공급은 외부에서 이루어져야 한다.

연역적 추리로 다양한 구상화로부터 추상화될 수 있는 일반성을 도출하여 Strategy의 공통점을 도출하면 Strategy를 아우르는 타입을 정의할 수 있다. 위 코드의 Strategy를 살펴보면 공통적으로 el, k, v 그리고 viewmodel로 Strategy가 구성되므로 위의 4가지 요소로 타입을 정의해볼 수 있다.

const Processor = class{
		cat;
		constructor(cat){
			this.cat = cat;
			Object.freeze(this);
		}
    process(vm, el, k, v, _0=type(vm, ViewModel), _1=type(el, HTMLElement), _2=type(k,"string")){
        this._process(vm,el,k,v); //자식한테 위임한다. 의존성을 상속받는다. 이를 Template메소드라고 함. 자바에서는 protected메소드
    }
    _process(vm,el,k,v){throw "override";} //이렇게 자식에게 의존하는 메소드를 hook이라고 함
}
//타입스크립트로 표현해본 Processor
abstract class Processor{
	constructor(public readonly cat) {}
	process(vm:ViewModel, el:HTMLElement, k:string, v:any){
		this._process(vm,el,k,v);
	}
	abstract _process(vm:ViewModel, el:HTMLElement, k:string, v:any);

}

process 메소드는 앞서 연역적으로 도출한 공통점인 vm, el, k, v를 매개변수로 받아서 _process 메소드에 위임한다. 이는 오버라이드를 가정하기 때문에 자식에게 의존하는 메서드이며 따라서 이 composition은 부모와 자식사이에 일어나게 된다. 앞서서는 외부에서 Strategy 객체를 공급받는다고 가정하여 얘기하였지만 이처럼 부모 자식간의 Composition으로 상속받은 객체에게 위임할 수도 있다. 이 때 의존성 역전 DI 의 방향은 자식과의 사이에 성립한다. 이를 Template Method Pattern이라고 한다. 외부에서는 process를 template 메서드로 인식하지만 내부에서는 자식쪽으로 injection된 _process 메소드에 의존하고 있으며 이를 hook이라고 부른다.

이제 앞으로 Binder의 render 메서드는 Processor 클래스 타입의 인스턴스를 받아서 Strategy를 위임하게 된다. Processor 클래스는 throw로 오버라이드를 가정하고 있으므로 추상 클래스라고 할 수 있다.사실 타입스크립트에서는 abstract로 간단하게 표현할 수 있는 부분이다.

el.style[k]=v 라는 Strategy는 vm의 styles 라는 키와 관련이 있다. 따라서 vm의 key 값이 바로 Strategy를 결정하는 Structure가 되므로 Processor에 vm의 키값을 속성으로 가지게 하여 연관된 Structure가 무엇인지에 대한 힌트를 갖게 한다.

그럼 이제 사용해보자

new (class extends Processor{
	_process(vm:ViewModel, el:HTMLElement, k:string, v:any){
		el.style[k] = v
	}})('styles')

styles라는 키를 갖는 vm의 자료구조에 이 Processor 타입의 전략을 적용할 수 있는 힌트를 갖게 된다. 자바스크립트에서 클래스는 값이며 마치 즉시 실행함수와 같이 사용하여 재사용 불가능한 고유한 클래스 인스턴스를 생성할 수 있다.

Processor라는 Strategy를 외부에서 주입한 Binder 클래스는 다음과 같이 코드가 변할 것이다.

const Binder = class{
    #items = new Set;
    #processors = {}; //카테고리별로 하나의 프로세서만 사용하기 위해서 Set이 아니라 카테고리를 키로 가지는 객체로 만듬. 다만 객체지향에서 값으로 식별하면 안되기 때문에
    // cat을 symbol로 만들어주면 identifier context 임. 
    add(v,_=type(v,BinderItem)){this.#items.add(v);}
    addProcessor(v,_0=type(v,Processor)){
        this.#processors[v.cat] = v; //카테고리 key값의 k-v를 하나만 가지기 위해서 객체로 선언한다. 동일한 cat을 추가할 시에 나중에 추가한 값(processor 객체)로 바뀌게 된다.
        //cat을 Symbol()로 정의해서 해결할 수도 있다. 하지만 여기서는 viewmodel의 키가 문자열이므로 cat도 동일하게 문자열로 만든다.
    }
    render(viewmodel, _=type(viewmodel, ViewModel)){
        const processors = Object.entries(this.#processors); // Binder는 Processor와 계약했다. 따라서 Processor 계약한 내용만 사용하도록 알고리즘을 고쳐야 한다.
        this.#items.forEach(item=>{
            const vm = type(viewmodel[item.viewmodel], ViewModel), el=item.el;
            processors.forEach(([pk,processor])=>{ //알고리즘의 일반화
                Object.entries(vm[pk]).forEach(([k,v])=>{
                    processor.process(vm, el, k, v); // 외부에서 공급된 타입을 이용하여 알고리즘을 일반화
                })
            })
        })
    }
}

객체 컨텍스트에서 processors의 집합을 가지기 위해서는 Set으로 관리하는 것이 좋을 텐데 왜 객체를 선언해서 값 컨텍스트인 문자열로 processor를 저장했을까? 우선 여기서는, vm의 카테고리 당 하나의 프로세서만을 사용하기 위해 카테고리당 하나의 프로세서를 저장하려고 객체로 저장하는 것이다. 객체로 저장하더라도 키를 Symbol로 선언하면 고유한 키값을 가지기 때문에 Identifier context가 된다. 다만 여기서는, viewmodel의 키가 현재 문자열로 선언하였으므로 동일하게 문자열로 선언하였다. 또는, Symbol.for(*문자열)* 이라는 Symbol 클래스의 메소드를 이용하여 Symbol을 선언하더라도 키값의 중복을 막을 수 있다.
타입에서 정의한 프로토콜만으로 알고리즘을 변경했다. 이러한 작업을 객체 사이의 의존성 또는 계약이라고 부른다. 바인더는 프로세서와 계약했기 때문에 프로세서의 계약 명세인 cat과 processor를 사용하는 것이다.

Strategy와 Structure를 분리한 다음, Strategy를 연역적인 추론을 통해 공통점을 찾아내고, StrategyStructure와 맺는 관계를 도출하여 어떻게 이를 위임할지를 타입을 정의하여 타입에 따라 알고리즘을 수정하는 것 - 이 Strategy를 외부에서 주입받기 위해 거쳐야 하는 일련의 과정이다.

바인더에 Strategy를 공급하는 코드는 다음과 같다.

binder.addProcessor(new class extends Processor {
    _process (vm, el, k, v) { el.style[k] = v }
}('styles'))
binder.addProcessor(new class extends Processor {
    _process (vm, el, k, v) { el.setAttribute(k, v) }
}('attributes'))
binder.addProcessor(new class extends Processor {
    _process (vm, el, k, v) { el[k] = v }
}('properties'))
binder.addProcessor(new class extends Processor {
    _process (vm, el, k, v) { el[`on${k}`] = e => v.call(el, e, vm) }
}('events'))

이 과정까지 거치면 바인더가 프로세서를 의존하게 된 것이다. 객체지향에서 의존성이 생기는 건 불가피한 일이지만 의존성의 방향은 단방향이어야 한다. 프로세서는 바인더를 몰라야 하고 바인더의 변화에 영향을 받지 않는다. 따라서 이 의존성은 단방향이며, 바인더가 의존성을 주입받고 있다.(Dependency Injeciton). 런타임으로 Processor를 주입해주지 않으면, Binder 클래스는 BinderItem에 존재하는 카테고리를 처리할 수 없게 된다.


옵저버 패턴 Observer Patttern

지금까지는 뷰모델에서 직접 수동으로 바인더를 호출하여 렌더링해주었지만, 많은 프레임워크에서는 뷰모델의 변화에 따라 렌더링이 자동으로 일어난다. 이는 바인더가 뷰모델을 옵저빙하고 있기 때문이다. 즉 뷰모델의 변화를 감시하고 있다가 알아서 바인더가 변화를 알아차리고 렌더링하는 것이다. 이게 어떻게 가능할까? 실제로는 이런 마법이 발생할리 없으므로 변화를 알아차릴 Observer가 관찰의 대상이 되는 Subject에게 감시하겠다는 사실을 통보하면, Subject가 자신의 변화를 Observer에게 알려주는( Notify) 과정이 옵저버 패턴에서 일어나는 일이다. 옵저버 패턴에서 주의할 점은, 실제로는 감시하는 Observer가 적극적으로 알아서 변화를 알아차리거나 하지 않고 감시당하는 Subject가 감시하는 Observer에게 변화를 보고한다는 것이다. 감시당하는 Subject가 해야할 일이 감시하는 Observer가 해야할 일보다 훨씬 많은 점이 실제 세계와 컴퓨터 세계의 옵저버 패턴과 다른 점이라고 할 수 있다. 또한, 자신의 변화를 알아차릴 수 있어야 한다는 점도 주의할 점이다. 인메모리 객체에서 자신의 속성의 변화를 알아차릴 방법에는 어떤 것들이 있는가?

첫번째는 defineProperty, 두번째는 Proxy가 있다. Proxy는 ES6 이후부터 지원되지만 바벨로 컨버팅이 되지 않는 다는 치명적인 단점이 있다. 따라서 구형 안드로이드 브라우저 등에서의 지원은 포기해야만 한다. 구형 브라우저 호환성이 중요한 도메인에서는 defineProperty 사용이 강제된다. 안드로이드 킷캣의 수명은 대체 몇년동안 이어질까?

강의에서는 실무에 최대한 가깝게 구현한다는 취지로 `definePropertyAPI를 사용했지만, 보다 최신 스펙인 *Proxy*API`를 사용해서 동일한 코드를 작성해보고 둘을 비교해 볼 것이다.

객체지향이므로 옵저버도 다음과 같이 타입으로 정의해야 할 것이다.

const ViewModeListener = class{
    viewmodelUpdated(updated){throw "override";}
}

옵저버를 ViewModelListener라는 타입으로 인식할 것이다. 뷰모델 리스너는 Subject가 업데이트를 알릴 시 실행될 viewmodelUpdated라는 명세를 가진다고 정의하였다.

뷰모델은 Subject로써, 자신을 Observe하는 리스너들의 Set인 listeners와, 업데이트된 내용을 한 번에 알리기 위해 모아두는 Set인 isUpdated를 가진다. 알려야 할 시점이 되면 notify()메소드를 통해서, listerners set에 있는 모든 observer들에게 정의된 명세인 viewmodelUpdated에 자신의 업데이트 내용인 this.#isUpdated set을 주고 실행하게 할 것이다. 주의할 점은 업데이트 내용들은 여러가지를 한 번에 모아서 알리기 때문에 마찬가지로 set으로 저장해야 한다는 점이다.

const ViewModel = class { 
    static #private = Symbol()
    styles={}; attributes={}; properties={}; events={};
		#listeners = new Set; #isUpdated = new Set;
		addListener(v, _=type(v, ViewModelListener){
			this.#listeners.add(v);
		}
		removeListener(v, _=type(v, ViewModelListener){
			this.#listeners.delete(v);
		}
		notify(){
			this.#listeners.forEach(v=>v.viewmodelUpdated(this.#isUpdated));
		}

그럼 이제, 자신의 변화를 알아차리기 위해 쓰기 작업인 Setter를 중간에 가로채는 코드를 보자. 앞서 constructor에서 객체를 전달받고나서 전략 패턴을 사용해서 외부 객체를 공급받아 전략 문제를 위임했던 것을 기억할 것이다. 잠시만 전략 객체에 관한 부분은 지우고, 생성시 입력받은 data 객체에 대한 트랩을 설정하는 과정을 살펴보자. data객체에 대해서 defineProperties와 Object.entries를 사용해서 모든 키에 대한 트랩을 설정해준다. 이 때 enumerable은 true로 iteration이 가능하게 해준다. 이 부분은 ProxyAPI에서는 getOwnPropertyDescriptor 트랩을 사용하여 동일하게 처리한다.

두 번째 코드는 동일한 코드를 Proxy API를 사용해서 작성해본 것이다.

//defineProperty를 사용한 코드    
constructor(checker, data, _=type(data, "object")) {
        if (checker != ViewModel.#private) throw  'use Viewmodel.get()';
        super();
        Object.entries(data).forEach(([cat,obj])=>{
            if("styles,attributes,properties".includes(cat)){ 
                if(!obj || typeof obj!= 'object') throw `invalid object cat:${cat} obj:${obj}`
                this[cat] = Object.defineProperties(obj,
                    Object.entries(obj).reduce((r,[k,v])=>{
                        r[k] = {
                            enumerable:true, //for in 또는 entries에서 열거되도록 enurable 프로퍼티 true 설정
                            get:_=>v,
                            set:newV=>{
                                v = newV;
                                this.#isUpdated.add(...)
                            }
                        }
                        return r;
									}, {}))}
							else{...}//아직 구현하지 않은 부분
        Object.seal(this);
  }

//Proxy를 사용한 코드
constructor(checker, data, _=type(data, "object")) {
        if (checker != ViewModel.#private) throw  'use Viewmodel.get()';
        super();
        Object.entries(data).forEach(([cat,obj])=>{
            if("styles,attributes,properties".includes(cat)){ 
                if(!obj || typeof obj!= 'object') throw `invalid object cat:${cat} obj:${obj}`
									this[cat] = new Proxy(obj,{
                    getOwnPropertyDescriptor:(target, p) =>({enumerable:true}),
                    get:(target, name)=>{
                        return target[name];
                    },
                    set:(target,name,value)=>{
                        target[name] = value;
												this.#isUpdated.add(...)
                        return true;
                    }
                });
						else{...}
				Object.seal(this);
}

데이터가 업데이트 되어 setter가 호출될 때 마다 뷰모델의 #isUpdated set에는 알려야할 업데이트 내용들이 축적될 것이다. 그런데 이 때 들어가야 할 내용은 무엇일까? setter에서 알 수 있는 건 키와 값인데, 단순히 키와 값만으로는 어떤 카테고리가 업데이트 됐는지 알 수 없으므로 당연히 현재 업데이트된 객체의 키/밸류와 부모 객체의 키값을 같이 보내줘야 한다. 예를 들어, style의 display키의 밸류가 ‘flext’로 업데이트 됐다면 이것이 어떤 업데이트인지 알려면 style과 display, ‘flex’라는 3가지 내용이 전부 있어야 업데이트의 모든 부분을 파악할 수 있게 되는 것이다. 따라서 #isUpdated 셋에 들어가게 될 밸류는 위 세가지 필드를 가진 또다른 타입으로 정의할 수 있다.

const ViewModelValue = class{
   cat;k;v;
    constructor(cat,k,v) {
        this.cat = cat;
        this.k = k;
        this.v = v;
        Object.freeze(this) //변경 불가(1회 사용)
    }
 }

업데이트시 쌓일 데이터의 타입은 앞으로 ViewModelValue가 될 것이다.
따라서 앞서 setter에서 추가될 코드는 아래와 같다.

//Proxy를 사용한 코드
set:(target,name,value)=>{
      target[name] = value;
			this.#isUpdated.add(new ViewModelValue(cat,k,v))
      return true;
}

기본 속성이 아닌 속성이 들어오게 되면 다음과 같이 작동할 것이다. 단일 ViewModel만 들어오게 되므로 defineProperty를 사용하면 되고, 카테고리는 빈 문자열로 표시해 준다.

else{
	Object.defineProperty(this,cat, {
      {enumerable:true,
      get:_=>obj,
      set:newObj=>{
          obj=newObj;
          this.#isUpdated.add(new ViewModelValue("", cat,obj)) //그 외 속성은 1개씩 defineProperty,
      }
      }
  })
}

Composite 합성

컴포지트 패턴은 바로 동일한 문제를 계속 위임하는 것을 반복해서 이를 취합하는 것을 말한다. 우리가 만든 뷰모델 안에는 뷰모델이 들어있다. 옵저버가 구독하는 뷰모델은 가장 상위의, 하위 뷰모델들을 소유하고 있는 뷰모델뿐이고, 하위의 뷰모델들은 구독하고 있지 않다. 그 이유는 바로 실제 렌더링 행위를 하는 바인더가 가장 상위 뷰모델하고만 계약하고 있기 때문이다. 바인더는 가장 상위 뷰모델 하나와만 계약함으로써 나머지 상위 뷰모델에 속한 나머지 하위 뷰모델을 전략객체에 전부 위임할 수 있었다. 따라서 가장 상위의 뷰모델은 옵저버에게 2가지를 알려야 한다.

  1. 자기 자신의 변화 (this.#isUpdated)
  2. 자신에게 속한 하위 뷰모델의 변화

어느 깊이의 하위 뷰모델까지 알려줘야 하냐면 가장 끝까지 전부 알려줘야 한다. 자신에게 속한 모든 하위 뷰모델의 변화를 알릴 책임이 상위 뷰모델에게 있는 것이다. 따라서 상위-하위 관계의 책임을 동일하게 계속해서 위임해야 하는 Composition 문제가 된다. 일반적으로 Composition문제는 알고리즘에서는 동적 계획법 또는 트리탐색으로 해결하지만, 객체지향에서는 이처럼 동일한 문제를 계속해서 위임하는 것을 반복하고 취합하는 동적 위임으로 해결한다.

따라서 기본값 속성이 아닌 ViewModel의 인스턴스가 객체의 속성으로 들어올 경우 우리는 해당 인스턴스의 변화를 알아야 할 필요가 있다. 즉 부모 뷰모델이 하위 뷰모델의 옵저버(ViewModelListener)가 되야 한다.
업데이트의 기본 단위인 ViewModelValue의 필드 속성 또한 부모 뷰모델인지 하위 뷰모델인지를 나타내는 subKey까지 포함하도록 확장해야 한다. 즉, 기존에는 업데이트 정보가 style의 display가 ‘flex’가 됐다는 정보를 담고 있었다면, 이제는 어떤 하위 뷰모델인지를 나타내는 subKey를 포함해서 wrapper라는 뷰모델의 style의 display가 ‘flex’가 됐다고 알리게 된 것이다.

const ViewModelValue = class{
    subKey;cat;k;v;
    constructor(subKey,cat,k,v) {
        this.cat = cat;
        this.k = k;
        this.v = v;
        this.subKey=subKey;
        Object.freeze(this) //변경 불가(1회 사용)
    }
 }

또한, 자식 뷰모델의 subKey는 부모가 할당하게 된다. subKey가 빈 문자열인 경우에는 루트 뷰모델일 경우가 되며, parent는 현재 뷰모델의 상위 뷰모델을 가리킨다.

const ViewModel = class extends ViewModel{
subKey=""; parent=null;
#subjects = new Set; #inited=false
constructor(checker, data, _=type(data, "object")) {
    if (checker != ViewModel.#private) throw  'use Viewmodel.get()';
        super();
        Object.entries(data).forEach(([cat,obj])=>{
            if("styles,attributes,properties".includes(cat)){...}
	else{
		Object.defineProperty(this,cat, {
	      {enumerable:true,
	      get:_=>obj,
	      set:newObj=>{
	          obj=newObj;
	          this.#isUpdated.add(new ViewModelValue(this.subKey, "", cat,obj)) //그 외 속성은 1개씩 defineProperty, this.subKey는 자기 자신의 입장이면서 동시에 자식 뷰모델의 입장
	      }
	      }
	  }
		if(obj instanceof ViewModel){
			obj.parent = this;
			obj.subKey = cat;
			obj.addListener(this); //내가 자식의 listener가 됨
		}
		}
Viewmodel.notify(this) //ViewModel의 static 메소드로 모든 뷰모델의 notify를 하나의 requestAnimationFrame으로 걸기 위한 메소드
Object.seal(this);
}
	viewmodelUpdated(updated){
		updated.forEach(v=>this.#isUpdated.add(v));
	}
}

옵저버 패턴은 현실세계에서는 Composition이 일어나는게 일반적이다. 하나하나 옵저빙하는 것이 아니라 다른 객체의 변화를 한번에 취합해서 보고하는 것이 효율적이기 때문이다. Subject가 Observer도 되는 것이다. 따라서 우리는 ViewModel을 ViewModelListener를 상속받도록 개조하였다. ViewModelListener로써 하는 일은 viewmodelUpdated 메소드로 자식의 업데이트가 들어올 시 자식의 업데이트를 자신의 업데이트 (*this.#isUpdated*)에 취합하는 것이다. 바로 이 부분에서 취합이 일어난다. 업데이트의 타입인 ViewModelValue의 subKey는 자신과 자식의 업데이트를 구분해주는 역할을 한다. subKey도 중복을 방지하기 위해서 Symbol로 생성하는 것이 좋다. ViewModelValue와 같이 옵저버가 수신하는 타입을 info 객체라고 부른다. 브라우저의 이벤트를 예시로 들면 AddEventListener가 수신하는 이벤트 객체가 바로 info 객체이다. info객체의 기능이 부족하면 옵저버가 Subject에 대한 참조를 만들게 되고, 기능이 너무 과다하면 유지보수가 힘들어진다. 따라서 info객체의 밸런스를 조절할 수 있는 좋은 디자인 의사결정이 필요하다.

생성시에 Viewmodel.notify(this)를 하는 코드가 추가되었다. 이 코드는 ViewModel의 static 메소드로 뷰모델의 생성시에 생성된 뷰모델들을 취합하여 하나의requestAnimationFrame에 요청한다. 이를 통해 setter의 발동시마다 notify와 렌더링이 일어나는 것이 아니라 전체 뷰모델의 변화를 한 프레임마다 취합해서 한번씩만 렌더링하는 효율적인 렌더링을 할 수 있다.

따라서 다음 메소드르를 ViewModel의 static 메소드로 추가한다.


static #subjects = new Set; static #inited = false; //플래그 기반 변수. 싱글 스레드에서는 자주 사용한다.
static notify(vm){
        this.#subjects.add(vm);
        if(this.#inited)return;
        this.#inited = true;
        const f = _=>{
            this.#subjects.forEach(vm=>{
                if(vm.#isUpdated.size){
                    vm.notify();
                    vm.#isUpdated.clear();
                }
            })
            requestAnimationFrame(f);
        }
        requestAnimationFrame(f)
		}

전체 뷰모델을 관리하는 static #subjects 필드와, 현재 뷰모델이 static notify가 발동이 되었는지를 나타내는 플래그 필드인 static #inited 필드를 추가했다. 스태틱 메소드의 인자에 일반 메소드처럼 타입체킹을 하지 않는 이유는 스태틱 메소드가 내부에서만 사용되어 내부적으로 타입이 확정된 상태이며 외부에 노출되지 않기 때문이다.
static notify를 사용함으로서 Subject 뷰모델들은 다음과 같은 notify 메소드의 워크플로우를 가진다.

  1. 생성 시에 static notify()로 자기 자신을 static #subjects에 등록
  2. requestAnimationFrame으로 등록된 뷰모델들에 대해서 updated가 있을 때 notify로 리스너들에게 알림
  3. 업데이트 내역 초기화

스태틱 수준의 단일 루프인 #subjects로 requestAnimationFrame을 한 번만 등록할 수 있다. 객체마다 requestAnimationFrame을 등록하지 않아도 된다. 루프를 순회하는 연산이 훨씬 자원을 적게 소모하기 때문에 인터벌을 여러개 등록하는 행위는 퍼포먼스의 심각한 저하를 일으킨다. 따라서 하나의 루프에 인터벌을 하나만 등록하는 것이 좋다.

그럼 이제 뷰모델의 가장 중요한 Observer라고 할 수 있는 바인더의 역할을 살펴보자.

앞서 Binder의 전략 객체를 Processor로 도출하여 외부에서 공급받은 바 있다. 이제 바인더는 Observer 로써의 책임을 가지고 ViewModelListener 인터페이스를 가지게 된다. 즉 바인더는 이제 프로세서를 담는 그릇이자 실제 렌더링을 위한 바인더 이자 Observer 라는 다각적인 역할을 수행하게 되므로 인터페이스 분리 원칙에 따라 ViewModelListener를 상속하여 Observer로써의 측면을 분리해준 것이다.

const Binder = class extends ViewModelListener{
    #items = new Set;
    #processors = {}; //카테고리별로 하나씩 가지기 위해 객체로 만듬. 키로 갱신가능. 다만 객체지향에서 값으로 식별하면 안되기 때문에
    // cat을 symbol로 만들어주면 identifier context 임. 여기선 예외. 값을 노출할 때는 신중하게 생각해라.
    viewModelUpdated(target, updated) {
        const items = {};
        this.#items.forEach(item=>{
            items[item.viewmodel] = [type(target[item.viewmodel], ViewModel), item.el]
        })
        updated.forEach(v=>{
            if(!items[v.subKey])return; //바인더 아이템에 존재하지 않는 하위 viewmodel일 경우 리턴
            const [vm, el] =items[v.subKey], processor = this.#processors[v.cat]
            if(!el || !processor)return;
            processor.process(vm,el, v.k,v.v);
        })
    }
    add(v,_=type(v,BinderItem)){this.#items.add(v);}
    addProcessor(v,_0=type(v,Processor)){...}
    render(viewmodel, _=type(viewmodel, ViewModel)){...}
    watch(viewmodel, _=type(viewmodel, ViewModel)){
        viewmodel.addListener(this);
        this.render(viewmodel)
    }
    unwatch(viewmodel, _=type(viewmodel, ViewModel)){
        viewmodel.removeListener(this);
    }
}

우선 현재 BinderItem을 담는 자료구조인 Set은 매번 forEach로만 루프를 돌아야 하므로 자료구조를 객체로 변경하여 각각의 viewmodel 문자열 키로 실제 뷰모델 객체와 네이티브 DOM Element를 값으로 가지게 한다. 이렇게 자료구조를 변경하는 것은 알고리즘 전략을 변경하는 것으로 매번 #items를 루프를 돌지 않기 위함이다.

그 후 업데이트를 forEach로 루프를 돈다. 앞서 info 객체인 ViewModelValue에는 subKey, cat, k, v 가 있다고 했다. 현재 바인더에서 해당 업데이트 info의 subKey 는 곧 BinderItem의 viewmodel 키와 같은데, subkey가 존재하지 않는 것은 이 바인더가 그 업데이트를 처리할 수 있는 BinderItem이 없다는 뜻으로 바로 return을 해주게 된다. 그 후 앞서 바꾼 items 객체에서 해당하는 subKey를 가진 BinderItem을 찾고, 해당 cat 전략 객체를 찾아서 processor.process(vm,el, v.k, v.v) 로 업데이트의 변화분을 Strategy 객체인 processor에 렌더링하도록 위임하면 끝이다.
한가지 눈여겨 볼 점이 있는데 전략객체라고 생각했던 Processor 객체가 viewmodelUpdated 메소드와 render 메소드 두 곳에서 모두 사용된다는 것이다. 외부에서 공급되는 전략이 두 군데 이상의 알고리즘에 개입할 때 이를 Visitor 라고 부른다. Processor에 대한 동일한 일반화 전략 processor.process(vm, el, k, v) 을 사용하는 것을 볼 수 있다.

앞서 이벤트를 속성을 처리하기 위한 Processor를 다음과 같이 정의했었다.

binder.addProcessor(new class extends Processor {
    _process (vm, el, k, v) { el[`on${k}`] = e => v.call(el, e, vm) }
}('events'))

앞서서는 이벤트 객체에 인자로 전달하는 vm이 항상 최상위 뷰모델로 한정됐었다. 하지만 이제 우리는 v.subKey로 계층적 구조를 가진 뷰모델을 전달해 주기 때문에 이벤트 객체가 전달받는 vm도 해당 계층의 뷰모델로 바뀐다.

  const viewmodel = Viewmodel.get({
		isStop: false,
    changeContents() {
        // viewmodel을 갱신하면, binder가 viewmodel을 view에 rendering 한다.
        // 즉, '인메모리 객체'만 수정하면 된다
        this.wrapper.styles.background = `rgb(${getRandom()},${getRandom()},${getRandom()})`
        this.contents.properties.innerHTML = Math.random().toString(16).replace('.', '')
  
    },
		 wrapper: ViewModel.get({  //첫번째 depth에서는 viewmodel의 key값들이 viewmodel이 아니면 typeError(new Binder().render(viewmodel))
        styles: {width: "50%", background: "#ffa", cursor: "pointer"},
        events: {
            click(e, vm) {
                vm.parent.isStop = true;
								console.log('click', vm);
            }
        }
    }),
	})

마지막으로 렌더링을 하기 위한 코드에서 수동으로 render하는 코드가 없어지고 바인더가 뷰모델을 바라보도록 binder.watch(viewmodel)이 추가되면 끝이다.

binder.watch(viewmodel);
const f = _ => {
    viewmodel.changeContents();
    if (!viewmodel.isStop) requestAnimationFrame(f);
}
requestAnimationFrame(f);

이제 수동으로 호출하지 않고 뷰모델만 수정하면 렌더링이 일어나게 되었다.

profile
inudevlog.com으로 이전해용

0개의 댓글