뉴비를 위한 MV* 패턴 #3/3

MVVM패턴top

MVP패턴을 살짝 건너뛰고 이번 글에서는 MVVM패턴을 살펴봅니다. 이번 글에서는 구체적인 MVVM의 구현을 위해 가장 모범적인 예제를 만들어보면서 개념을 익히게 되는데 이규원님의 글 중에 추천 링크에 등장하는 msdn 매거진에 기고된 Patterns – WPF Apps With The Model-View-ViewModel Design Pattern 라는 글에 등장하는 샘플입니다. 이를 ES6로 구현해보면서 핵심 개념을 차근차근 익혀가도록 하죠.

애플리케이션 개요top

고객의 정보를 관리하는 앱을 생각해보죠. 이 앱은

  1. 주소록처럼 고객이 전체 리스트가 나오며
  2. 해당 리스트에서 특정 고객을 누르면 상세 정보를 볼 수 있고
  3. 삭제나 수정도 가능해야 합니다.
  4. 또한 고객의 추가가 당연히 가능해야 합니다.

이 정도의 요구사항을 기본으로 하나씩 구현해가면서 MVVM을 이해해보죠.

모델제작top

우선 실제 고객의 정보를 나타내는 Customer클래스를 작성해보겠습니다.

코드 2-1

const Customer = class{
  //고객리스트에 추가됨
  static add(customer){Customer.list.add(customer);}
  
  get firstName(){return this._firstName;}
  set firstName(v){this._firstName = v;}

  get lastName(){return this._lastName;}
  set lastName(v){this._lastName = v;}

  get email(){return this._email;}
  set email(v){this._email = v;}

  get totalSales(){return this._totalSales;}
  set totalSales(v){this._totalSales = v;}

  get totalSales(){return this._totalSales;}
  set totalSales(v){this._totalSales = v;}

  get isCompany(){return this._isCompany;}
  set isCompany(v){this._isCompany = v;}
};
//모든 인스턴스는 여기에
Customer.list = new Set();

간단히 고객의 정보를 저장하는 모델입니다.

뷰모델top

뷰모델은 실제 데이터 저장소인 모델을 관리한다는 점에서 MVC의 컨트롤러가 수행하던 역할을 대신합니다. 하지만 뷰를 직접 알지 않고 일반적으로 뷰모델의 변화를 알려줄 옵져버패턴 형태로 구현하게 됩니다. 이는 모든 뷰모델의 공통적인 사항이므로 추상 뷰모델 클래스에서 처리하면 됩니다.

코드 2-2

const ViewModel = class{
  constructor(){
    this.listeners = new Set();
  }
  addListener(target){
    this.listeners.add(target);
  }
  removeListener(target){
    this.listeners.delete(target);
  }
  notify(field, data){
    this.listeners.forEach(v=>v.update(field, data));
  }
};

간단한 옵져버 구현체니 현재로서 큰 특징은 없습니다. 하지만 실제 데이터 저장소를 제어하면서 이벤트를 발생시키는 구체적인 뷰모델을 작성해보면서 구체적인 면을 이해할 수 있습니다. 위를 기반으로 해 CustomerViewModel을 만들어보죠. 실제 모델이 어떤가보단 뷰에서 보여줄 속성이 무엇이고 어떻게 반응해야 하는 지를 기준으로 뷰의 상태를 대신할 수 있는 형태가 되어야 합니다. 실제 고객을 추가하는 인터페이스를 구성해보죠.

코드 2-3

<style>.err{color:red}</style>

고객 종류: 
<select></select>
<div class="err">고객 종류를 반드시 선택해주세요</div>

성: 
<input type="text">
<div class="err">성을 반드시 적어주세요</div>

이름: 
<input type="text">
<div class="err">이름을 반드시 적어주세요</div>

이메일: 
<input type="text">
<div class="err">이메일을 반드시 적어주세요</div>

<button>저장하기</button>

위의 태그는 다음과 같은 뷰가 될 것입니다.

위의 뷰에 맞게 상태를 구성해보죠.

코드 2-3

const CustomerViewModel = class extends ViewModel{
  constructor(){
    super();
    //실제 데이터 저장소 모델
    this.model = new Customer();
  }

  //셀렉트용 옵션리스트
  get typeOption(){ 
    return ["선택안함", "개인", "회사"];
  }

  get type(){return this._type || "";}
  set type(v){this.notify('type', this._type = v);}

  get firstName(){return this._firstName || "";}
  set firstName(v){this.notify('firstName', this._firstName = v);}

  get lastName(){return this._lastName || "";}
  set lastName(v){this.notify('lastName', this._lastName = v);}

  get email(){return this._email || "";}
  set email(v){this.notify('email', this._email = v);}
};

상당 부분 코드가 반복되는 느낌입니다. 이는 프록시등을 사용하면 개선할 수 있지만 지금은 그냥 무식하게 구현하죠 ^^;

하지만 값에 대한 검증도 뷰모델이 책임지고 에러에 대한 출력도 담당해야 합니다. 코드를 정리하면서 각 필드별 밸리데이션 및 에러메세지를 처리하는 validate 메소드도 구현하겠습니다.

근데 버튼에 걸린 액션은 어떻게 해야 할까요? 기존 MVC에서는 당연히 컨트롤러의 메소드에 걸었습니다. 이는 뷰모델에서도 큰 차이는 없습니다. 따라서 뷰모델에 save메소드를 추가하여 save버튼의 액션도 담당합니다.

코드 2-4

const ViewModel = class{
  constructor(){this.listeners = new Set();}
  addListener(target){this.listeners.add(target);}
  removeListener(target){this.listeners.delete(target);}
  notify(field, data){this.listeners.forEach(v=>v.update(field, data));}
};

const CustomerViewModel = class extends ViewModel{
  constructor(){
    super();
    this.model = new Customer();
  }

  get typeOption(){return ["선택안함", "개인", "회사"];}
  get type(){return this._type || "";}
  set type(v){this.notify('type', this._type = v);}
  get firstName(){return this._firstName || "";}
  set firstName(v){this.notify('firstName', this._firstName = v);}
  get lastName(){return this._lastName || "";}
  set lastName(v){this.notify('lastName', this._lastName = v);}
  get email(){return this._email || "";}
  set email(v){this.notify('email', this._email = v);}

  //밸리데이터
  validate(field, v = ''){
    switch(field){
    case'type':return !["개인", "회사"].includes(v) ? '고객 종류를 반드시 선택해주세요' : '';
    case'firstName':return !v.trim() ? '이름을 반드시 적어주세요' : '';
    case'lastName':return !v.trim() ? '성을 반드시 적어주세요' : '';
    case'email':return !v.trim() ? '이메일을 반드시 적어주세요' : '';
    }
  }

  //save메소드
  save(){
    const fields = 'type,firstName,lastName,email'.split(',');

    //밸리데이션이 통과 되는가?
    if(fields.some(v=>this.validate(v, this['_' + v]))) return;

    //일괄로 모델을 갱신해준다.
    'firstName,lastName,email'.split(',')
        .forEach(v=>this.model[v] = this.proxy[v]);

    //모델 측의 isCompany처리
    this.model.isCompany = this.proxy.type == '회사';
    Customer.add(this.model);
  }
};

만들어진 뷰모델을 MVC의 컨트롤러와 비교해보죠.

  1. 컨트롤러와 달리 뷰모델의 경우 아예 뷰를 모르고 있습니다.
  2. 뷰모델은 실제 뷰는 아니지만 가상화된 뷰이므로 화면없이 로직을 테스트할 수도 있고 다양한 뷰에서 사용할 수 있습니다.

기존 컨트롤러가 뷰와 단단하게 연결되어있던 것과 달리 뷰모델은 뷰와 완전히 단절되어있습니다.
또한 이러한 점 때문에 다양한 뷰에서 뷰모델을 소비할 수 있습니다. 문제는 뷰모델과 뷰의 연결입니다.

뷰가 직접 뷰모델을 소유하는 경우top

WPFknockout등의 뷰와 뷰모델을 자동으로 연결해주는 프레임웍이 없는 경우 커스텀으로 뷰에서 뷰모델을 소비하게 작성할 수 밖에 없습니다. 이런 관점에서 CustomerView를 제작해보죠. 뷰의 코드는 뷰모델의 연결로 인해 굉장히 복잡하니 집중해야 합니다.

코드 2-5

const CustomerView = class{
  constructor(vm, stage){

    //루트엘리먼트를 초기화함
    this.stage = stage;
    stage.innerHTML = "";

    //뷰모델을 소유하고 리스너로 등록한다.
    this.vm = vm;
    this.vm.addListener(this);
  }

  render(){

    //1. 필요한 HTML생성
    this.stage.innerHTML = `
      <style>.err{color:red;font-size:11px}</style>
      고객 종류: 
      <select name="type">
      ${
        this.vm.typeOption.reduce(
          (str, v)=>str+=`<option value="${v}">${v}</option>`, ''
        )
      }
      </select>
      <div class="err" name="typeVali">${this.vm.validate('type')}</div>
      성: <input type="text" name="lastName" value="${this.vm.lastName}">
      <div class="err" name="lastNameVali">${this.vm.validate('lastName')}</div>
      이름: <input type="text" name="firstName" value="${this.vm.firstName}">
      <div class="err" name="firstNameVali">${this.vm.validate('firstName')}</div>
      이메일: <input type="text" name="email" value="${this.vm.email}">
      <div class="err" name="emailVali">${this.vm.validate('email')}</div>
      <button name="save">저장하기</button>
    `;

    'type,lastName,firstName,email'.split(',').forEach(v=>{

      //2. 개별 엘리먼트와 밸리데이션 엘리멘트를 name속성을 통해 잡아둔다.
      this[v + 'Vali'] = this.stage.querySelector(`[name=${v}Vali]`);
      this[v] = this.stage.querySelector(`[name=${v}]`);

      //4. 뷰모델의 값을 업데이트함
      const f =_=>this.vm[v] = this[v].value;

      //3. change, keyup이벤트를 걸어줌
      this[v].addEventListener('change', f);
      this[v].addEventListener('keyup', f);
    });

    //저장버튼은 직접 vm의 save와 연결하자
    this.stage.querySelector('[name=save]').addEventListener('click', _=>this.vm.save());
  }

  //5. 뷰모델의 변화를 수신하여 반영함.
  update(field, v){
    const error = this.vm.validate(field, v);
    this[field + 'Vali'].innerHTML = error || '';
    if(v != this[field].value) this[field].value = v;
  }
};

//뷰를 초기화하고 생성함.
const vm = new CustomerViewModel();
const stage = document.querySelector('#stage');
const view = new CustomerView(vm, stage);
view.render();

//뷰모델을 갱신해도 뷰에 반영된다!
vm.firstName = 'hika';

뷰모델과 뷰의 바인딩을 직접 구현하고 있으므로 신경쓸 내용이 꽤나 많습니다.

  1. 먼저 HTML을 생성하고
  2. 각각 엘리먼트를 name을 통해 찾아 뷰의 속성으로 등록하며
  3. 엘리먼트에 change, keyup 이벤트를 걸어줍니다.
  4. 이벤트에서는 변화 내용으로 뷰모델을 업데이트하면,
  5. 이러한 뷰모델의 업데이트 결과가 옵져빙을 통해 update메소드로 들어옵니다.

이상의 내용은 번호에 맞춰 코드 상의 주석으로도 위치를 확인하실 수 있습니다.

  1. 이제 직접 뷰모델의 값을 바꾸면 뷰의 상태가 변하고
  2. 뷰의 내용을 바꾸면 뷰모델의 값도 갱신되는

양방향 바인딩이 일어나게 됩니다. 뷰가 뷰모델을 직접 바인딩하는 전체 코드는 아래와 같습니다. 이 코드에서는 모델클래스의 필드를 간단히 속성으로 정의해버리고, 뷰모델의 개별 속성에 대한 반복적인 정의는 Proxy의 get, set트랩을 이용해 제거하고 있습니다.

코드 2-6

//기저모델
const Customer = class{

  static add(customer){Customer.list.add(customer);}

  constructor(){

    //간단히 필드를 정의함
    const fields = 'firstName,lastName,email,totalSales,isCompany'.split(',');
    fields.forEach(v=>this[v] = undefined);
  }
};
Customer.list = new Set();

//뷰모델
const ViewModel = class{
  constructor(){this.listeners = new Set();}
  addListener(target){this.listeners.add(target);}
  removeListener(target){this.listeners.delete(target);}
  notify(field, data){this.listeners.forEach(v=>v.update(field, data));}
};

const CustomerViewModel = class extends ViewModel{
  constructor(){
    super();
    this.model = new Customer();

    //뷰모델측 데이터 저장소
    const proxy = this.proxy = {}; 
    const fields = 'type,firstName,lastName,email';

    //프록시를 이용해 get, set제거
    return new Proxy(this, {

      get(self, k){

        //필드는 proxy가 대응
        if(fields.includes(k)) return proxy[k] || ""; 
        else if(k == 'typeOption') return ["선택안함", "개인", "회사"];
        else return self[k];
      },

      set(self, k, v){

        //필드는 proxy에 기록
        if(fields.includes(k)) self.notify(k, proxy[k] = v);
        return true;
      }
    });
  }
  validate(field, v = ''){
    switch(field){
    case'type':return !["개인", "회사"].includes(v) ? '고객 종류를 반드시 선택해주세요' : '';
    case'firstName':return !v.trim() ? '이름을 반드시 적어주세요' : '';
    case'lastName':return !v.trim() ? '성을 반드시 적어주세요' : '';
    case'email':return !v.trim() ? '이메일을 반드시 적어주세요' : '';
    }
  }
  save(){
    const fields = 'type,firstName,lastName,email'.split(',');

    //기존에는 this의 속성이지만 이제 proxy가 대응함
    if(fields.some(v=>this.validate(v, this.proxy[v]))) return;
    'firstName,lastName,email'.split(',')
        .forEach(v=>this.model[v] = this.proxy[v]);
    this.model.isCompany = this.proxy.type == '회사';
    Customer.add(this.model);
  }
};

//뷰
const CustomerView = class{
  constructor(vm, stage){
    this.stage = stage;
    stage.innerHTML = "";
    this.vm = vm;
    this.vm.addListener(this);
  }
  render(){
    this.stage.innerHTML = `
      <style>.err{color:red;font-size:11px}</style>
      고객 종류: 
      <select name="type">
      ${
        this.vm.typeOption.reduce(
          (str, v)=>str+=`<option value="${v}">${v}</option>`, ''
        )
      }
      </select>
      <div class="err" name="typeVali">${this.vm.validate('type')}</div>
      성: <input type="text" name="lastName" value="${this.vm.lastName}">
      <div class="err" name="lastNameVali">${this.vm.validate('lastName')}</div>
      이름: <input type="text" name="firstName" value="${this.vm.firstName}">
      <div class="err" name="firstNameVali">${this.vm.validate('firstName')}</div>
      이메일: <input type="text" name="email" value="${this.vm.email}">
      <div class="err" name="emailVali">${this.vm.validate('email')}</div>
      <button name="save">저장하기</button>
    `;
    'type,lastName,firstName,email'.split(',').forEach(v=>{
      this[v + 'Vali'] = this.stage.querySelector(`[name=${v}Vali]`);
      this[v] = this.stage.querySelector(`[name=${v}]`);
      const f =_=>this.vm[v] = this[v].value;
      this[v].addEventListener('change', f);
      this[v].addEventListener('keyup', f);
    });
    this.stage.querySelector('[name=save]').addEventListener('click', _=>this.vm.save());
  }
  update(field, v){
    const error = this.vm.validate(field, v);
    this[field + 'Vali'].innerHTML = error || '';
    if(v != this[field].value) this[field].value = v;
  }
};
const vm = new CustomerViewModel();
const stage = document.querySelector('#stage');
const view = new CustomerView(vm, stage);
view.render();

다음의 주소에서 구동되는 샘플을 확인할 수 있습니다.

https://hikamaeng.github.io/mvc/2-6.html

뷰모델과 뷰의 바인딩 문제top

위의 코드에서 문제는 다름 아닌 뷰에 있습니다. 뷰에 뷰모델을 바인딩하는 과정이 전부 코드로 작성되어있는데, 뷰마다 이 정도의 코딩을 하지 않으면 안된다는 의미입니다. 뷰모델과의 상호작용을 자동으로 바인딩하는 방법이 있다면 뷰의 코드가 크게 줄어들 것입니다. 한 가지 아이디어로 생각해보죠.

  1. 뷰를 순수하게 HTML로만 작성하고
  2. HTML5표준인 data-* 확장스펙을 통해 뷰모델과 연결될 수 있는 힌트를 제공해줄 수 있을 것입니다.

아주 간단한 형식 문법을 만들어 뷰모델과 바인딩 될 수 있는 정보를 제공해보죠.

코드 2-7

<style>.err{color:red;font-size:11px}</style>

<section id="stage" data-bind="vm=CustomerViewModel">
  고객 종류: 
  <select data-bind="prop=type,option=typeOption"></select>
  <div class="err" data-bind="validate=type"></div>

  성: 
  <input type="text" data-bind="prop=lastName">
  <div class="err" data-bind="validate=lastName"></div>

  이름: 
  <input type="text" data-bind="prop=firstName">
  <div class="err" data-bind="validate=firstName"></div>

  이메일: 
  <input type="text" data-bind="prop=email">
  <div class="err" data-bind="validate=email"></div>

  <button name="save" data-bind="action=save">저장하기</button>
</section>

위의 HTML에서 data-bind라는 속성은 뷰모델과 뷰를 바인딩하기 위해 사용됩니다.

  1. 최초 section에 선언된 vm=CustomerViewModel 을 통해 내부의 자식범위가 전부 이 뷰모델의 영향권에 들어간다는 것을 선언해줍니다.
  2. select박스에는 바인딩될 뷰모델의 속성을 prop값으로, 옵션에 사용될 속성을 typeOption으로 바인딩했습니다.
  3. 그 외의 input들은 prop를 이용해 뷰모델의 속성을 바인딩하고
  4. 밸리데이션 에러를 출력할 div들에게는 validate를 이용해서 어떤 속성의 밸리데이션을 표시할지 힌트를 줍니다.

이렇게 순수한 HTML이 뷰인 상태에서 뷰모델과 자동으로 바인딩해주는 장치가 필요할 것입니다. 이러한 바인딩처리기를 차근차근 구현해보죠.

바인딩용 파서top

우선 data-bind에 기술된 데이터는 컴마로 구분되고 각 항목은 ‘=’을 통해 키와 값으로 분리됩니다. 이 문자열을 파싱하여 Map을 반환하는 간단한 파서를 먼저 작성해보죠.

코드 2-8

const parse = v=>new Map(v.split(',').reduce((arr, v)=>(arr.push(v.split('=')), arr), []));

이제 parse(‘a=3,b=5’) 처럼 보내면 {a:3, b:5} 형태의 Map을 얻을 수 있게 되었습니다.

바인더top

실제 바인딩 전략에 대해서 생각해보죠.

루트 엘리먼트에는 바인딩할 뷰모델의 힌트가 vm값으로 주어지므로 이를 통해 얻은 뷰모델 클래스로 인스턴스를 생성할 것입니다. 결국 이전 MVC의 라우터처럼 뷰모델 클래스를 라우팅 테이블과 흡사한 상태로 바인딩 시스템에 미리 등록해야 한다는 의미입니다.

그럼 파서와 뷰모델 등록 및 최초 엘리먼트의 뷰모델 초기화까지를 한데 모아 기초가 되는 Binder클래스를 작성해보죠.

코드 2-9

const Binder = class{

  //파서
  static parse(v){
    return new Map(
      !v ? null :
        v.split(',')
        .reduce((arr, v)=>(arr.push(v.split('=')), arr), [])
    );
  }

  constructor(){
    this.vm = new Map();
  }

  //뷰모델 등록
  addVM(k, vm){
    this.vm.set(k, _=>new vm());
  }

  //바인딩시작
  bind(root){

    //루트로부터 바인딩할 뷰모델 팩토리를 얻음
    const getVM = this.vm.get(
      Binder.parse(root.dataset.bind || '').get('vm')
    );

    //팩토리가 없으면 여기서 종료
    if(!getVM) return; 

    //뷰모델 인스턴스 생성
    const vm = getVM();
  }
};

const binder = new Binder();

//뷰모델 클래스 등록
binder.addVM('CustomerViewModel', CustomerViewModel);

//바인딩 시작!
binder.bind(document.querySelector('#stage'));

위의 코드는 뷰와 바인딩할 뷰모델을 생성하는데까지 정의하고 있습니다. 이제 기초가 잡혔으니 DOM을 순회하면서 data-bind속성을 처리해가면 됩니다.

DOM 순회처리top

바인딩할 뷰모델을 생성했으니 남은 건 root의 자손을 순회하면서 바인딩을 처리를 해주는 것 뿐입니다. DOM의 순회는 간단히 아래와 같이 처리됩니다.

코드 2-10

bind(root){
  const getVM = this.vm.get(Binder.parse(root.dataset.bind || '').get('vm'));
  if(!getVM) return;
  const vm = getVM();

  //빈 스택 생성
  const stack = [];

  //최초 루프의 시작은 첫번째 자식부터
  let el = root.firstElementChild, temp;

  do{

    //순회하며 자식이나 형제 발견시 스택에 추가!
    if(temp = el.firstElementChild) stack.push(temp);
    if(temp = el.nextElementSibling) stack.push(temp);

    //여기서 바인딩처리

  }while(el = stack.pop());//스택을 소비한다!
}

간단한 스택머신을 응용하여 root엘리먼트의 첫 번째 자식부터 쭉 형제와 자식을 추가해가는 방식으로 순회하게 됩니다.
각 순회한 엘리먼트의 data-bind속성을 조사하여 뷰모델과 연결지어주면 될 것입니다. 위 코드의 주석을 집중하여 작성해보죠. 우선 구현해야 하는 스펙은 다음과 같습니다.

  1. prop – 뷰모델속성의 get, set과 연결함.
  2. option – 뷰모델로부터 옵션이 될 배열을 받아 option태그를 만들어준다.
  3. validate – 해당 속성의 밸리데이터와 연결한다.
  4. action – 뷰모델의 특정 메소드와 연결한다.

별거 없습니다. 위 스펙에 맞게 bind 메소드 내부를 차근차근 구현해보죠.

코드 2-11

const vm = getVM(), stack = [];

//리스너에 반응할 DOM저장소. 
//fields는 뷰모델 필드 갱신시, validators는 밸리데이터메세지에 반응할 엘리먼트들
const fields = new Map(), validators = new Map();

//뷰모델용 리스너(기존에는 뷰의 update메소드 역할)
vm.addListener({update(field, v){
    const error = vm.validate(field, v);
    validators.get(field).innerHTML = error || '';
    field = fields.get(field);
    if(v != field.value) field.value = v;
}});

let el = root.firstElementChild, temp;
do{
  if(temp = el.firstElementChild) stack.push(temp);
  if(temp = el.nextElementSibling) stack.push(temp);
  
  //bind속성을 순회
  const target = el; //forEach콜백을 위한 자유변수
  Binder.parse(el.dataset.bind || '').forEach((v, k)=>{
    switch(k){

    case'prop':

      //기존처럼 뷰모델 속성과 연결하고 추적 리스너 등록
      const f =_=>vm[v] = target.value;
      target.addEventListener('change', f);
      target.addEventListener('keyup', f);

      //update시를 위한 fields에도 등록
      fields.set(v, target);
      break;

    case'option':

      //간단히 select의 옵션을 채워줌
      target.innerHTML = vm[v].reduce(
        (str, v)=>str+=`<option value="${v}">${v}</option>`, 
        ''
      );
      break;

    case'validate':

      //최초 밸리데이션 결과를 출력하고
      target.innerHTML = vm.validate(v);

      //update용 밸리데이터 대상에 등록한다.
      validators.set(v, target);
      break;

    case'action':

      //뷰모델의 메소드에 연결한다.
      target.addEventListener('click', _=>vm[v]);
      break;
    }
  });
}while(el = stack.pop());

바인딩의 각 속성을 연결해주는 switch문의 각 case는 이 전 뷰에서 구현했던 로직을 보다 일반화 시켜 나눠 준 것일 뿐이므로 새로운 코드는 없는 셈입니다.
큰 어려움 없이(^^;) data-bind를 이용하는 자동 바인딩 시스템을 구축할 수 있었습니다. 완성된 전체 코드는 다음과 같습니다.

코드 2-12

<style>.err{color:red;font-size:11px}</style>
<section id="stage" data-bind="vm=CustomerViewModel">

    고객 종류: 
    <select data-bind="prop=type,option=typeOption"></select>
    <div class="err" data-bind="validate=type"></div>

    성: 
    <input type="text" data-bind="prop=lastName">
    <div class="err" data-bind="validate=lastName"></div>

    이름: 
    <input type="text" data-bind="prop=firstName">
    <div class="err" data-bind="validate=firstName"></div>

    이메일: 
    <input type="text" data-bind="prop=email">
    <div class="err" data-bind="validate=email"></div>

    <button name="save" data-bind="action=save">저장하기</button>
</section>

//2-6과 완전 동일 
const Customer = class{..}
const ViewModel = class{..}
const CustomerViewModel = class extends ViewModel{..}

//바인더
const Binder = class{
    static parse(v){
        return new Map( !v ? null : v.split(',').reduce((arr, v)=>(arr.push(v.split('=')), arr), []));
    }
    constructor(){this.vm = new Map();}
    addVM(k, vm){this.vm.set(k, _=>new vm());}
    bind(root){
        const getVM = this.vm.get(Binder.parse(root.dataset.bind || '').get('vm'));
        if(!getVM) return;
        const vm = getVM(), stack = [];
        const fields = new Map(), validators = new Map();
        vm.addListener({update(field, v){
            const error = vm.validate(field, v);
            validators.get(field).innerHTML = error || '';
            field = fields.get(field);
            if(v != field.value) field.value = v;
        }});
        let el = root.firstElementChild, temp;
        do{
          if(temp = el.firstElementChild) stack.push(temp);
          if(temp = el.nextElementSibling) stack.push(temp);
          const target = el;
          Binder.parse(el.dataset.bind || '').forEach((v, k)=>{
            switch(k){
            case'prop':
              const f =_=>vm[v] = target.value;
              target.addEventListener('change', f);
              target.addEventListener('keyup', f);
              fields.set(v, target);
              break;
            case'option':
              target.innerHTML = vm[v].reduce((str, v)=>str+=`<option value="${v}">${v}</option>`, '');
              break;
            case'validate':
              target.innerHTML = vm.validate(v);
              validators.set(v, target);
              break;
            case'action':
              target.addEventListener('click', _=>vm[v]());
              break;
            }
          });
        }while(el = stack.pop());
    }
};

const binder = new Binder();
binder.addVM('CustomerViewModel', CustomerViewModel);
binder.bind(document.querySelector('#stage'));

구동하는 샘플은 아래에 있습니다.
https://hikamaeng.github.io/mvc/2-12.html

결론top

MVVM은 순수한 인메모리 객체로 뷰를 표현할 수 있으므로 테스트가 용이하고 변화가 심한 뷰의 디자인으로부터 안전하게 핵심로직을 구현해갈 수 있는 장점이 있습니다. 또한 뷰모델이 뷰에 대해 전혀 모르기 때문에 하나의 뷰모델을 다수의 뷰와 바인딩하거나 하나의 뷰가 여러 개의 뷰모델과 관계를 맺는 것도 가능합니다.

하지만 자동화된 뷰와 뷰모델 사이의 바인딩 시스템이 없다면 둘 사이를 연결하는 코드가 중복적이고 양도 많아 부담스럽습니다. 위에서는 직접 뷰 객체를 만들어 수동바인딩한 예를 보여드렸지만 MVC 상에서 컨트롤러가 뷰에 뷰모델을 연결해주는 형태로 진행할 수도 있습니다(MVCVM 이라고 해야할까요 ^^;)

WPF나 knockout.js(MS출신이 만들었..)처럼 유명 MVVM프레임웍들은 뷰에 데이터바인딩 정보를 넣어두면 오토바인딩을 해주는 시스템을 내포하고 있습니다. 이 글에서도 미니버전으로 오토바인더를 만들어 봤습니다.

하지만 이런 문제가 해결되어도 뷰모델에 대한 최적화가 자동으로 이루어지는 것은 아닙니다. 이 글에서는 다행히 ES6+의 프록시 기능으로 반복적인 속성 정의를 간단히 줄였지만 보통 지루한 코드가 많이 나오는 편입니다. 하지만 컨트롤러에 비하면 뷰의 CRUD에 대해 별도로 대응하지 않고 단일한 바인딩으로 대응하는 장점이 커 복잡성이 많이 줄어듭니다.

연습문제top

중급 – 처음 정의했던 전체 고객리스트, 상세보기, 삭제, 수정을 개별 뷰객체 바인딩으로 구현하세요.
중급 – 위의 내용을 오토바인딩으로 구현하세요.

P.S 연습문제를 다 푸셔도 제가 해드릴 수 있는 건 별로 없습니다만 댓글로 질문하시면 성실히 답해드립니다 ^^;;