[react] 리액트 훅 MVVM #1

개요top

리액트 16 이후에 적용되어있는 훅기반의 함수형 컴포넌트는 편리하지만 까다로운 성질을 갖고 있습니다. 모델-렌더링을 이해하고 더 나아가 MVVM을 어떻게 구현하는데 있어 리액트를 어떻게 활용할 것이가를 살펴봅니다.
본 포스팅에서는 리액트 훅으로만 설명할거라 클래스 컴포넌트의 설명을 생략합니다. 또한 기본적인 리액트에 대한 이해가 있다고 가정하고 있습니다 ^^

리액트는 왜 공부할수록 아리송한거죠?
맘 내키는 대로 쓸 수 있게 생겨서 멍!

vDOM과 증분 렌더링top

vDOM의 핵심은 증분 렌더링이라고 할 수 있습니다. 증분 렌더링은

  1. 실제 네이티브 객체인 DOM의 상태를 이용하지 않고,
  2. 비교군인 vDOM의 이전 렌더링 상태와 현재 변화된 상태의 차이점을 찾아낸 뒤,
  3. 차이점 만큼만 연결된 실제 DOM의 속성을 업데이트하여 렌더링하는 기법입니다.

이 기법은 vDOM을 비교하는 비용이 직접 DOM렌더링하는 비용보다 적을 수록 성능 상 큰 이득이 됩니다. DOM이 느리다는 것은 통설로 알려져있지만, 실은 그렇지도 않습니다. IE가 아닌 모던 브라우저는 DOM도 자바스크립트와 완전히 동일한 메모리 모델을 쓰기 때문에 자바스크립트의 객체를 다루는 것과 속도 차이는 거의 없습니다. 즉

const a = {title:'hello'};
a.title = 'world';

이렇게 js의 속성을 다루는 것과

const a = document.getElementById("a");
a.value = 'world';

DOM의 속성을 얻거나 갱신하는 것은 거의 같은 비용을 발생시킬 뿐 딱히 DOM객체가 느린 것은 아닙니다. 문제는 DOM을 건드리면 브라우저가 렌더링 트리를 재계산하고 그에 맞춰 렌더링을 일으킨다는 점입니다. 즉 느려지는 것은 렌더링 부분입니다.
되도록 DOM의 속성을 건드리지 않으려는 것은 렌더링이 느리기 때문입니다. 그런 의미에서 DOM의 속성 중에 렌더링에 영향을 주지 않는 것은 딱히 자주 갱신한다고 크게 성능의 문제가 되지는 않습니다.

const a = document.getElementById("a");
a.onclick =_=>console.log('world');

위 예에서 onclick은 이벤트리스너를 재설정한 것이기 때문에 렌더링에 큰 영향을 주지 않습니다. 사실 이런 건 vDOM을 경유할 이유가 없는 것이죠.
하지만 렌더링에 조금이라도 영향을 주는 것들은 최소한으로 DOM을 건드리는 것이 좋습니다….라곤 하지만 그건 모던 브라우저를 우습게 보는 것이기도 합니다 ^^;

const a = document.getElementById("a");
setInterval(_=>a.style.width = '500px', 16);

이 코드에서 초당 60회로 width를 갱신해서 브라우저 렌더링에 큰 부하를 줄거 같지만, 모던브라우저는 워낙 똑똑해서 리액트와 마찬가지로 이전 렌더링 결과와 바뀐 게 없다면 딱히 추가 작업을 하지 않습니다.

즉 vDOM이 하는 일을 이미 브라우저도 할 줄 압니다.

그러한 이유로 모던 브라우저에서 같은 일을 하는 코드를 작성해보면 바닐라JS가 대부분의 경우 리액트보다 빠르게 작동합니다.
리액트가 강조하는 vDOM에 의한 성능 향상은 실제로는 그렇게 크게 발생할 일이 별로 없다는 거죠.

게다가 일반적인 통념과 달리 리플로우보다 리페인트의 부하가 더 큽니다. 단지 리플로우가 일어나면 많은 부분에 리페인트를 유발하기 때문에 리플로우를 최소화하려는 것입니다. 하지만 리플로우를 최소화했다고 해도 원래 부하가 큰 리페인트가 있다면 소용없습니다.

페인트 및 합성

크롬 개발자 페이지에서도 페인팅이 가장 비용이 많이드는 공정이라는 사실을 알려주고 있습니다. 이런 저런 이유로 vDOM이 날 JS에 비해 성능 상의 잇점이 크지 않다는 것을 알아둘 필요가 있습니다. 즉 리액트를 사용하는 것은 성능 상의 잇점 때문이라고만 보기는 힘들다는 것이죠.

근데 왜 사람들은 DOM을 건드리면 안된다고 하나요?
그렇게 생각하는게 편하니까?? 멍!

모델 – 렌더의 개념top

하지만 리액트같은 뷰라이브러리가 단지 성능 상의 의미를 갖는 것은 아닙니다. 클라 작업의 오류는 대부분 뷰 객체의 속성을 직접 다루면서 발생합니다.

const a = document.getElementById("a");
a.style.webKitBorderRadius = "10px";
a.addEventListener("click", console.log);

네이티브 뷰인 a를 직접 제어하는 코드가 있습니다. 이런 코드는 반드시 노하우와 예외처리가 결부됩니다.

  • 사소한 스타일일지라도 접두어에 대한 노하우나 예외처리가 필요하고,
  • 이벤트리스너를 다는 단순한 작업조차 브라우저별 호환성을 조사해야하는 경우가 발생할 것입니다.

과거 제이쿼리는 이러한 뷰제어의 노하우와 예외처리를 함수로 감춰 개발자가 그러한 지식이 없어도 안전하게 네이티브 DOM을 다룰 수 있는 전략을 취했습니다.

하지만 설령 그러한 제어를 감춘다고 해도 여전히 뷰를 직접 제어하는 것은 수 많은 오류의 원인이 됩니다.
대표적으로 뷰를 제어할 때마다 뷰의 상태가 뷰 객체 내에 쌓인다는 문제가 있습니다.

const img = document.getElementById("img");
const id = setInterval(_=>img.width = 10 + img.width, 1000);
img.addEventListener("click", _=>clearInterval(id));

클릭하기 전까지 img의 크기가 1초마다 10씩 늘어나다 클릭하면 멈추게 됩니다. 뷰 자체의 상태인 width에 의존해서 상태 갱신되고 있죠.
이 구조의 문제는 복원할 수 없다는 점입니다. 현재 이미지의 크기 상태로 복원하려면 몇 번 인터벌이 발생했나를 알고 있어야합니다만 그러한 외부 상태에 의존하지 않고 뷰 자체의 상태를 적층했기 때문에 저장할 수도 복원할 수도 없는 것이죠.

만약 DOM이 json처럼 저장가능하게 시리얼라이즈가 된다면 오히려 리액트같은 프레임웍들은 거의 필요가 없을 것입니다. 뷰 그 자체를 저장했다가 그대로 복원하면 되니까요. 하지만 이것이 불가능하다면 외부 상태로부터 복원되도록 다음과 같이 변경해볼 수 있습니다.

const img = document.getElementById("img");
const viewModel = {
  target:img,
  prop:{width:10},
  render(){Object.assign(this.target, this.prop);}
};
const id = setInterval(_=>{
  viewModel.prop.width += 10;
  viewModel.render();
}, 1000);
img.addEventListener("click", _=>clearInterval(id));

이제 인터벌에서 직접 img를 다루는 코드가 완전히 사라졌습니다. 상태는 완전히 viewModel이라는 js객체 안에 들어있죠.

그럼 직접 img를 갱신하는 코드는 어디로 간건가요?

render함수 안에서 처리합니다. 이 render함수는 네이티브 DOM을 일관성있게 다루는 제어문을 갖고 있죠. 따라서 외부에는 DOM을 제어하는 코드가 사라집니다.
원래는 여기저기 있던 제어문이 render에만 존재하고 나머지는 이 render의 제어를 의존하게 되어 제어 역전이 성립하게 되는거죠.

따라서 현재 상태는 DOM을 저장하는게 아니라 viewModel을 저장하면 됩니다. save, load는 다음과 같이 만들 수 있겠네요.

const save =_=>localstorage["vm"] = JSON.stringify(viewModel.prop);
const load =_=>viewModel.prop = JSON.parse(localstorage["vm"]);

그 전에는 불가능했던 뷰의 상태 복원이 가능해진 이유는 두 가지입니다.

  1. 뷰가 상태를 갖는 것이 아니라 순수 js객체가 상태를 갖고
  2. 뷰는 그저 그 상태를 그대로 표현하는 제어에만 사용한다.

즉 단지 뷰의 제어를 render에 몰아두는 것만으로는 부족합니다. 상태의 외재화가 성공하려면 뷰의 업데이트가 오직 외부 상태로부터 복원되는 것만 있어야 합니다.

이러한 일반적인 뷰의 통제 정책을 ‘모델-렌더’라 합니다.

  1. 상태를 보관한 모델이 있고
  2. 그 모델을 그대로 반영하여 뷰를 그리는 것이죠.

이 개념 하에선 모델을 갱신하는 것만 유일하게 뷰를 업데이트할 수 있는 방법이 됩니다. 이 구조를 통해

  1. 뷰를 직접 제어하는 것으로부터 초래되는 수많은 버그를 제거하고,
  2. 단순히 모델의 상태를 갱신하는 코드로 대체되어 안정적인 작동과
  3. 복원 및 저장이 가능하게 됩니다.

리액트는 이러한 모델-렌더를 실현할 수 있는 뷰제어라이브러리지만 그것은 리액트를 모델-렌더에 맞춰 사용할 경우에 얻을 수 있는 혜택입니다.
리액트도 얼마든지 모델-렌더를 깨버린 위험한 제어를 포함한 형태로 사용될 수 있고 실제 그렇게 사용하는 경우가 많습니다.

본 포스팅 시리즈에서는 모델-렌더에 기반한 MVVM방식의 리액트 사용을 다루게 됩니다.

원래 리액트는 모델-렌더방식으로 쓰는 거 아닌가요?
하지만 JSX는 로직리스 뷰가 아니라능!

증분 업데이트top

모델의 변화에 따라 렌더링되는 것에 대해 약간 더 고찰해보죠.

const viewModel = {
  target:img,
  prop:{width:10},
  render(){Object.assign(this.target, this.prop);}
};

앞 서 나왔던 render함수는 무조건적으로 prop을 target에 업데이트하고 있습니다. 하지만 정말 그럴 필요가 있을까요?

만약 이 전에 상태와 동일하다면 굳이 렌더링할 필요는 없을 것입니다. 오히려 직전 상태와 다른 점만 찾아서 렌더링하는 것이 효과적이죠.
조금 복잡할 수 있지만 이전 상태와의 차이점을 계산하는 로직을 짜보죠.

const viewModel = {
  target:img,
  prop:{width:10},
  old:{}, //이전 상태
  render(){

    //이전에 존재했다가 사라지는 키를 찾기 위해 준비
    const oldKeys = Object.keys(this.old);
   
    //현재 prop을 복사하기 위한 객체
    const cloneProp = {};

    //현재 prop의 상태를 old와 비교
    const diff = Object.entries(this.prop).reduce((diff, [k, v])=>{
      //cloneProp에 복사
      cloneProp[k] = v;

      //old에 없거나 값이 다르면 diff에 포함
      if(!this.old[k] || this.old[k] !== v) diff[k] = v;

      //oldKey에서 지금 키를 제거하고 남은게 old에만 있는 키
      const i = oldKeys.indexOf(k);
      if(i !== -1) oldKeys.splice(i, 1);

      return diff;
    }, {});

    //old를 다 썼으므로 갱신함
    this.old = cloneProp;

    //old에만 있는 키에 대해 제거작업을 진행
    oldKeys.forEach(k=>delete this.target[k]);

    //변화가 있는 diff만 적용
    if(Object.keys(diff).length) Object.assign(this.target, diff);
  }
};

위 코드는 증분렌더러의 전형적인 예입니다.

  1. 이전에 그렸던 모델의 정보를 old에 잡아두고,
  2. 이번에 그릴 prop와 비교하여 차이점만 계산하여 제거하거나 수정합니다.
  3. 그리고 나서 현재의 상태를 다시 old에 잡는 것이죠.

예를들어

old = {width:20, height:20}

인 상황에서 이번에 그릴 상태가 다음과 같다고 생각해보죠.

prop = {width:30}
  1. height는 제거해야하고
  2. width는 갱신하면 되겠죠.
  3. 그리고 나서 다시 현재 prop이 새로운 old가 되는 식입니다.

직관적으로 보면 이전 모델과 현재 모델의 차이점을 계산하는 것이 큰 비용으로 보이지만, 변화가 생긴 최소한의 속성만 렌더링하니 유리한 면이 발생하는 것입니다(사실 모던 브라우저의 내장된 증분렌더링 때문에 그렇게까지 극적인 효과를 갖고 있지는 않습니다 ^^)

하지만 중요한 건 앞서 말씀드린대로 성능 향상 이전에 그저 prop만 갱신하고 render()를 호출하면 뷰에 대한 제어가 끝난다는 점입니다.
이 점은 좋긴 한데 좀 더 살펴볼 개선사항이 있습니다.

const id = setInterval(_=>{
  viewModel.prop.width += 10;
  viewModel.render();
}, 1000);

이 코드에서는 순수하게 모델인 prop의 width만 갱신하고 render를 호출했습니다.

  • 만약 width만 수정하면 자동으로 render를 호출하게 해도 되지 않을까요?

이렇듯 어떤 일을 했는데 그것을 감지하고 다른 일이 따라오게 하는 패턴을 옵져버패턴이라고 합니다.

  1. prop의 속성이 변화할 때마다 render가 자동으로 호출되게 하고 싶다면
  2. prop의 속성 하나하나에 옵져버를 걸어줘야합니다.
  3. 뿐만 아니라 prop의 width가 갱신되도 render가 호출되고 height가 갱신되도 render가 호출됩니다.
  4. 결국 바꾼 속성 수 만큼 render가 호출되겠죠.

확실히 이건 성능 상 좋지 않은 불합리함입니다.

차이점을 계산하는게 오히려 성능을 떨어트리지 않나요?
렌더링이 아닌 계산은 개 빠르다능!

스케쥴러top

어떤 대안이 있을까요? 중요한 착안점은 ‘렌더링이란 마구 호출해봐야 브라우저의 프레임 단위로만 일어날 수 있다’는 것입니다.
그렇다면 이번 프레임에 변화가 있었던 뷰모델만 수집해서 render를 호출해주는 시스템이 따로 존재하는 식으로 처리할 수 있지 않을까요?

  1. 즉 prop의 각 속성에 옵져버를 걸었지만,
  2. 이 옵져버는 render를 직접 호출하는 것이 아니라 이번에 렌더링을 호출할 대상으로 넣어주는 선에서 끝나고,
  3. 렌더링 프레임에 맞춰 일괄로 지정된 대상들에게만 render를 호출해주는 방식입니다.

이를 위해 간단한 준비를 해보죠.

  1. viewModel는 prop의 속성이 바뀌면 렌더링 대상풀에 들어가야 합니다.
  2. 따라서 prop은 모든 속성을 추적할 수 있는 Proxy여야합니다.
  3. 또한 프록시가 속성변화에 따라 렌더링 타겟 대상에 넣어줘야 합니다.

먼저 쉽게 만들 수 있는 이번에 그릴 대상에게 render를 호출해주는 쪽부터 구현해보죠.

const renderTarget = new Set;
const f =_=>{
  renderTarget.forEach(vm=>vm.render());
  renderTarget.clear();
  requestAnimationFrame(f);
};
requestAnimationFrame(f);

구현이 매우 간단합니다. 그저 renderTarget에 뭔가 있으면 render호출해주고 비워주는 게 전부니까요.
하지만 이 간단한 구현이 바로 렌더링 스케쥴러가 됩니다.
렌더링 스케쥴러는 끝 없이 렌더링해야 할 대상을 감시하고 변화가 생기면 render함수를 호출해 줍니다.

이제 viewModel의 옵져버를 Proxy를 이용해 구현해보죠.

const Handler = class{

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

  //추가, 삭제, 수정 프록시 훅
  defineProperty(){renderTarget.push(this.vm);}
  deleteProperty(){renderTarget.push(this.vm);}
  set(){renderTarget.push(this.vm);}
};
  
const ViewModel = class{

  target;
  prop = new Proxy({}, new Handler(this));
  old = {};

  constructor(element){this.target = element;}
  render(){/*상동*/}
};

Proxy에 익숙하지 않은 분들에겐 약간 허들이 있을 수 있습니다. 간략히 설명하죠.

  1. Handler클래스는 생성자에서 대상이 되는 ViewModel의 인스턴스를 자신의 vm속성으로 잡아두고
  2. 추가, 삭제, 수정에 대한 훅에서 앞서 정의한 renderTarget에 해당 vm을 넣어줍니다.
  3. ViewModel클래스는 prop속성에 대해 이 핸들러를 건내 Proxy를 만들게 되므로
  4. prop의 어떤 값이 추가, 삭제, 수정되든 자신을 renderTarget에 등록하게 되죠.

이제 완성된 코드를 사용해보면

const vm = new ViewModel(document.querySelector("#img1"));
setInterval(_=>{
  vm.prop.width += 10;
  vm.prop.height += 10;
}, 1000);
  1. prop의 속성만 바꾸면,
  2. vm이 renderTarget에 포함되며
  3. 스케쥴러가 vm.render()를 호출해줄 것이고,
  4. render함수는 차이점을 계산하여 실제 DOM에 반영할 것입니다.

이제 뷰의 변화를 완전히 모델의 변경에만 의존할 수 있으며 그 갱신주기나 성능에 대해 크게 걱정할 필요가 없어졌습니다.
하지만 여기서 이 방식의 중요한 2단계 처리과정을 알 수 있습니다.

  1. 변화가 생기면 일단 renderTarget에 포함되어 render를 호출하는 대상이 되고 render가 호출된다.
  2. 하지만 render가 호출되어도 그 안에서 증분계산이 되므로 만약 prop이 바뀐 부분만 실제 DOM에 반영될 것이다.

즉 render가 호출되는 것과 실제 증분계산을 통한 DOM의 반영은 다르다는 것입니다.

  • 모델의 속성만 변하면 무조건 render가 호출되겠지만,
  • 같은 값이라면 DOM에는 아무것도 반영되지 않을 것입니다.
const vm = new ViewModel(document.querySelector("#img1"));
setInterval(_=>vm.prop.width = 10, 1000);

위에서 width는 똑같은 10을 계속 넣고 있습니다.

  1. Proxy의 set이 발동되어 renderTarget에 포함되고,
  2. 실제 vm.render()함수가 호출되긴 하지만
  3. 최초 이후 diff계산에 의해 실제 DOM에는 아무 변화도 주지 않게 됩니다.
처음부터 기존과 달라질 때만 renderTarget에 넣으면 되지 않나요?
render에서 prop변화 처리 외에도 여러가지를 하고 싶을 수 있다능!

리액트 컴포넌트와 statetop

사실 여태 설명한 매커니즘이 바로 리액트의 state와 컴포넌트의 관계입니다.
우리는 개발자이므로 개념적인 설명보다 코드적인 구현의 이해가 더 쉬울 수 있기에(정말?) 코드를 통해 설명했던거죠 ^^

리액트 엘리먼트는 내부에 존재하는 state가 set되는 시점이 바로 renderTarget에 등록되는 이벤트입니다.
리액트 스케쥴러는 이렇게 등록된 대상을 일괄로 호출하게 됩니다.
즉 useState로 부터 얻은 setter를 호출하는 시점이 바로 컴포넌트 함수가 호출되는 시점인거죠.

const View1 =_=>{
  const [get, set] = useState(1);
  const [click] = useState(_=>_=>set(2));
  return (<div onClick={click}/>);
};

위 코드는 짧지만 여러 의미를 내포합니다.

  1. 처음 useState로부터 get, set을 얻습니다.
  2. set은 아무리 상태를 갱신해도 언제나 같은 함수참조를 반환합니다.
  3. 2번을 이용하면 set은 한번만 캡쳐해도 충분하다는 것을 알 수 있습니다.
  4. click은 이벤트 리스너이므로 함수가 필요합니다.
  5. 하지만 만약 함수내에서 매번 화살표함수를 만들게 되면 새로운 객체를 생성해 뷰에 할당한 것이 됩니다.
  6. 이러면 컴포넌트가 호출될 때마다 같은 일을 하는 click인데도 다른 함수객체가 들어가 매번 새로운 렌더링 대상이 됩니다.
  7. 이를 방지하려면 이 또한 state로 잡아주면 됩니다.
  8. 하지만 useState에 그냥 함수를 넣으면 그 함수를 호출하여 나온 값을 상태로 잡기 때문에 함수를 반환하는 함수를 만들 필요가 있죠.
  9. 그렇게 만들어진 div는 클릭하면 set을 통해 첫번째 state를 갱신하게 됩니다.

이 부분은 많이 간과되지만 클릭리스너를 만약 다음과 같이 설정해보면

const View1 =_=>{
  const [get, set] = useState(1);
  return (<div onClick={_=>set(2)}/>);
};

View1이 호출될 때마다 새로운 화살표함수가 div에 할당되어 증분렌더링의 대상이 되어버립니다. 항상 같은 함수를 사용하고 싶다면 state를 사용하거나 useMemo, useCallback 등을 활용해야하나 여기서는 간단히 useState로 통일해 쓰겠습니다(짜피 setter로 바꿀 생각이 없으므로 ^^)

다시 원래 코드에서 div를 클릭해보면

  1. set이 호출되므로 View1은 다시 호출됩니다.
  2. 하지만 다시 호출된 View1의 get은 2가 되었지만,
  3. 실제 div에 반영되는 곳은 없습니다.
  4. click은 아까와 같은 화살표함수 객체이므로 결론적으로 div에는 아무런 변화가 없습니다.

바로 앞서 만들었던 예제와 같은 상황이 되는 거죠.
state의 set에 의해 View1자체는 호출대상이 되어 실행되었지만 반환하는 div에는 변화점이 없기 때문에 DOM에 실질적으로 반영되는 것은 없다는 것입니다.

이렇듯 리액트는 state변화에 따라 컴포넌트가 호출되는 단계와 그것으로 실제 반환되는 뷰의 변화가 있는가를 판단하는 단계로 나눠져 작동합니다.

이에 따라 리액트이 최적화 전략은 크게 보면 두 가지로 요약할 수 있습니다.

  1. state를 꼭 필요할 때만 변화시킨다(컴포넌트 호출 최소화)
  2. view의 갱신요소를 독립시켜 증분계산시 최소한의 변화점만 갖도록 한다(렌더링 대상 최소화)

1번은 DOM에 영향을 미치지 않고 2번이 실질적인 영향을 미치기 때문에 둘 사이를 잘 조율하는 것으로 많은 성능 향상을 이룰 수 있습니다.

하지만 리액트 컴포넌트는 순수한 모델이 아니라 뷰인데요?
하나의 컴포넌트가 순수 상태인 state에 따라 그려지도록 뷰를 만들어야 한다능!

결론top

시리즈 첫번째 글에서는 MVVM의 기저를 이루는 모델-렌더 시스템을 이해하기 위한 기초 이론과 리액트에서의 작동을 살펴봤습니다.

  1. 모델-렌더의 개념
  2. 스케쥴러와 옵져버의 관계
  3. 2단계 렌더링 프로세스의 이해
  4. 리액트 state 작동원리
  5. 2단계에 맞춘 리액트 최적화 전략

을 간단히 살펴봤습니다. 다음 글에서는 MVVM을 향해 보다 본격적인 내용을 다뤄보도록 하죠.

결국 리액트는 끝에 쪼끔만 등장했어요!
하루 중 산책하는 시간도 그렇다능!