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

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

86-2

들어가기 앞서...

이 글은 코드 스피츠 유튜브에 공개된 무료강의인 86-객체지향 자바스크립트를 회차별로 정리한 글입니다. 이 강의는 객체지향을 해보았거나, 해보지 않았더라도 자바스크립트 개발에 익숙한 사람들을 대상으로 합니다. 객체지향의 기본적인 개념에 관한 간단한 소개는 1회차에서 다루지만, 강의 음질이 1회차만 좋지 않아 정리를 못한 관계로 실제 코드를 살펴보는 2회차부터 강의를 정리하여 글을 쓰게 되었습니다. 차후 1회차를 정리하게 되면 업로드하도록 하겠습니다.

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

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

이번 강의에서 만들어볼 모델은 MVVM이라는 아키텍쳐의 핵심적인 부분을 차용한 간단한 모델로, 이를 어떻게 객체지향으로 만들 수 있는지 코드를 살펴볼 것이다. MVVM은 Model -View -ViewModel의 약자이며, 다양한 변형이 존재한다.

그 이전에 , 우선 고전적 MVC모델을 알아보자. MVC 모델이란 Model -View -Controlle의 약자로. 모델을 컨트롤러가 가져와서 뷰가 소비할 수 있는 데이터로 공급한다. 위 그림의 화살표는 Controller와 Model 사이의 방향이 반대로 되어있는데, 화살표는 이 객체가 누구를 알고 있는지 의존성의 방향을 표시해준다. 따라서 컨트롤러는 Model도 알게 되고, 뷰도 알게 된다. 뷰는 유저에 대한 인터랙션을 받아들이고, 이 인터랙션에 따라 다시 모델을 갱신하므로 모델에 대한 의존성을 가진다.

이 모델의 문제점이 바로 뷰가 모델에 대한 의존성을 가지는 데서 발생한다. 모델은 비지니스 도메인에 관련된 객체로, 도메인에 따라 수정/변경이 일어나는데, 뷰는 화면에 표시되는 내용이나 그에 따른 유저의 인터랙션에 의해 변경이 발생한다. 모델의 변화는 컨트롤러를 통해 뷰에 반영되기 때문에 간접적으로 (순환적으로) 뷰에 변화가 발생하며, 뷰는 변경내용에 따라 모델을 갱신하므로 결국 모델과 뷰는 양방향 의존성을 가지게 된다. 이에 따라 서로 변화율이 다른 두 객체가 서로에게 의존성을 가지는 양방향 의존성이 발생하는 것이 MVC의 단점이다. 서로 간의 양방향 의존성을 가지는 두 객체가 변화율이 다르다면 결국에는 변화를 추적할 수 없게 된다.

하지만, 스프링 MVC 등의 백엔드 프레임워크에서는 여전히 사용하는 아키텍쳐이기도 한데 이는 서버에서는 뷰가 모델로 다시 인터랙션을 일으키지 않기 때문에 단방향 의존성으로 이를 처리할 수 있 기 때문이다. 클라이언트에서는 인터랙션이 일어나게 되어 양방향 의존성이 발생하는 데다가, MVC 아키텍쳐의 뷰와 모델은 서로 변화율의 이유도 매우 상반되기 때문에(비지니스 도메인, 사용자 화면 UI) 뷰와 모델이 가지는 밀접한 의존성에 따라 걷잡을 수 없는 변화가 발생하고 유지보수가 어려워진다.

또다른 MVC 모델로는 View와 Model이 서로를 모르게 대신 컨트롤러가 뷰와 모델을 모두 다 알게되는 제왕적 컨트롤러 모델이 있다. 의존성 화살표를 보면 뷰와 모델 사이의 의존성이 끊어진 것을 볼 수 있다. 하지만 이 대신에, 뷰가 컨트롤러를 알게 되었다. 이 모델의 문제점은 모델과 뷰의 변화에 따라 항상 컨트롤러의 코드가 변화함에 따라 컨트롤러가 모델과 뷰의 변화율을 모두 흡수해야 한다는 점이다. 비싼 대가를 치른다고 할 수 있다. 컨트롤러의 유지보수가 매우 힘들어진다.

자체적으로 이 의존성을 해결할 방법은 없기 때문에 Service와 Dispatcher등으로 제어역전을 이루어 사용하는 경우가 대부분이다.

MVC 아키텍쳐 다음으로 출현한 아키텍쳐는 4세대 언어인 비쥬얼 베이직, 파워빌더 등과 함께 출현한 MVP 아키텍쳐이다. MVP 아키텍쳐는 뷰의 모든 프로퍼티를 Getter와 Setter로 노출한다. 이때 이 프로퍼티는 뷰의 인터페이스가 아닌, 뷰에 해당하는 프로그래밍의 인터페이스를 모두 노출한 것이다. 예를 들어 DOM 등의 경우 style 객체나 네이티브 DOM 객체를 노출하는 것이 아니라, 모든 속성을 커스텀 gettersetter로 가지는 것이라고 볼 수 있다. MVP 아키텍쳐에서 컨트롤러를 Presenter 라고 부르는데, Presenter는 View를 오직 getter와 setter로만 이루어진 인터페이스로 보게 되기 때문에, View의 getter/setter만 호출하고 Model과의 연결을 끊을 수 있게 된다. 앞서 제왕적 컨트롤러 모델에서는 직접적인 연관성만을 끊을 수 있었고 여전히 비대해진 Controller를 통해서 Model과 View가 데이터를 주고 받았지만, MVP모델은 단지 Presenter가 Model의 변화를 해석해서 getter/setter를 호출하기만 할 뿐이다. 따라서 MVP 아키텍쳐는 뷰에서 모델을 그릴 수 있는 로직을 제거한 대신 Model에 대한 의존성을 없애는데 성공했다. 하지만 MVP 아키텍쳐의 단점은 속성을 Getter와 Setter로 전부 맵핑해줘야한다는 것으로 매우 수고로운 일이 아닐 수 없다.


그럼 이제 MVVM 아키텍쳐에 대해 알아보자.

MVVM은 Model- View- ViewModel이지만, 실제로 사용하기 위해서는 뷰모델과 뷰 사이에 Binder 라는 역할이 하나 더 필요하다.

ViewModel이란 인메모리 객체로 이루어진 순수한 데이터로써의 뷰이다. 즉, 네이티브한 뷰가 아니라 뷰를 대신하는 인메모리 객체가 바로 뷰모델이다. 우리가 뷰를 조작할 때는, 네이티브 환경에 따라 영향을 받게 되어있다. 이 뷰가 어떤 네이티브 환경인지에 따라 사용하는 인터페이스가 달라지기 때문이다예를 들어, NodeJS 환경에서는 DOM을 조작할 수 없고, Canvas가 없는 브라우저에서 Canvas를 조작할 수 없는 것처럼 뷰는 네이티브환경에 제한을 받는다. . 하지만 뷰모델은 순수한 데이터 객체이기 때문에, 네이티브 환경에 상관없이 조작이 가능하다. 예를 들어, DOM을 흉내낸 styles,attributes,events 등의 속성을 가진 데이터 객체를 만든다면, 이 객체는 NodeJS에서도 조작이 가능할 것이다.

우리는 뷰모델을 조작했을 때 그에 맞게 뷰를 렌더링 하기를 원한다. 이 때, 실제 뷰로 변환해주는 것이 바로 Binder이다. 혹은 뷰의 변화가 생기면 자동으로 뷰모델을 갱신해주는 역할을 한다.즉 뷰와 뷰모델의 변화율을 중개해줄 바인더가 없으면 네이티브 뷰와 인메모리 데이터인 뷰모델이 간에 자동으로 인터랙션이 일어나지 않는다.

따라서 MVVM에서 뷰모델은 아예 뷰를 모르게 되며, 이 때 서로 모르는 뷰모델과 뷰를 연결해주고 변화를 중개해주기 위한 것이 바인더이다. 바인더는 양방향 / 단방향으로 observe 하거나, 또는 ViewModel이 수동으로 바인더를 호출할 수 있다.

본격적으로 MVVM 아키텍쳐를 알아보기 전에, type 검증을 위한 헬퍼 함수를 하나 정의해보자.

export const type = (target, type) => {//
//typeof 값은 항상 문자열이며, 문자열이 아닌경우 instanceof로 검사해주면 됨
    if (typeof type == "string") { //문자열끼리 비교하므로 강제현변환이 일어나지 않으므로 ==도 사용가능하며, ===보다 ==이 빨리 작동함
        if (typeof target != type) throw`invaild type${target}: ${type}`;
    } else if (!(target instanceof type)) throw`invaild type${target}: ${type}`;
    return target;
};
}

자바스크립트는 런타임 언어가 아니므로 throw하지 않으면 오류가 계속해서 전파된다. 오류의 전파를 막고 가장 빨리 알아챌 수 있는 방법은 throw 뿐이다. 타입 헬퍼 함수를 정의함으로써 프로그램의 컴파일 단계에서 타입을 체크하고 에러를 throw할 수 있다.

MVVM에서는 뷰모델과 뷰를 중개하는 바인더가 필요하다는 것은 앞서 알아본 바 있다. 그런데 우리는, 네이티브 뷰로부터 바인더에 맵핑될 dataset 훅을 가지는 엘리먼트를 스캔하여 훅에 해당하는 BinderItem을 가진 Binder를 생성하는 Scanner 클래스를 분리할 것이다. 바인더와 스캐너를 분리하는 이유는 바로 네이티브 뷰에 대한 스캐너의 변화율이 바인더와 다르기 때문이다. 객체를 분리하는 첫번째 이유는 항상 변화율이다. 바인더는 뷰모델을 이용해서 뷰를 그리는 로직이 바뀔 때 변화하고, 스캐너는 네이티브 뷰를 해석하는 로직이 바뀔 때 변화하므로 서로 변화율이 다르며, 따라서 객체마다 변화의 원인이 1가지여야 하는 SRP 원칙을 지키려면 이 둘을 분리해야 한다.

스캐너를 둠으로써, 스캐너는 네이티브 뷰의 변화를 흡수하고 바인더와 네이티브 뷰가 서로를 모르도록 연결을 끊는 역할을 한다.

export const ViewModel = class {
    static #private = Symbol()

    static get(data) { ///외부에서 인스턴스를 생성하는 것을 막고 오직 static get으로만 인스턴스를 만들 수 있게 한다.
        return new ViewModel(this.#private, data); //static 내부에서 this를 사용하면 클래스를 의미
    }

    styles = {};
    attributes = {};
    properties = {};
    events = {}

    constructor(checker, data) {
        if (checker != ViewModel.#private) throw  'use Viewmodel.get()'; //이 클래스는 어떠한 경우에도 static.get으로만 생성 가능
        Object.entries(data).forEach(([k, v]) => {

            switch (k) {
                case "styles":
                    this.styles = v;
                    break;
                case "attributes":
                    this.attributes = v;
                    break;
                case "properties":
                    this.properties = v;
                    break;
                case "events":
                    this.events = v;
                    break;
                default:
                    this[k] = v; //custom key
            }
        });
        Object.seal(this); //더 이상 필드로 키를 추가하지 못하게. 값은 바꿀 수 있음
    }
}

private 속성은 대괄호로 접근할 수 없고 외부에서 속성으로도 접근할 수 없다. 심지어 내부에서도 오직 점(.)으로만 접근할 수 있다. 따라서 this.[”#a”] 등으로 접근하면 private이 아닌 일반 속성으로 접근된다. static private으로 정의한 symbol은 클래스 내부에서만 접근가능하며, 따라서 static get()으로 클래스를 생성하지 않으는 다른 인스턴스 생성은 모두 에러를 throw하게 된다.

따라서 ViewModel은 static으로 ViewModel.get()을 통해서만 생성할 수 있으며, 생성자에 전달한 객체의 키값 외의 새로운 키는 생성하지 않도록 Object.seal로 객체를 봉인한다.

이제 ViewModel은 하나의 돔 객체를 완전하게 표현할 수 있게 되었다.

export const BinderItem = class {
    el;
    viewmodel;

    constructor(el, viewmodel, _0 = type(el, HTMLElement), _1 = type(viewmodel, "string")) { //viewmodel은 hook되어있는 data
        this.el = el;
        this.viewmodel = viewmodel; //data-viewmodel 훅
        Object.freeze(this); //불변객체
    }
}
}
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가지를 전달
        });
    }
}

Binder 클래스는 BinderItem의 Set을 가진다. BinderItem은 각각의 data hook에 해당되는 이름인 viewmodel과 해당 HTMLelement를 가진다. Binder가 배열이 아니라 Set을 가지는 이유는 중복된 값을 허용하지 않고 메모리 주소로 식별하는 Identifier Context를 사용하는 객체지향에서 객체의 컨테이너는 언제나 Set이어야 하기 때문이다. Value Context에서는 값으로 식별되기 때문에 중복을 허용하는 배열을 컨테이너로 사용해도 무방하다.

Binder 클래스는 viewmodel을 입력받아 그에 따라 자신이 가진 BinderItem을 렌더링한다. HTML Element를 렌더링하는 로직은 Binder에게 위임되어 있으며 이를 변경함으로써 각각의 네이티브 환경에 따라 맞는 방식으로 렌더링할 수 있다. 리액트 네이티브 등의 크로스 플랫폼도 동일한 원리로 이루어져 있다. viewmodel의 속성을 Binder가 플랫폼에 맞게 변형해서 View를 렌더링하면 안드로이드 /ios 플랫폼에서 동일한 viewmodel로 렌더링이 가능해지는 것이다.

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

Scanner 클래스는 element DOM을 순회하며 “data-viewmodel”훅을 가진 element들로 BinderItem을 생성하고 해당 BinderItem들을 가지는 새로운 Binder 인스턴스를 생성한다.

그럼 이제 MVVM의 재료들은 다 만들어졌다. 사용해보도록 하자.

다음과 같은 html에 대한 ViewModel을 만든다.

<section id="target" data-viewmodel="wrapper">
	<h2 data-viewmodel="title"></h2>
	<section data-viewmodel="contents"></seciton>
</section>
const viewmodel = ViewModel.get({
    wrapper: ViewModel.get({  //첫번째 depth에서는 viewmodel의 key값들이 viewmodel이 아니면 typeError(new Binder().render(viewmodel))
        styles: {width: "50%", background: "#ffa", cursor: "pointer"},
    }),
    title: ViewModel.get({
        properties: {innerHTML: "Title"}
    }),
    contents: ViewModel.get({
        properties: {
            innerHTML: "Contents"
        }
    }),

이렇게 만들어진 ViewModel은 네이티브 View(HTML)에 관한 지식이 전혀없으며, View에 대한 의존성이나 참조가 없는 순수한 인메모리 객체이다. MVVM에서는 이렇게 ViewModel과 View가 서로 의존성이 없어야 한다. 그리고 당연하지만, 네이티브 View의 부모자식 구조 같은 구조가 viewmodel에서는 나타나지 않고 BinderItem에 맵핑되는 각각의 element는 모두 첫번째 depth에만 존재하게 된다.

다음과 같이 viewmodel에 다른 속성을 넣어 개조해볼 수도 있다.

  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.isStop = true;
            }
        }
    }),
	})

viewmodel 데이터를 조작하여 변경한 다음 직접 수동으로 binder의 render를 호출할 수 있다. 모델을 바꿔서 렌더링 하는 이러한 형태를 Model Renderer라고 한다.

앞서 Binder는 이벤트 콜백의 파라미터로 viewmodel을 전달해주도록 했으므로, 이벤트 콜백으로 viewmodel의 속성을 조작할 수 있다. 이를 통해서 다음과 같은 일들을 할 수 있다.

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

viewmodel의 속성을 변경하는 것 만으로 애니메이션 렌더링을 조작할 수 있게 되었다.

크롬 개발자 도구의 프로파일 탭을 살펴보면 브라우저는 자원의 97% 가까이를 렌더링하는데 소모한다는 것을 알 수 있다. Binder가 인 메모리 데이터를 루프하는 것은 브라우저에 거의 부하를 주지 않는 행위로 속도의 저하가 거의 없으며, View에 관한 모든 제어가 ViewModel로 역전되어 ViewModel에 대한 갱신만으로 View를 제어할 수 있다.

의존성의 문제란 변화율과 수정 주기/ 생명 주기가 서로 다른 Model과 View과 서로에게 의존성을 가짐으로써 생기는 문제로 ViewModel이 View를 모르게 함으로써 의존성의 문제를 해결할 수 있었다. 거기에 Binder를 통해서 제어역전을 달성할 수 있었다.

profile
inudevlog.com으로 이전해용

0개의 댓글