86-5

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


지난 3번째 강의에서 우리는 Binder의 도메인 전략을 외부로 위임하기 위한 Processor라는 전략 객체를 만들었었다. 이를 통해 우리는 실제 도메인에서 수행할 전략에 대한 코드는 남김없이 Binder에서 제거할 수 있었다. 그런데 현재 우리의 ViewModel의 생성자를 보자.

class ViewModel extends ViewModelSubject{
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{...}
		}
}

뭔가 이상하지 않은가? 생성자에서 도메인 뷰에 맵핑될 형식을 전부 명시해 (styles, attributes, properties, events) 주고 그 형식에 따라 분기문 if-else를 만들어 전략을 세우고 있다. 따라서 이는 DOM에 종속적인 객체가 되버렸다. 앞서 코드를 정리하면서 우리의 뷰모델이 도메인패턴을 사용하여 전체가 도메인에 독립적인 추상화 계층으로 이루어졌다고 한 것이 무색하게도 말이다. 데이터 구조를 코드로 형식을 구조해줬으니, 앞서 코드 객체로 Composition하여 지킬 수 있었던 객체지향의 SOLID 원칙을 당연히 위반할 수 밖에 없다.
게다가 코드로 데이터 형식을 미리 뷰모델에 정의해 놨으니 실제 ViewModel.get으로 뷰모델을 사용할 때도 이에 맞춰 styles, events 등을 DOM 형식에 맞게 사용할 수 밖에 없다. 우리가 호언한 도메인에 독립적인 추상 프레임워크의 꿈은 물거품이 되는 것이다. 이를 미리 발견하여 의문을 제기하지 못한 것이 이상한 수준이다. 우리가 목표를 달성하려면, 실제 ViewModel 인스턴스를 생성할 때 자유롭게 데이터를 기술하여 건네줄 수 있어야 한다. 따라서 자유로운 데이터 형식을 옵저버 패턴에서 사용할 수 있는 getter/setter로 변경해주는 파서가 있어야 한다.

비정형적으로 깊이가 다른 데이터를 순회하거나 파싱하는 작업은 비정형 그래프를 동적으로 탐색하는 작업으로 매우 자주 하게될 작업이다. 앞서 Visitor에서는 이를 스택을 사용하여 했지만, 여기서는 재귀를 이용하여 구현해보려고 한다.

constructor(data, _=type(data,'object')){
	super();
	this[ViewModel.KEY] = 'root';
	Object.entries(data).forEach(([k,v])=>this.define(this,k,v));
	Object.seal(this);
}

다음과 같이 제어를 define이라는 메서드로 분리하고 define에서 옵저버 패턴을 위해 필요한 작업을 한다고 해보자. 제어문을 실제로 구현하기전에 구현한 코드를 어떻게 사용할지 인터페이스를 명시하는 것은 코드를 효율적으로 구현하기 위한 지름길이다. 마치 출발점과 도착점을 제시하고 100m 달리기를 하라고 하면 가장 빠른길을 쉽게 찾을 수 있는 것과 같다. 그럼 define 메서드를 구현해보자.

우리가 재귀적으로 탐색할 객체는 깊이가 정해지지 않은 비정형 데이터이다. 따라서 우리는 동적으로 이를 분기해야 하는데, 그 기준은 무엇일까? 바로 값인 v의 타입이 배열/객체인지 원시값인지 이다. 만약 배열/객체라면 이를 다시 재귀적으로 탐색하여 내부의 모든 깊이를 탐색하도록 해야한다. 이것이 비정형 그래프 탐색의 기본이다.

재귀함수에는 진입점 함수가 있고 루프함수가 있다. 루프함수는 꼬리물기 최적화가 되어있으므로 초기에 이를 사용할 수 없어서 진입점 함수를 별도로 만들어야 한다. 꼬리물기 최적화는 코드스피츠 89강에서 설명하는 개념이지만, 여기서는 자세히 설명하기엔 글이 길어지므로 아주 설명이 잘되어 있는 블로그 출처로 대신한다. 즉 꼬리물기 최적화는 스택 생성을 방지하여 오버플로우를 일으키지 않기 위함이다.

꼬리물기 최적화 - 조찬기 | 우아한테크캠프3기 스터디 블로그

재귀함수에서 서브루틴 체인이 일어날 때 각 서브루틴의 반환점은 서브루틴을 호출한 함수가 되는데, 호출한 함수에서 실행 컨텍스트를 가지지 않도록 메모리를 잡아놓지 않는 지연 연산자 (각 언어마다 다름) 를 사용하여 호출 함수를 호출하면, 호출이 함수는 호출을 함수로 돌아올 필요가 없이 마지막에 호출 함수가 최초로 호출을 지점으로 값을 반환한다.

따라서 꼬리물기 최적화가 이루어진 루프는 모양이 다른 진입점 함수(여기서는 data 객체를 받는 define메서드) 안에 꼬리물기 최적화 형태로 이루어진 루프 함수가 그 내부에 있는 형태가 된다.

class ViewModel{
    static KEY= Symbol();
    define(target, k, v){
        if(v && typeof v=='object' && !v instanceof ViewModel){ //ViewModel은 define으로 옵저버 세팅이 되어있기 때문에 스킵
            if (v instanceof Array){ //
                target[k] =[];
                target[k][ViewModel.KEY] = target[ViewModel.KEY]+ "."+ k;
                v.forEach((v,i)=> this.define(target[k], i, v)) //배열의 키는 인덱스
                 //v.map((v,i)=>this.define(target[k], i, v))
            }else{
                target[k] = {[ViewModel.KEY]:target[ViewModel.KEY]+ "."+k}; //새롭게 객체 생성해주면서
                Object.entries(v).forEach(([ik,iv])=>this.define(target[k], ik, iv)); //객체에 기존의 키와 값을 맵핑해
            }
        }else{ //원시값 또는 ViewModel
            if(v instanceof ViewModel){v._setParent(this, k);}
            Object.defineProperties(target, { //properties로 정의한 배열의 원소는 length로 잡히지 않으므로 따로 처리해야함
                [k]:{
                    enumerable:true,
                    get:_=>v,
                    set:newV=>{
                        v=newV;
                        this.add(new ViewModelValue(target.subKey, target[ViewModel.KEY], k, v));
                    }
                }
            })
        }
    }
}

v가 배열/ 객체 / 뷰모델인지에 따라 분기하는 제어는 구체적으로 설명하기 보단 직접 코드를 살펴보는 것을 추천한다. 주의해야 할 점은 ViewModel의 인스턴스는 처음에 생성될 때부터 현재의 define 메서드로 옵저버 파싱이 되어있으므로 배열이나 객체처럼 재귀적으로 옵저버 파싱을 할 필요가 없다는 것, 그리고 자식으로 들어온 뷰모델 인스턴스는 setParent로 부모를 현재 객체로 설정해주고 subkey를 부여하면 된다는 것이다.

ViewModel.KEY는 static 싱글턴으로 설정한 고유한 Symbol로써, 객체가 속성으로 들어올 때마다 키 사이에 마침표 ‘.’을 찍어주는 역할을 한다. 따라서 모든 배열과 객체는 ViewModel.KEY라는 Symbol을 키값으로 가지는 지금까지의 depth만큼 viewmodel.style.display.width 같이 ‘.’으로 연결된 고유한 속성을 가지게 된다. 일종의 상대경로라고 할 수 있다. 이 부분은 지금 보면서도 정말 아이디어가 좋다고 느끼고, Symbol을 잘 사용한 좋은 예라는 생각이 든다.

이렇게 만든 만든 상대경로를 cat으로 하는 ViewModelValue를 옵저버 속성으로 set으로 넣어주면 끝이다. 기존의 ViewModel에서는 옵저버 파싱으로 전달하는 info객체가 같은 subKey와 카테고리를 가질 수가 있어서 인포객체가 중복될 수 있는 우려가 있었다. 하지만 지금은 상대경로를 전부 전달함으로써 고유한 info객체를 가질 수 있게 됐다. else문은 더 이상 재귀가 호출되지 않음으로써 재귀가 종결되는 지점이다. 종결 조건이 확정이라면 제어의 분기문에서 mandatory라는 것을 if/else로 확실하게 표현해줘야만 한다.


이제 우리의 ViewModel은 형식에 자유로운 데이터를 받아서 Observable한 객체로 만들어줄 수 있게 되었다. ViewModelListener 인터페이스를 구현한 객체라면 어떤 객체라도 뷰모델을 옵저빙 할 수 있게 된 것이다. 가상화된 뷰를 만드는 기능 외에도 Observable한 객체를 만들어주는 단위로써의 역할이 보다 커졌다고 할 수 있다.

그럼 이제 뷰모델을 소비하는 바인더의 변화를 보자.

viewModelUpdated(viewmodel, updated) {
    const items = {};
    this.#items.forEach(item=>{
        items[item.viewmodel] = [type(viewmodel[item.viewmodel], ViewModel), item.el]
    })
    updated.forEach(v=>{
        if(!items[v.subKey])return; //viewmodel에 존재하지 않는 하위 viewmodel일 경우 리턴
        const [vm, el] =items[v.subKey], processor = this.#processors[v.cat.split("").pop()]
        if(!el || !processor)return;
        processor.process(vm,el, v.k,v.v);
    })
}

프로세서의 cat 키로 불러오던 것을 앞서 Symbol을 사용해 만들었던 상대경로의 마지막 카테고리를 키로하여 가져오면 끝이다. 이제 도메인에 대한 특정 전략을 가지게 되는 것은 구상 프로세서밖에 존재하지 않게 되었다. 따라서 이제는 뷰모델을 작성할 때 내가 사용할 전략 프로세서가 사용하는 키를 참조하여 작성하면 되기 때문에 뷰모델에 전달하는 객체는 도메인으로부터 완전히 독립하게 되었다.


데코레이터 패턴 Decorator Pattern

현재 우리는 프로세서를 Set 컬렉션이라는 그릇에 담아서 바인더가 컬렉션을 관리할 책임을 지게 된다. 이럴 경우 프로세서를 알고 관리할 책임이 하나의 바인더에 집중되어 무거운 편이다. 반면, 데코레이터 패턴과 Chainable Responsibility 라는 패턴에서는 객체 하나하나가 자신 다음에 책임을 위임받을 객체를 알게 되는 형태를 가진다. 컬렉션을 사용하는 대신 데코레이터 패턴과 Chainable Responsibility 패턴을 사용하면 이러한 관리 책임을 작게 나누어 관리할 수 있다는 장점이 있다.

const Processor = class{
	cat; #next= null;
	next(process){
		this.#next = process;
		return process
	}
	process(vm,el,k,v,_0=type(vm,Viewmodel),_1=type(el,HTMLElement),_2=type(k,'string')){
		this._process(vm,el,k,v)
		if(this.#nest)this.#next.process(vm,el,k,v);
	}
}

우리는 프로세서를 런타임에 바인더에 공급하였다. 하지만 그 때 우리는 Processor의 set을 만들어 한번에 공급해야했다. 이제 링크드 리스트의 원리를 사용하는 데코레이터 패턴 을 차용하면, 바인더의 컬렉션에 상관하지 않고 언제든지 프로세서들의 연결리스트를 추가/삭제 한 후에 바인더에 공급할 수 있다. binder의 addProcessor 메소드는 프로세서의 연결리스트를 만드는데 전혀 관여하지 않고 필요한 런타임에 한번만 사용하면 프로세서가 공급되는 것이다. 보다 동적으로 바인딩하는 방향으로 변화했다고 할 수 있다.

데코레이터 패턴Chainable Responsibiliy 패턴을 사용하는 이유가 여기에 있다. 객체를 바인딩하거나 아니면 소유해야하는 소유자 객체와 격리시켜너 연결리스트로 된 자료구조를 만들어서 공급할 수 있는 것이다. 또한, 컬렉션을 관리하고 컬렉션 객체를 사용해야 하는 소유자 객체의 책임이 훨씬 줄어드는 것은 당연한 결과이다. 이 경우에는 Processor 를 사용하는 Binder의 로직도 컬렉션을 사용하는 쪽에서 하나의 Processor를 사용하는 로직으로 변화한다. 사용코드는 아래와 같다.

const p1 = new (class extends Processor{
	_process(vm,el,k,v){el.style[k]=v;}
})("styles").next(new(clss extends Processor{
	_process(vm,el,k,v){el.setAttribute(k,v);}
})("attributes");

따라서 객체지향에서 컬렉션을 결정할 때 고려해야 할 건

  1. 해당 컬렉션을 사용하는 컨텍스트가 객체 컨텍스트라면 반드시 Set을 사용하여 중복을 허용하지 말 것
  2. 해당 컬렉션을 사용하는 소유자 객체가 과연 해당 클래스에 대한 책임을 가져야 하는지 고민할 것. 혹은, 해당 컬렉션의 구조를 소유가 객체가 결정하는 것이 옳은지를 고민할 것.

바인더가 프로세서의 컬렉션을 책임지는 것은 옳을까? 프로세서는 바인더에게 있어 책임을 위임할 대상이다. 뷰모델의 구체적인 처리를 위임하게 되기 때문이다. 이러한 바인더가 프로세서의 컬렉션을 소유하는게 옳은 일일까? 일반적은 답은 아니다 바인더의 목적은 프로세서에게 위임하는 것이므로 프로세서는 하나만 가지는 것이 더 적합하다. 왜일까? 컬렉션 소유자 객체가 컬렉션에 대해서 사용할 수 있는 정책이 한정적이기 때문이다.

컬렉션을 소유하고 그것에 대한 루프를 수행한다는 것은 컬렉션 객체들에 대한 일반화된 정책을 시행하는 것이다. 이렇게 일반화된 정책을 시행하는 것만으로도 이미 각각의 객체에 따른 고유한 컨텍스트를 생략하거나 무시하게 되기 때문에 일반화의 오류를 내포하게 된다. 데코레이터 패턴을 사용한다는 것은 이러한 컬렉션에 소속된 객체들 중 소유자 객체와 연결된 header 에 해당하는 객체를 제외하고는 각각의 객체들에게 루프로 시행할 자신의 행위에 대한 정책을 결정할 수 있게 하는 것이다. 따라서 훨씬 더 많은 복잡도를 다룰 수 있게 된다. 예를 들어, 데코레이터 패턴으로 전후로 연결된 동형(타입이 동일한) 객체들의 상태나 컨텍스트에 따라 루프를 진행할지 말지, 특수한 루프를 수행할지 결정할 수도 있다.

객체가 스스로의 컨텍스트에 따라 루프를 도는 행위에 대한 정책을 결정해야 하는 이유는, 우리는 시그니처가 통일된 객체의 어떠한 특정행위를 호출하기 때문에, 통일된 시그니처를 제외하고는 각 객체의 행위는 스스로만 알 수 있기 때문이다.통일된 시그니처를 사용하여 일반화된 루프를 수행한다는 것은 실제로 일어나는 행위를 알 수 없게 만들어버리는 것이다.

그러나 또 여기서 한가지 유의해야 할 점은 컬렉션 소속 객체들이 각자의 행위 컨텍스트를 가지는 Processor 같은 객체가 아닌 단순히 데이터만 가지는 BinderItem 같은 객체라면, 컬렉션의 일반화된 루프 정책이 유효할 가능성이 크다는 것이다. BinderItem은 내부에 단순이 el과 viewmodel 이라는 값 또는 데이터만 가지고 이를 사용하는 메소드나 오퍼레이션이 존재하지 않는다. 이러한 데이터 객체들에 대한 일반화된 루프 정책을 시행할 때 컬렉션은 유효한 구조일 수 있다. (거기에 BinderItem은 생성자에서 Object.freeze(this)로 불변객체로 만들어 값 컨텍스트를 보장했다)

따라서 우리는 행위가 있는 객체의 컬렉션에 대한 루프 제어문을 코드를 데코레이터 패턴 을 통해 객체로 합성할 수 있으며, 이를 통해 각 객체에 루프에서 수행할 행위에 스스로 책임을 지도록 할 수 있다.

최종적인 코드는 다음과 같다. Binder가 데코레이터 패턴으로 Processor의 헤더만을 소유하게 됐다.

const Binder = class{
	#processor = null;
	viewModelUpdated(viewmodel, updated, _0=type(viewmodel, ViewModel), _1=type(updated, Set)) {
    const items = {};
    this.#items.forEach(item=>{
        items[item.viewmodel] = [type(viewmodel[item.viewmodel], ViewModel), item.el]
    })
    updated.forEach(v=>{
        if(!items[v.subKey])return; //viewmodel에 존재하지 않는 하위 viewmodel일 경우 리턴
        const [vm, el] =items[v.subKey], processor = this.#processor //this.#processors[v.cat.split("").pop()]
        if(!el || !processor)return;
        processor.process(v.cat, vm,el, v.k,v.v);
    })
	}
	render(viewmodel, _-type(viewmodel, ViewModel){
		this.#items.forEach(item=>{
      const vm = type(viewmodel[item.viewmodel], ViewModel), el=item.el;
			Object.entries(vm).forEach(([cat, child])=>{
				Object.entries(child).forEach(([k,v])=>{
					this.#processor.process(cat,vm,el, k,v);
			})
	}
}
const Processor= classs{
	cat; #next= null
	process(cat, vm,el, 0=type(vm,Viewmodel),_1=type(el,HTMLElement)){
		if(this.cat==cat)this._process(vm,el,k,v)
		if(this.#nest)this.#next.process(cat,vm,el,k,v);
	}
	_process(vm,el,k,v){
		throw 'overried'; //vm[this.cat]
	}
	next (processor) {
    this.#next = processor
    return processor
  }
}

이전에 우리의 코드는, Binder에서 무거운 제어를 담당하여 Processor의 cat이라는 속성을 사용해 직접 뷰모델 혹은 updated ViewModelValue 셋을 프로세서와 맵핑시켜주었다. 하지만 이제 외부에서 cat을 전달하여 Processor가 직접 자신의 cat과 비교하여 루프 수행여부를 결정하도록 해야 한다.

그럼 이제 런타임에서 바인딩할 Processor 코드를 살펴보자

const visitor = new DomVisitor
const scanner = new DomScanner(visitor)
const binder = type(scanner.scan(document.body), Binder)

// 첫 번째 processor 주입
binder.processor = new class extends Processor {
_process (vm, el, k, v) { el.style[k] = v }
}('styles')

// 나머지 processor 주입
binder.processor 
.next(new class extends Processor {
  _process (vm, el, k, v) { el.setAttribute(k, v) }
}('attributes'))
.next(new class extends Processor {
  _process (vm, el, k, v) { el[k] = v }
}('properties'))
.next(new class extends Processor {
  _process (vm, el, k, v) { el[`on${k}`] = e => v.call(el, e, vm) }
}('events'))

구조를 독립적으로 구성한 Processor 리스트를 런타임에 바인딩하게 되었다.


템플릿 처리

우리는 Binder와 ViewModel로부터 제어 역전을 달성하여 런타임에 Processor를 원하는대로 바인딩할 수도록 하였다. 이를 통해 OCP가 달성되었으므로, 이제 마치 플러그인처럼 새로운 Processor를 추가하면 얼마든지 확장가능할 수 있게 되었다. 그럼 이제 새로운 종류의 도메인을 처리하는 프로세서를 만들어보자.

다음과 같은 새로운 html 훅을 가지는 html이 있다고 해보고, 이를 처리하기 위한 프로세서를 만들어보자.

<section id="target" data-viewmodel="wrapper">
  <h2 data-viewmodel="title"></h2>
  <section data-viewmodel="contents"></section>
  <ol data-viwmodel="list">
    <li data-template="listItem" data-viewmodel="item"></li>
  </ol>
</section>

프로세서를 만들기 전에, data-template 속성을 처리하기 위해 DomScanner의 메서드를 약간 변경하였다. 스태틱 수준으로 Map을 생성하고 이 맵에 템플릿을 저장한다. 스캐너가 data-template 속성을 스캔하더라도 일반 뷰모델처럼 각각의 BinderItem 을 만들어 렌더링하는 것이 아니라 스캐너의 스태틱 Map 에 data-template 훅에 바인딩한 HTML 엘리먼트를 저장한 뒤에 해당 엘리먼트의 data 속성에 포함된 viewmodel 들에 대해 재귀적으로 렌더링을 수행해줄 것이다.


export const DomScanner = class extends Scanner{
    static #templates = new Map;
    static get(k){return this.#templates.get(k)}
    constructor(visitor, _=type(visitor, DomVisitor)) {
        super(visitor);
    }
    scan(target ,_ = type(target, HTMLElement)) {
        const binder = new Binder, f = el => {
            const template = el.getAttribute('data-template');
            if(template) {
                el.removeAttribute('data-template');
                DomScanner.#templates.set(template, el);
                el.parentElement?.removeChild(el);
            }else {
                const vm = el.getAttribute('data-viewmodel');
                if (vm) {
                    el.removeAttribute('data-viewmodel')
                    binder.add(new BinderItem(el, vm))
                }
            }
        }
        f(target);
        this.visit(f, target); //부모가 제공하는 visit 메소드드
        return binder;
    }
}

프로세서의 코드는 다음과 같다.

const err = text=>throw text;
const scanner = new DomScanner();
const visitor = new DomVisitor()
new class extends Processor{
    _process(vm,el,k,v){
        const {name=err('no name'), data=err('no data')} = vm.template;
        const template = DomScanner.get(name) || err('no template'+name);
        if(!(data instanceof Array))err('invalid data'+data);
				data.forEach((vm,i)=>{
					if(!(vm instanceof ViewModel)) err('invalid ViewModel');
				});
        Object.freeze(data);
        visitor.visit(el=>{
            if(el.binder){
                const [binder, vm] = el.binder;
                binder.unwatch(vm);
                delete el.binder;
            }
        }, el);
        el.innerHTML='';
        data.forEach((vm,i)=>{
            
            const child = template.cloneNode(true);
            const binder = scanner.scan(child);
            child.binder = [binder, vm];
            binder.watch(vm)
            el.appendChild(child);
        })
    }
}

위 코드는 2가지의 부분으로 나뉜다. data, template 그리고 vm의 template 키의 값을 err() 함수로 검증한 코드와 이를 사용하는 visitor의 visit 로직이 있는 코드이다. 정합성을 검증하는 코드는 Black List , 로직을 전개하는 코드는 White List 라고 한다. 코드의 가독성을 위해서, 이 둘은 철저히 분리할 필요가 있다. 따라서 로직을 전개하기 전에 err() 를 사용하는 모든 검증을 완료한 것이다. 이를 통해 모든 변수를 안정화 한뒤 로직을 전개할 수 있다. 이를 쉴드 패턴 Shield Pattern 이라고 하며, Black List 에서 사용할 데이터의 명세를 모두 기술한 뒤 White List 는 이를 사용하기만 하게 된다. Black List의 검증을 통과하지 못하면 White List의 로직은 전개되지 않는다. 반대로, White ListBlack List에서 검증되지 않는 변수가 등장한다면 바로 이를 찾아서 Black List 에 검증을 추가할 수 있어야 한다. 이를 위해 Black List에서 검증이 완료된 변수들을 하나의 객체로 포인터로 설정하는 것도 좋은 컨벤션이다. (const WL = {data,tempalte})

앞서 뷰모델 훅의 경우 스캐너가 스캔을 하게 되면 BinderItem 이 생성되어 각각의 바인더에 컬렉션이 생성되고 각각의 아이템에 맵핑된 프로세서가 업데이트 시 렌더링을 처리하였지만, template 훅의 경우에는 template에 대한 BinderItem을 따로 생성하지는 않는다. 단지 스캐너의 스태틱 Map 에 data-template 훅에 바인딩한 HTML 엘리먼트를 저장하였다가, 해당 템플릿의 name 속성으로 프로세서가 스태틱 Map 에서 엘리먼트를 받아온 후에 엘리먼트 내부를 Domvisitor의 인스턴스를 사용해서 전부 visit 하면서 data 배열의 ViewModel에 대해 재귀적으로 Binder 인스턴스를 생성하여 옵저빙 /렌더링 하고, 만들어진 인스턴스를 전부 삭제하는 것을 반복한다. 앞서 말했듯이 우리는 이렇게 정의한 구상 Processor의 키를 참고하여 뷰모델을 생성해주기만 하면된다. Black List 에 정의한 data와 name 속성을 가지도록 뷰모델을 작성하기만 하면, 이 프로세서가 자동으로 해당부분에 대한 렌더링과 옵저빙을 수행해줄 것이다.

💡 위 예시에서 Processor의 상속 메서드에 연관된 visitor와 scanner의 인스턴스를 생성한 것은 static 의사결정에 대해 유의할 점을 내포하고 있다. 어떠한 객체가 싱글톤이라고 판단했다면 이를 위해 변수나 메소드를 스태틱으로 만드는 것은 나쁜 의사결정이다. 스태틱으로 만들면 여러 개의 인스턴스가 필요한 경우에 대응할 수 없게 확장이 불가능해지기 때문이다. 일반적인 클래스 메소드/변수로 설정하고 그 때 그 때 인스턴스를 만들어 사용하면 싱글턴이던, 여러 개의 인스턴스던 상관없이 대응할 수 있기 때문에 확장에 용이하여 OCP에 부합한다. Processor가 사용할 Domvisitor의 경우에도 메소드 visit을 위해 인스턴스가 1개만 있으면 되므로 static으로 만들고 싶을 수가 있다. 하지만 이러한 경우에도 클래스의 인스턴스로 새로 만들어 각자 다른 인스턴스를 소유하는 것이 보다 좋은 의사결정이다. 새로운 인스턴스를 생성한다고 오버헤드가 발생하지도 않는다. static을 지양하는 이유는 static은 단순한 함수와 상태의 집합으로 상속이 불가능하여 객체지향의 컨텍스트를 공유할 수 없기 때문이다.

모던 프레임워크들은 제어역전을 통해 코어는 일반적인 제어를 제외하고는 매우 가볍게 유지한다. 여기서 일반적인 제어란 디자인 패턴상에서의 객체구조를 제어하는 것을 말한다. 즉, 스스로가 가지고 있던 코드로 된 제어문을 객체로 합성하여 이러한 객체에 대한 일반화된 제어만을 수행하고 남은 제어는 합성한 객체에 위임한다는 것이다. 이는 코어에 구체적인 제어를 작성하여 성급한 일반화의 오류를 일으키는 것을 막고 보다 확장된 객체 쪽으로 제어를 밀어내기 위함이다.

앞서 Binder에서 도메인 전략을 Processor로 위임하고, Processor의 자료구조에 대한 책임을 데코레이터 패턴을 통해 각각의 Processor에 위임하고 나서 Binder는 오직 Processor의 헤더만을 가지고 render 시에 헤더 프로세서만 호출하는 코드만 남게 되었다. 나머지 도메인 제어는 모두 확장된 Processor 객체에게 위임되게 되었다. 이제 Processor는 마치 플러그인 처럼, 원하는 행위를 커스텀하여 언제든지 코어와 조합하여 제어를 수행하게 되었다. 제어를 더 많이 위임하면 위임할 수록, 우리는 우리가 원하는 대로 프레임워크를 재구성할 수 있게 된다.

심지어 플러그인이 플러그인이 참조하도록 의존성에 가지를 뻗쳐 확장할 수도 있다. 우리는 제어역전을 통해 OCP를 달성했기 때문에 확장에 제한이 없기 때문이다.

프로그램 코드를 볼 때 전체적인 코드의 구조를 Macro한 범위, 각각의 제어코드나 함수의 제어문 등을 Micro한 범위로 분류한다면, 코드를 학습할 때는 Macro 한 코드에 대한 고민이 우선시되어야 한다. 프로그램이 어떠한 구조로 이루어져야 하는지가 프로그램의 뼈대이자 중추이기 때문이다. 실제 결과물의 퀄리티에도 좁은 범위의 제어문 코드가 끼치는 영향보다 구조와 설계 더 큰 영향을 끼치게 된다. 이는 개발자 본인이 주니어이든 시니어이든 상관없다. 개발자의 보다 일반적인 역량은 바로 Macro 한 수준에서의 객체통신을 성립시키고 책임에 따른 역할 부여에 달려있는 것이다. 이를 통해 수정 여파를 격리시키고 확장이 가능하도록 OCP를 달성시키는 것이 객체지향에서의 우선순위가 된다. 우리가 앞서서 template 기능을 추가하는데 때 코어 객체에 단 한줄의 코드를 수정하지 않고 추가할 수 있었던 것이 바로 OCP가 달성되었기 때문에 가능했다. 앞서 만든 코드를 수정하기 않고도 안심하고 기능을 추가할 수 있게 된 것이다.

개발자는 항상 Micro 한 코드를 함수로 정리하여 위임하고 보다 Macro한 구조를 고민할 수 있어야 한다. Macro한 구조를 고민하여 구조를 확정한다면 미래에 위임한 Micro 코드를 해결하는 것은 어려운 일이 아니다. 이를 테스트 할 때도 기본적인 Mock 데이터만 리턴하고 있다면 아무 문제가 없다. 그 뒤 코드를 유지보수할 때는Macro 한 코드만 해석하여 구조만 파악하면 디버깅이 매우 용이해진다.


지금까지 코드 스피츠 86강을 듣고 강의와 그에 대한 제 생각을 정리해보았습니다. 정리하면서 저 스스로도 매우 즐거운 시간이었고 많은 것들을 배울 수 있었습니다. 코드에 대한 이만한 고찰을 담은 강의를 무료로 볼 수 있던 것은 큰 즐거움이었습니다. 다시 한번 강의해주신 bsidesoft의 맹기완 대표님께 감사드립니다.

profile
inudevlog.com으로 이전해용

0개의 댓글