86 객체지향 자바스크립트 4회차

이누의 벨로그·2022년 3월 6일
0

86-4

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

💡 객체지향 프로그래밍은 이루고자 하는 목표에서 부터 덩어리진 코드를 클래스로 분리하고 깎아내는 과정이라고 할 수 있다. 이 때 어떻게 깎아낼 것인지를 정하는 기준은 `역할`, `책임`이다

객체지향에서는 역할과 책임을 한꺼번에 정의한다.

인터페이스 분리 원칙(ISP)에 따라 하나의 코드에 여러가지 역할이 중첩되어 있다면 역할 별로 코드를 분리할 수 있다. 현재 우리는 매우 헤비한 ViewModel 코드를 가지고 있다. 이를 역할 별로 분리할 수 없을까?

그럼 우선 분리할 수 있는 인터페이스를 찾아보자.

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));
		}
}

뷰모델에 있는 addListener ,removeListener, notify 은 뷰모델의 역할이 아닌 옵저버 패턴에서의 Subject 의 역할을 위한 메소드이다. 따라서 이 역할은 분리해야 한다. 역할을 분리할 때 주의할 점은 행위에 해당하는 메소드를 분리하면 행위에 해당하는 상태인 필드 또한 같이 분리해야 한다는 것이다. 여기서는 #isUpdated 필드와 #listeners 필드에 해당된다.

그렇다면 뷰모델에 존재하는 static notify 함수는 뷰모델의 역할에 해당하는가? 아니다. 해당 함수의 역할은 뷰모델이 생성될 때 최초 1회에 한해서 자신의 notify 행위를 하나의 스태틱 루프로 취합하도록 하는 것으로, 마찬가지로 뷰모델의 역할이 아닌 옵저버 패턴의 Subject 로써의 역할에 해당된다.

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)
		}

따라서 인터페이스 분리 원칙에 따라서 위의 Subject로써의 역할에 해당하는 코드들을 분리할 것이다.

인터페이스 분리를 구현하는 방법은 소유 모델을 사용해서 분리할 메소드를 소유하는 전략객체를 주입하거나, 메소드 별로 인터페이스를 분리하는 것이다. 여기서는 이 두가지와도 살짝 다른 방법을 사용할 건데 바로 상속을 이용하는 방법이다. Subject의 역할을 담당하는 인터페이스를 분리하는 대신 ViewModel이 상속받도록 하는 것이다. 이부분은 사실 정석적인 방법과는 약간 다른 부분인데, 자바스크립트나 다중 상속을 지원하지 않기 때문에, ViewModelSubjectViewModelListener 둘 다를 상속받으려면 ViewModelSubject 가 우선 ViewModelListener 를 상속받고, ViewModel 이 다시 ViewModelSubject 를 상속하는 방식으로 상속을 중첩하는 방법밖에는 없다. 실제로는 타입스크립트에서라면 Subject 인터페이스와 Listener 인터페이스를 다중 implementation 하거나, 아니면 인터페이스가 없는 자바스크립트에서는 소유 모델로 이를 해결할 수 있을 것이다.

그럼 앞서 Subject 의 역할을 담당하는 코드를 ViewModelSubject 로써 분리해보자.

const ViewModelSubject = class extends ViewModelListener{
    #info = new Set; #listeners = new Set;
    static #subjects = new Set; static #inited = false; //플래그 기반 변수. 싱글 스레드에서는 자주 사용한다.
    static #notify(){
        const f =_=>{
            this.#subjects.forEach(v=>{
                if(v.#info.size){
                    v.notify();
                    v.clear();
                }
            })
            if(this.#inited) requestAnimationFrame(f); //false면 멈춤
       };
        requestAnimationFrame(f);
    }
    add(v, _=type(v, ViewModelValue)){this.#info.add(v);}
    clear(){this.#info.clear();}
    addListener(v, _=type(v, ViewModelListener)){
        this.#listeners.add(v);
        ViewModelSubject.watch(this);
    }
    removeListener(v, _=type(v, ViewModelListener)){
        this.#listeners.delete(v);
        if(!this.#listeners.size) ViewModelSubject.unwatch(this)
    }
    static #watch(vm, _=type(vm, ViewModelListener)){
        this.#subjects.add(vm);
        if(!this.#inited){
            this.#inited=true;
            this.notify() //static notify
        }
    }
    static #unwatch(vm, _=type(vm, ViewModelListener)){
        this.#subjects.delete(vm);
        if(!this.#subjects.size) this.#inited = false;
    }
    notify(){
        this.#listeners.forEach(v=>v.viewModelUpdated(this.notifyTarget, this.#info)) //viewmodel을 날려줘야 하므로
    }
    get notifyTarget(){throw 'override'}
}

#isUpdated라는 필드명을 Subject라는 역할에 보다 맞게 #info set으로 변경하였다. 그 외에도 addclear 메소드를 추가하였는데 이는 부모 클래스가 된 ViewModelSubject 의 private 필드는 자식이 접근할 수 없기 때문에, 이전에는 직접 setter에서 #isUpdated 에 info 객체를 add하거나 clear해줄 수 있었지만 이제는 이를 접근하려면 부모로 부터 메소드를 상속받아 접근하는 방법밖에 없기 때문이다.

또한,이전에는 ViewModel의 생성 시점에 static #subjects 루프에 뷰모델을 등록하고 static notify() 로 requestAnimationFrame에 업데이트를 취합했었다. 하지만 이제는 생성 시점에 등록하는 것이 아니라, addListener 로 나 자신에 대한 옵저빙이 시작되는 시점에 static watch 메소드로 보고대상에 등록되도록 시점을 분리하였다. 따라서 static notify()메서드는 기존의 스태틱 루프에 대상을 등록하고 #inited 플래그로 시작여부를 판단하는 제어를 static watch 메서드로 위임하게 되었다. removeListener 는 한가지 로직이 더 추가되었는데, 바로 더 이상 자기자신을 옵저빙하는 옵저버가 없으면 static unwatch 메서드를 통해 스스로를 스태틱 루프에서 제거하고, 더 이상 보고할 대상이 없으면 #inited 플래그를 제어하여 스태틱 루프를 중지시킬 수 있게 되었다. 따라서 이제는, static notify() 메서드가 다음 프레임이 시작하기 전에 #inited 플래그에 따라 취합을 계속할지 여부를 결정하게 된다. 즉, 리스너가 없는 뷰모델은 더 이상 업데이트를 취합하지 않는다.

그럼 이제 이전과 업데이트를 취합하기 위한 오퍼레이션이 어떻게 변화하였는지 살펴보자. 기존에는

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

의 순서로 오퍼레이션이 진행되었다면


  1. addListener로 자기자신에 대한 옵저버를 추가할 때 static watch()로 자기자신을 static #subject에 등록하고 static notify()호출

  2. requestAnimationFrame으로 프레임마다 #subjects에 등록된 subject 들의 보고를 전부 전달

  3. removeListener로 옵저버를 제거했을 때 #listeners에 더 이상 자신을 옵저빙하는 옵저버가 없으면 static unwatch()#subjects에서 자신을 제거. #subjects에도 더 이상 보고할 subject가 존재하지 않으면 #inited 플래그를 false로 변경

  4. 다음 프레임에 재귀적으로 자신을 호출하기 전에 #inited 플래그의 제어에 따라 다음 프레임에 호출할지 말지를 결정

    즉 이제부터 업데이트를 취합하기 위해 바깥에 노출된 오퍼레이션은 static notify()가 아니라 static watch, unwatch가 되며 static notify() 은 캡슐화된다. 따라서 staic notify()는 이제 static private 으로 권한을 조정할 수 있다(static #notify()). 넓게 보아 static watch, unwatch 메서드들도 캡슐화 되었다고 본다면 외부에 최종적으로 노출된 오퍼레이션은 옵저버에 대한 addListener, removeListener 메서드라고 할 수 있다.

    그럼 이제 뷰모델로부터 Subject 역할을 하는 코드를 모두 분리했다. 이제 뷰모델 내부의 권한을 보다 섬세하게 조정해보자.

일반적으로 접근 제어자를 걸 때는 private > internal > protected > public 순으로 최소권한에서부터 필요할 때마다 권한을 풀어주는 방식을 사용한다.

자바나 코틀린 등의 언어는 섬세한 권한 제어자를 제공하며, 자바는 기본 제어자가 internal이다. 하지만 자바스크립트의 기본 제어자는 public이기 때문에, 메소드 마다 하나하나 권한을 제어해 주지 않으면 권한 조정에 실패하기 쉽다. 외부에 그대로 노출되어 버리는 것이다. public으로 공개된 코드는 유지보수하기 어렵다.

const ViewModel = class extends ViewModelSubject{ //ViewModel은 Subject(Listen 당하는 사람) 이면서 동시에 Listener
    static #private = Symbol()
    #subKey="";
    #parent=null;
    styles={}; attributes={}; properties={}; events={};
    static get(data){return new ViewModel(this.#private, data)}
    get subKey(){
        return this.#subKey; //public getter private setter, 외부에는 공개, 쓰기는 private
    }
    get parent(){return this.#parent;}
    _setParent(parent, subKey){ //typescript에서는 private method로 쓸 수 있음, 인자가 2개라서 setter로 쓸 수 없음
        this.#parent = type(parent, ViewModel);
        this.#subKey = subKey;
				this.addListener(parent);
    }

subKey는 내부에서 계층구조에 따라 결정되며 외부에서는 단지 get만 하고 있으므로 getter만 public으로 외부에 공개하고 setter는 은닉한다. 이를 private setter public getter 패턴이라고 한다. 외부에는 공개하고 쓰기는 private으로 은닉한다. parent또한 계층구조로 내부에서 결정되므로 private이 된다. setParent처럼 한번에 일어나야 하는 일련의 과정을 transaction이라고 하며 하나의 함수로 표현하게 된다. 그 이유는 하나의 함수로 묶어야 일련의 과정을 생략하거나 오염시키는 것을 방지하고 트랜잭션으로 코도를 인식할 수 있기 때문이다. 어떠한 트랜잭션 메서드가 필요에 의해 생겨나면, 해당 트랜잭션 메서드로 변경되는 필드는 트랜잭션 메서드 외에는 변경이 불가능하도록 private으로 권한을 조정해야 한다. 따라서 트랜잭션 메서드로 인하여 권한 관계의 조정이 일어나게 된다.

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)) { //데이터 처리기Processor가 있으므로 하드코딩 하지말고 동적으로 처리해야 한다.
                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.add(new ViewModelValue(this.subKey, cat, name, target[name]))
                        return true;
                    }
                })
            }
            else {
                Object.defineProperties(this, {
                    [cat]:{enumerable:true,
                    get:_=>obj,
                    set:newObj=>{
                        obj=newObj;
                        this.add(new ViewModelValue(this.#subKey,"", cat,obj)) //그 외 속성은 1개씩 defineProperty, //ViewModelSubject에게 위임
                    }
                    }
                })
                if(obj instanceof ViewModel){ //하위가 뷰모델이면 상위가 하위의 옵저버가 되어야 한다.
                    //자식의 변화와 본인의 변화를 구분하기 위해(외부에다 전파하므로) subkey 속성을 하나 더 만들고 부모자식 관계를 설정한다.
                   obj._setParent(this); 
                }
            }
        });
        Object.seal(this);
		}
		viewModelUpdated(target,updated) {
        updated.forEach(v=>this.add(v));
    }
}

constructor에서 기존에 setter마다 직접 #isUpdateed에 ViewModelValue를 추가해줬던 것 대신 ViewModelSubject 로써 상속받은 this.add를 실행한다. 또한, 기존의 생성시에 취합보고를 했던 ViewModel.notify() 메서드가 사라졌고, 자식의 부모를 설정해주는 트랜잭션 메소드인 _setParent를 사용한다. 마찬가지로, ViewModelListener의 인터페이스인 viewModelUpdated에서도 Subject 로써 this.add()를 실행하면 된다.

앞서 한가지 상태를 변화하기 위에 실현되어야 하는 일련의 과정을 transaction이라고 했다. 만약, 코드에서 이러한 transaction을 발견한다면 항상 함수로 이를 외제화하는 것이 좋다. 코드를 함수로 만든다는 것은 코드가 참조하는 컨텍스트(지역변수, 클로저 등)를 연역적으로 도출하여 인자로 명시해야 한다는 것을 의미하고, 이에 따라 암묵적으로 컨텍스트를 가지는 대신 명시적으로 인자를 받는 함수가 되어 코드의 가독성이 훨씬 좋아지게 된다. 단지 코드가 예뻐진다는 것만으로도 트랜잭션의 함수화는 충분히 의미가 있는 일이다.


Visitor Pattern 비지터 패턴

기존의 Scanner 클래스를 보자

export const Scanner = class {
    scan(el, _ = type(el, HTMLElement)) {
        const binder = new Binder;
        this.checkItem(binder, el);
        const stack = [el.firstElementChild];
        let target;
        while (target = stack.pop()) {
            this.checkItem(binder, target);
            if (target.firstElementChild) stack.push(target.firstElementChild);
            if (target.nextElementSibling) stack.push(target.nextElementSibling);
        }
        return binder;
    }

    checkItem(binder,el, _=type(binder, Binder)) {
        const vm = el.getAttribute("data-viewmodel");
        if (vm) binder.add(new BinderItem(el, vm)); //
    }
}

기존 스캐너는 하나의 HTMLElement를 인자로 받아서 스택머신을 사용해 루프를 돌며 viewmodel 속성을 가진 바인더 아이템을 생성하고 이 바인더 아이템을 가지는 Binder 클래스의 인스턴스를 최종적으로 생성했었다. 기존에 바인더 클래스와 스캐너 클래스를 분리했던 이유는 바인더는 뷰모델을 이용해서 뷰를 그리는 로직이 바뀔 때 변화하고, 스캐너는 네이티브 뷰를 해석하는 로직이 바뀔 때 변화하므로 서로 변화율이 다르기 때문이라고 했었다. 즉, 네이티브 뷰를 해석하기 위한 전략 알고리즘인 checkItem이 바로 스캐너 클래스를 분리한 이유가 된다. 그렇다면 Scanner 클래스에 네이티브 뷰에 대한 루프를 도는 scan 메서드가 과연 필요한 제어일지에 대해 생각해보아야 한다. 스캐너를 분리한 이유인 checkItem 메서드 외에, scan에서 일어나는 제어는 누구의 몫이 되는가? checkItem에서 네이티브 뷰를 해석하려면 scan에서 일어나는 제어는 뗄레야 뗄 수 없는 부분이다. Scanner가 이 제어를 가지고 있을 이유는 없지만 마찬가지로 이 제어가 뷰모델을 입력받아 BinderItem에 대한 렌더링만을 수행하는 Binder의 몫도 아니다. 따라서 Scanner는 HTMLElement에 대한 루프를 도는 이 제어에 대한 책임을 다른 객체에 위임할 필요가 있다.

이 내용은 상당히 철학적이고 어려운 내용이다. 객체의 역할을 파악하는 것에 숙달되는 것은 충분한 훈련을 통해서만 가능하다.


비지터 패턴에는 두가지 등장인물이 있다. 원본 데이터 구조인 CareTakerVIsitor . CareTakerVisitor를 소유하며 Visitor에게 자신의 데이터 구조에 대한 루프를 도는 제어를 위임한다.

const Visitor = class{ //visitor의 역할은 caretaker(visitor를 받는 대상)과 visitor로 나뉜다. caretaker가 visitor을 소유하고 자기 자신을 visit하도록 제어한다.
    visit(action, target, _0=type(action, "function")){ //target에 대해서는 override할 수 있도록 형을 정하지 않는다.
        throw 'override';
    }
}

target은 HTML Element가 될 수도, JSON 객체가 될 수도 있다. 네이티브 객체에 따라 타입을 달리하기 위해 타입을 정하지 않는 추상 클래스를 만든다.

그럼 HTML DOM Element을 타겟으로 하는 구상 클래스를 만들어보자.

구상 클래스에서 구체적인 형을 정하는 이러한 형태는 다른 언어에서는 제네릭이 된다. 타입스크립트에서는 다음과 같이 제네릭을 사용할 수 있다.

abstract class Visitor<T>{
	abstract visit(action:function, target:T);
} 

class DomVisitor extends Visitor<HTMLElement>{
    visit(action: Function, target: HTMLElement) {
				
    }
}

자바스크립트에서는 자바스크립트 나름대로의 방식으로 이를 구현할 수 있다.

const DomVisitor = class extends Visitor{ //DOM visitor 다른 언어에서는 제네릭으로 표현된다.DOM에 대한 순회제어 코드
    visit(action, target, _0=type(action, "function"), _1=type(target, HTMLElement)){ //target으로 DOM을 받는다. 다른 언어에서는 제네릭으로 표현된다.
        const stack = [];
        let curr = target.firstElementChild;
        do{
            action(curr); //CareTaker와의 상호작용
            if(curr.firstElementChild)stack.push(curr.firstElementChild);
            if(curr.nextElementSibling)stack.push(curr.nextElementSibling);
        }while(curr = stack.pop())
    }
}

구상 Visitor 클래스는 제어코드를 가지게 된다. 어떤 스캐너든, 어떤 객체든 DomVisitor를 소유함으로써 DOM 엘리먼트에 대한 순회 제어 코드는 무조건 DomVisitor의 제어코드를 사용하게 되는 제어 역전이 일어난다. 제어코드를 외부로 위임하는 제어 역전이 중요한 이유는, 그것을 통해 제어 코드를 한군데에 집중시킬 수 있으며 수정여파를 격리하여 제어를 안정화하기가 훨씬 쉽기 때문이다. Scanner가 제어를 자기 안에 가질 필요가 없는 것이다.

이전에 전략 패턴에서는 Strategy 문제를 해결하는 역할을 분리하기 위해 코드를 객체로 분리했다. 하지만 비지터 패턴에서는 제어를 외부로 위임하기 위해서 코드를 객체로 분리하게 된다. 제어를 외부에 위임하기 위해서는, 자연스럽게 위임하는 CareTaker 객체가 Visitor 객체에 관여할 수 있는 부분이 있어야 한다. CareTaker없이 혼자 제어를 수행하는 Visitor는 있을 수 없기 때문이다. Visitor와 상호작용하기 위해 바로 action으로 실행할 함수를 전달한 것이다. action은 CareTaker와 Visitor의 일종의 소통 창구가 된다.

그럼 Visitor와 계약을 맺음으로써 Scanner가 어떻게 바뀌었는지를 보자.

const Scanner = class{
	#visitor;
	constructor(visitor, _=type(visitor, DomVisitor){
		this.#visitor = visitor;
	}
	scan(target, _=type(target, HTMLElement)){
    const binder = new Binder, f=el=>{
        const vm = el.getAttribute("data-viewmodel");
        if(vm) binder.add(new BinderItem(el, vm))
    }
    f(target);
    this.#visitor.visit(f, target);
    return binder;
	}
}

DomVisitor는 Scanner뿐만 아니라 Dom의 루프를 도는 제어를 사용하는 모든 클래스에서 제어를 위임받을 수 있다. 따라서, 루프를 도는 제어의 역할은 Scanner의 책임이 아니라는 것이 명백해졌다. 이러한 설계의 필요성을 느끼고 제어코드를 분리하는 일은 어려운 일이다. 소프트웨어의 설계란 곧 코드를 재배치하는 기술로 객체지향에서 코드를 재배치하는 이유는 바로 역할 때문이다. 즉 코드 수준에서 코드의 역할이 무엇인지 느낄 수 있어야 설계를 할 수 있다. 객체의 역할을 알아차리는게 아니라 코드의 역할을 알아차리는 것이 곧 설계의 지름길이다.

이제 스캐너는 루프제어에 에러가 나서도 아니고, DOM이 잘못되서도 아니고, 오직 DOM 네이티브 뷰를 해석하는 로직이 잘못됐을 때만 에러가 발생한다. 코드의 응집력이 상당히 높아진 것을 알 수 있다.


추상화 계층의 일치

추상화를 진행하다 보면 추상화 계층이 생기는데, 계층간의 계약은 매우 섬세한 조정이 필요한 문제이다. 여기서 계약이란, 어떠한 객체가 다른 객체를 알게 되는 의존성과 같은 의미이다.

우리는 Visitor 클래스를 DOM에 대한 의존성이 전혀 없는 추상 클래스로 만들었다. 반면 DomVisitor는 DOM에 대한 의존성을 가지는 구상 클래스다. 이렇게 계층을 분리한 이유는 DOM에 의존적인 네이티브 객체인 DomVisitor와 네이티브 지식이 없는 순수한 Visitor을 분리하기 위함이다.

앞서 만든 Scanner 클래스에서는 Visitor에게 정확하게 DOM 엘리먼트를 전달하기 위해 생성자에서 DomVisitor 구상 클래스를 소유하고 있다. 하지만 이렇게 소유한 DomVisitor로 사용하는 실제 메서드는 Visitor 추상클래스 수준에서 정의된 visit이다. 자식 타입을 소유하면서 실제 사용은 부모 수준에서 정의된 메서드를 사용하는 이러한 상태를 계약 불일치라고 한다. 즉 Scanner가 자식(구상) 수준의 DomVisitor를 소유하지만 실제 사용하고 있는 지식은 부모 수준에서 정의된 visit이라는 모순이 발생하는 것이다. 따라서 Scanner는 부모 수준의 Visitor 추상 클래스와 계약을 맺어야 하지만, 그렇게 되면 현재 DOM에 의존적인 내부 코드가 망가지는 문제가 발생한다. 추상 클래스와 계약을 맺는다는 것은 그것을 확장한 수많은 구상 클래스를 허용한다는 뜻이기 때문이다. 따라서, 현재 DOM 엘리먼트에 의존적인 Scanner 클래스의 지식을 구상계층으로 분리하고 네이티브 지식이 없는 추상클래스로 만들어서, Scanner 추상클래스는 Visitor 추상클래스와 계약을 맺고, DomScanner 구상클래스는 DomVisitor 구상클래스와 계약을 맺도록, 동일한 추상계층끼리 계약을 맺도록 계층을 일치시키는 작업을 해야 한다.

그럼 우선 Scanner 추상 클래스를 만들어보자

const Scanner = class{
	#visitor;
	constructor(visitor, _=type(visitor, Visitor){
		this.#visitor = visitor;
	}
	visit(f,target){this.#visitor.visit(f,target);}
	scan(target){throw "override";}
};

우선 자식이 부모의 private 필드인 #visitor를 접근하기 위해 메소드를 제공해야 한다. 그리고 실제로는 구상클래스에서 구체적으로 타입이 결정되도록 (제네릭) target의 타입을 구체화하지 않고 scan 메소드를 추상메소드로 만들면 부모 클래스는 네이티브 지식이 전혀 없는 추상클래스가 되어 Visitor와 추상 레이어가 일치하게 된다. 생성자에서 추상 계층이 일치하는 객체들끼리 계약을 맺도록 하면 된다.

구상 클래스끼리 계약을 맺도록 마저 변경해보자.

const DomScanner = class extends Scanner{
    constructor(visitor, _=type(visitor, DomVisitor)) {
        super(visitor);
    }
    scan(target ,_ = type(target, HTMLElement)) {
        const binder = new Binder, f = el => {
            const vm = el.getAttribute('data-viewmodel');
            if (vm) binder.add(new BinderItem(el, vm))
        }
        f(target);
        this.visit(f, target); //부모가 제공하는 visit 메소드드
        return binder;
    }
}

DomScanner는 DomVisitor와 계약을 맺는다. 또한, 계약을 맺은 구상 클래스로 부모 클래스 생성자에 전달하는 것도 자식이 부모를 대체할 수 있는 리스코프 치환원칙이 성립하므로 문제가 없다. 다음으로는, scan메서드에서 앞서 제네릭으로 타입을 정하지 않은 target의 타입을 확정해주면 scan메서드도 구상화가 된다. 또한, 추상 클래스끼리 맺은 계약으로 제공한 visit 메서드를 위임받아 사용하게 된다. 이렇게 우리는 순수한 추상 클래스와 네이티브 지식을 가진 구상 클래스를 분리하였다.

이제서야, 우리는 Scanner와 Visitor가 맺은 추상 클래스끼리의 계약과 DomScanner와 DomVisitor가 맺은 구상 클래스끼리의 계약을 분리할 수 있게 되었다. 추상 레이어(계층)의 일치가 된 것이다.

마틴 파울러는 이를 도메인 패턴이라고 명명했다. 도메인 패턴이란 기능적인 계층과 도메인적인 계층을 나누어서 서로 협력하게 만들어야 도메인 계층만 교체할 수 있다는 것이다. 여기서 우리가 바라보는 도메인은 DOM이 되는 것이고, 기능적인 계층은 추상 클래스 계층이 된다. 물론 어떤 경우에는 우선 순위에 따라 도메인 계층과 기능 계층이 서로 바뀌는 경우도 있다. 요점은 어떤 것이 도메인이냐, 기능 계층이냐가 중요한 것이 아니라, 변화율에 따라 변하는 부분과 변하지 않는 부분을 분리해야 한다는 것이다. 변화율로 코드를 나눔으로써 우리는 코드를 수정하지 않고 확장하게 된다. 예를 들어 내가 Canvas를 다루는 Scanner와 Visitor를 만들려 한다면 기존에는 코드를 수정해야 했지만 이제는 CanvasScanner와 CanvasVisitor를 새롭게 추가하는 것으로 문제가 해결된다. 이는 곧 수정에는 닫혀 있고 확장에는 열려 있는 OCP 원칙을 준수하게 되는 것이다.

이렇게 계층을 분리할 때 또 다른 좋은 점은 바로 필요할 때 동적으로 로딩할 수 있다는 점이다. 따라서 코드의 늦은 초기화나 늦은 클래스 로딩을 유도하므로 의존성 역전(Dependency Inversion)이 달성된다.

scanner를 사용하는 코드는 다음과 같다.

//기존 코드: const scanner = new Scanner; 
const scanner = new DomScanner(new DomVisitor);

인간의 뇌는 복잡성을 다루는데 한계가 있지만, 컴퓨터는 한계가 없다.

우리는 분리되지 않은 객체의 코드를 최적화 하는데에 한계가 있다. 하지만 분리를 하면 할 수록, 우리는 인간의 뇌로도 정복할 수 있을 정도로 복잡성을 쪼갤 수 있게 된다. 쪼개면 쪼갤 수록, 우리는 복잡성을 정복할 가능성에 점점 다가가게 되는 것이다. 하지만 바로 쪼개는 이 행위에 일관성을 부여해야 코드를 분리하는 이유를 알 수 있게 되고, 객체지향은 역할로써 코드를 분리하는 역할 모델을 일관성의 기준으로 삼는다. 여지껏, 역할 말고 다른 기준을 찾아낸 사람은 컴퓨터 공학에 없었다.

우리의 설계를 다시 한번 돌아보자. 우리는 뷰모델은 인터페이스 분리 원칙에 따라 subject의 역할과 Listener의 역할로 중첩된 상속을 통해 분리하였다. ViewModel, ViewModelSubjectViewModelListener 모두 info 객체인 ViewModelValue 에 의존하고 있다. 따라서 Info객체는 많은 역할과 의존성을 가지게 되는 무거운 객체가 된다. 무거운 의존성을 가진 ViewmodelValue의 수정은 우리의 뷰모델의 모든 역할들에 여파를 미치기 때문에 수정이 힘들어 진다. 안타깝지만 이는 옵저버 패턴에서 불가피한 부분이다. 옵저버 패턴에서 다양한 옵저버 객체들이 전부 동일한 Info 객체에 의존할 수 밖에 없기 때문이다. DOM 에서 Info 객체란 이벤트 발생시에 전달하는 이벤트 객체가 되며, 제이쿼리 등의 DOM 프레임워크 등이 DOM 네이티브 이벤트를 그대로 사용하지 않고 이를 감싸는 이벤트 객체로 변환하여 사용하는 것도 바로 이러한 이벤트 객체에 대한 무거운 의존성을 정복하기 위해서이다. 하지만 이렇게 무거운 의존성을 가지더라도, 의존성의 방향이 단방향으로만 이루어진다면 성공적인 설계라고 할 수 있다.

ViewModel, ViewModelSubjectViewModelListenerViewModelValue 를 알지만, ViewModelValue 는 이들을 모르므로 바로 단방향 의존성을 가지고 있다. 또한 뷰모델의 역할들 끼리도, ViewModel - ViewModelSubject - ViewModelListener 순서대로 단방향으로만 의존성이 설정되어 있다. 의존성은 무거워 질 수 있지만, 무거운 의존성이 객체가 서로를 참조하지 않도록 양방향 참조를 끊는 것이 중요하다. 객체가 서로를 참조하게 된다면 수정의 원인이 어디에서 부터 오는지 알 수 없는 무한 루프에 빠지므로 프로그램의 유지보수가 불가능해지기 때문이다. 그런 면에서, 현재의 설계는 비록 Info 객체가 무겁긴 하지만 성공적인 설계라고 할 수 있다. 물론, ViewModelValue를 바꾸게 된다면 이에 의존하는 뷰모델의 모든 객체들은 어쩔 수 없이 바꿔야 한다.

이러한 뷰모델 세계의 의존성에서 네이티브 지식에 대한 의존성은 (도메인 의존성)은 전혀 없다. 도메인에 의존적이지 않은 순수한 인메모리 객체들이다.


우리의 객체들의 의존성을 그림으로 살펴보자. 객체의 의존성을 파악하기 위해 폭넓은 UML 다이어그램을 알 필요는 없지만, 단순하게 의존성을 화살표 방향으로 나타낸 도식만으로라도 나타내보는 것이 설계를 확인하는 것에 큰 도움이 되므로 평소 습관화하는 것이 좋다.

Scanner 클래스는 추상 Visitor 레이어와 계약을 맺어 , 구상 DomScanner에게 추상 수준의 visit 메서드를 제공하고, 구상 DomScanner는 이 위임받은 메서드를 통해 필요한 제어를 위임하고 있다. 따라서, 직접적인 DomScanner와 DomVisitor의 의존성은 끊어내고 추상 클래스 수준에서만 계약을 맺게 되었다. 하지만 실제로는, DomScanner가 DomVisitor와 계약을 맺지 않고 다른 구상 클래스와 계약을 맺으면 문제가 발생한다. 계층적으로는 구상 클래스들끼리 의존성을 끊어냈지만 실제 도메인에서는 의존성이 생기는 이러한 경우를 숨겨진 의존성이라고 한다. 런타임에서는 DomScanner와 DomVisitor가 계약을 맺어야 할 필요성이 있는 것이다.

Visitor 클래스를 Scanner에서 분리한 이유는 바로 Visitor 클래스에서 담당하는 제어가 Scanner의 책임이 아니라고 판단했기 때문이다.

Binder 클래스는 ViewModelListener를 상속받아 viewModelValue의 Set을 업데이트시에 받고, 실제 ViewModel의 Listener로써 역할을 하므로 뷰모델 역할 객체들에게 향하는 의존성 화살표를 가지고 있다. 또한, Scanner의 결과물로 생성되므로 Scanner로 부터 오는 단방향 의존성을 가지고 있다. 하지만 Binder는 Scanner를 모르며, 마찬가지로 Binder가 의존하고 있는 뷰모델들도 바인더를 모르므로 양방향 참조가 없는 단방향 의존성만을 가지고 있다.

단방향 의존성(Simplex)만 있는 설계는 우선 성공했다고 할 수 있다. 물론 Binder는 ViewModel 세계의 서로 다른 역할 객체들에게 전부 의존성 화살표를 가지고 있으므로, 수정 변화율이 매우 높은 편에 속한다. 다른 객체들에게 의존하는 화살표 개수가 많으면 변화에 의해 깨지기 쉬운 위험한 객체가 되고, 다른 객체들로부터 들어보는 화살표개수가 많으면 자신의 변화가 많은 객체들에게 영향을 끼치는 무거운 객체가 된다.

Processor는 _process라는 구상 메소드를 구현하는 구상 Processor 클래스를 구현함으로써 실제 도메인을 처리하는 전략을 Binder에 공급하는 역할을 한다. 앞서 연역적 추리로 다양한 구상화로부터 추상화될 수 있는 일반성을 도출하여 Strategy의 공통점을 도출하고 Strategy를 아우르는 타입을 정의하는 과정을 알아봤었다.

실제 DOM에 대한 지식을 가지고 있는 클래스들은 파란색으로 표시되어 있다. 이 세 가지 클래스들은 도메인에 관한 지식을 가진 클래스들로써, 이 부분을 교체함으로써 각기 다른 도메인에 대응할 수 있게 된다. 그 이외의 모든 클래스들은 도메인 지식과는 무관한 순수 인메모리 데이터만을 가진 코드들이다. 이러한 구조가 바로 리액트 네이티브를 비롯한 가상화 플랫폼들이 크로스-플랫폼으로 동작할 수 있는 원리이다. 네이티브 지식을 가진 구상 부분만 교체하여 크로스 플랫폼에 대응할 수 있게 된다.

한가지 눈여겨 볼 것은 뷰모델과 Info 객체, 바인더 사이에 존재하는 무거운 의존성이 바로 옵저버 패턴을 구현하기 위해 추가된 비용이라는 점이다. 스스로의 변화분을 알아차리고 정보를 전달하기 위한 이 모든 비용은 최종적으로 코드를 사용할 시에 바인더를 직접호출하여 new Binder.render(viewmodel) 을 하는 코드 1줄을 줄이기 위함이지만, 우리는 이를 위해 많은 코드를 추가해야만 했다.(definedProperty, 각종 getter/setter 등등...).
또한 우리는 이러한 복잡한 비용을 안게 된 뷰모델과 바인더를 외에 실제 뷰를 프로세스하는 클래스들의 추상화 계층 또한 일치시켜줘야 했다. 뷰모델과 바인더가 도메인 지식과는 무관한 추상 계층이기 때문에, 나머지 이들과 계약을 맺는 클래스들에 도메인 지식이 개입하게 되면 우리의 추상 프레임워크가 도메인 지식으로 오염되고 만다. (우리의 예시에서 도메인 지식은 DOM 밖에 등장하지 않지만 WebGL, 캔버스 등 당신이 상상할 수 있는 거의 모든 도메인이 포함될 수 있다.) 우리는 특정한 네이티브 지식이 나오는 구상 부분들은 항상 추상 계층으로부터 격리시켜야 한다. 그리고 주요 프로세스의 객체망 통신을 추상 계층으로만 성립시켜야 한다. 이것이 바로 도메인 패턴이다. 이를 통해 우리는 어떤 도메인 시스템이 와도 추상 계층을 재활용할 수 있다.

profile
inudevlog.com으로 이전해용

0개의 댓글