[react] 리액트 훅 MVVM #2

개요top

이전글에서는 모델-렌더의 개념과 증분렌더링의 원리를 알아봤습니다.
요컨데 setState에 의한 컴포넌트의 호출과 실제 증분에 의한 DOM업데이트는 서로 다른 2단계로 되어있다라는 것이었습니다.
이번 글에서는 훅기반의 컴포넌트가 갖는 문제인 무의미한 기본값생성 문제와 해결방법을 자세히 살펴보며 리액트용 뷰모델의 기반을 다지겠습니다.

이 시리즈는 왜 읽을 수록 아리송한거죠?
리액트 쓰는 법은 이거라고 맘 속에 정한 게 있어서!

함수기반의 컴포넌트 작동top

만약 컴포넌트가 클래스의 인스턴스라면 인스턴스의 다양한 메소드 중 하나를 타이밍에 맞게 호출해주는 방식을 사용할 것입니다.

  1. 따라서 초기화 시점에 생성자가 한 번만 호출될 것이고,
  2. 이후 생명주기에 맞춰 다른 메소드를 호출하겠죠.

이를 통해 한번만 할 작업이라던가 반복적으로 할 작업을 분류하여 각각 메소드에 넣어주면 될 것입니다.
하지만 함수형 컴포넌트는 말하자면 하나의 메소드가 모든 이벤트에서 불리게 되는 구조입니다.
생성시에도, 업데이트시에도, 심지어 삭제 시에도 하나의 함수가 계속 불리게 되는거죠.

  1. 따라서 한 번만 해야하는 작업이나 선언이 반복적으로 일어나고
  2. 한 번만 설정하거나 생성해야하는 객체가 지속적으로 생성됩니다.

리액트 훅은 이 문제를 해결하기 위해 비교값과 수동업데이트라는 정책을 사용합니다.
useEffect, useCallback등은 두번째 인자로 받는 배열 안에 값을 이전 호출시의 값과 비교하여 값을 갱신하거나 호출하며,
useState는 수동으로 직접 setState를 호출하게 하여 내부적인 업데이트를 실시합니다.

하지만 그렇다고 해서 함수 내의 코드로 매번 생성되는 기본값 객체나 함수의 생성 비용이 없어지는 것은 아닙니다.
특히 호출 시마다 리액트 뷰 엘리먼트도 매번 새로 만들어서 반환하고 있는 부하도 무시할 수 없죠.

리액트 개발팀은 생성부하는 별거 아니라는데요?
별거 아닌 거 만들 때만 그렇다는!

useState 기본값 생성 문제top

우선 간단한 뷰컴포넌트를 보죠.

export const View1 =_=>{
  const [get, set] = useState(1);
  const [click] = useState(_=>_=>set(2))
  return (<div onClick={click}>{get}</div>);
};
  1. 위 코드에서 첫 번째 useState의 값은 get, set으로 해체되어 들어옵니다.
  2. click을 통해 set(2)를 실현하면 View1은 다시 호출되고,
  3. 그 때는 get에 2가 들어오게 되죠.

즉 useState(1)의 1은 초기값이 될 뿐이고 이후부터는 set에 의해 변화된 값이 내부의 state에 저장되어 get으로 불려지게 되는거죠.

하지만 그렇게 View1이 다시 호출되었을 때 코드로 존재하고 있는 useState(1)이 실행되는 것은 변함없습니다. 1은 값이기 때문에 부하를 거의 주지 않지만, 만약 객체가 들어있다면 어떻게 될까요?

export const View1 =_=>{
  const [get, set] = useState({title:'hello'});
  const [click] = useState(_=>_=>set({title:'world'))
  return (<div onClick={click}>{get.title}</div>);
};
  1. 위 코드에서는 기본 값에 {title:’hello’} 를 생성하여 넣고 있습니다.
  2. 그리고 클릭하면 새로운 객체인 {title:’world’} 를 넣고 다시 View1이 호출되겠죠.
  3. 그러면 get의 결과는 set한 객체인 {title:’world’}가 들어가지만
  4. 그렇다고 useState({title:’hello’}) 구문에서 {title:’hello’}를 만들지 않은 것은 아닙니다.

즉 최초 한 번만 사용하면 되는 {title:’hello’}는 set을 할 때마다 무의미하게 매번 생성됩니다.
어떻게 하면 이 무의미하게 반복되는 객체의 생성을 막을까요?

  1. useState는 인자로 값 외에도 함수를 받을 수 있는데
  2. 함수가 인자로 오는 경우는 함수 자체가 값이 되는게 아니라 이 함수를 호출하여 기본값을 얻습니다.
  3. 따라서 함수객체를 미리 생성해서 공급한다면
  4. 함수를 실제 호출하는 것은 초기화 시 한 번 뿐이라
  5. 여러번 View1이 호출되어도 생성부하가 없을 것입니다.

이를 코드로 구현하면 다음과 같습니다.

const factory =_=>({title:'hello'});
export const View1 =_=>{
  const [get, set] = useState(factory);
  const [click] = useState(_=>_=>set({title:'world'))
  return (<div onClick={click}>{get.title}</div>);
};

사실 state에 객체가 할당되는 경우 갱신하려면 반드시 새 객체를 넣어야만 하기 때문에 기본 객체를 사용하는 것에 큰 문제는 없습니다.
따라서 객체 state는 다음과 같이 해도 됩니다.

const base = {title:'hello'};
export const View1 =_=>{
  const [get, set] = useState(base);
  const [click] = useState(_=>_=>set({title:'world'}))
  return (<div onClick={click}>{get.title}</div>);
};

하지만 base객체 내부에 복잡한 참조관계 등이 있는 경우 여러 View1이 공유할 때 문제가 생길 수 있으니 경우에 따라 factory를 쓸지 base를 쓸지 잘 판단해야 합니다.
뷰컴포넌트는 기본적으로 클래스처럼 다수의 인스턴스를 만들어 낼 수 있는 그릇이므로 언제나 인스턴스 컨텍스트를 기본으로 생각하는 편이 좋습니다. 판단이 모호할 땐 항상 factory형태를 권장합니다.

근데 그 밑에 click은 좀 다른 사정이 있습니다. click을 위한 state는

_=>set({title:'world'})

라는 함수를 반환하기 위한 factory함수입니다. 헌데 이건 외부로 뺄 수 없습니다.
즉 View1이 호출될 때마다 매번 이 factory함수가 생성되는 것입니다. 사실 이 factory는 최초 값을 할당할 때 외엔 필요없는데도 말이죠. 이걸 해소하는 간단한 방법은 if 트랩을 거는 것입니다.

const base = {title:'hello'};
export const View1 =_=>{
  const [get, set] = useState(base);
  let [click, clickSet] = useState(undefined);
  if(!click) clickSet(click =_=>set({title:'world'}));
  return (<div onClick={click}>{get.title}</div>);
};

이 간단한 if트랩은 click을 위한 함수를 한번만 생성하게 하는 장치가 됩니다.

  1. 우선 useState(undefined)로 초기값이 설정됩니다.
  2. click은 undefined가 되고 if안에 들어오게 되죠.
  3. 이때 click은 _=>set({title:’world’}) 가 되고
  4. 동시에 clickSet을 호출해 상태를 갱신합니다.
  5. 최초 렌더링시에도 3번에서 click에는 바른 기본값 함수가 들어가 있어 그것에 맞춰 렌더링되며
  6. 4번에 의해 다시 View1이 호출되면
  7. click으로 얻은 값은 3번과 동일한 함수이므로 if안에 들어오지 않게 되며
  8. 그렇게 두번째로 생성된 div안의 onClick은 5번과 같은 함수이므로 렌더링은 일어나지 않습니다.

이전 포스트에서 다뤘던 View1이 불리는 상황과 증분 렌더링이 되는 상황은 다른 것이고 그걸 이용하는 손쉬운 방법인 셈입니다.

하지만 이런 if게이트를 매번 작성하는 것은 낭비이므로 커스텀 훅으로 빼는 게 바람직할 것입니다.
여기서 고려할 점이 애당초 이걸 외부로 빼지 못한 이유가 set을 클로저로 참고하기 때문이죠.

이러한 클로저 요소를 죄다 인자로 받아들여 처리해주는 커스텀 훅을 짜보면 다음과 같을 것입니다.

export const useLiteState = (factory, ...arg)=>{
  let [g, s] = useState(undefined);
  if(g === undefined) s(g = factory(...arg));  
  return g;
};

이 훅은 다양한 관련 요소를 arg로 받아들이고 factory가 이를 이용하여 state를 설정하면서 기본값을 반환하도록 합니다.
이제 앞의 코드를 useLiteState기반으로 변경하면 다음과 같을 것입니다.

const base = {title:'hello'};
const clickFactory =(set)=>_=>set({title:'world'});
export const View1 =_=>{
  const [get, set] = useState(base);
  const click = useLiteState(clickFactory, set);
  return (<div onClick={click}>{get.title}</div>);
};

이제 View1을 아무리 불러도 기본값을 위한 객체생성 없이 click을 얻을 수 있게 되었습니다.
새로 작성된 clickFactory는 인자로 set을 받아 이를 이용한 클릭리스너함수를 생성하여 반환하는 팩토리입니다.
useLiteState안에 if트랩이 내장되어있으므로 factory함수는 한번만 호출되고 최초에도 click값을 제대로 반환되어 올 것입니다.

이렇게 해도 매번 (<div..)부분은 새로 만드는데요?
jsx쪽의 최적화는 지금 안할거라능!

state의 의미top

결국 리액트에게 state는 무엇인가 생각해볼 필요가 있습니다.
리액트 컴포넌트란 자신의 state가 갱신될 때마다 호출되는 함수로 이해할 수 있습니다.
즉 컴포넌트를 새로운 데이터로 다시 그리고 싶다면 반드시 state를 변경하는 것으로 실현해야합니다.
이는 모델-렌더의 기초적인 개념에 부합합니다. 모델이 되는 state를 변경하여 뷰를 담당하는 컴포넌트를 렌더링하는 것이죠.
단지 리액트가 혼란한 점은 이 컴포넌트 안에 순수 상태인 state와 뷰의 렌더러가 같이 들어있다는 점입니다.

그렇다보니 특히 훅에서는 모델을 초기화하거나 갱신하는 부분과 뷰를 렌더링하는 부분이 혼재된 로직을 짤 수 밖에 없고,
모델을 관리하는 호출주기가 렌더링을 일으켜야하는 호출주기가 불일치 하여 이를 조정할 수 있는 로직이 추가로 필요하게 됩니다.
이게 대표적으로 위에서 다뤘던 useLiteState같은 게 필요해지는 이유죠.

더 나아가 단순히 함수에 불과한 리액트훅 컴포넌트는 내부에 상태를 갖을 수 없습니다.
따라서 리액트가 이 함수 호출시 외부에 바인딩 해주는 상태에 인덱스로 접근되는 useState를 공개해주고 있습니다. 이를 리액트 입장에서 바라보면 다음과 같이 작동할 것입니다. 조금 복잡한 의사코드이므로 어려울 수 있습니다 ^^

//모든 컴포넌트별 state를 보관하는 저장소
const states = new WeakMap;

//이번에 업데이트할 컴포넌트 셋
const updatedComponents = new Set;

//현재 호출된 컴포넌트와 useState를 호출한 인덱스
let currentComp, stateIndex;

//스케쥴러
const renderLoop =_=>{ 

  //업데이트될 컴포넌트를 호출함
  updatedComponents.forEach(comp=>{

    //현재 호출될 컴포넌트를 기준으로 잡고 인덱스도 초기화 함.
    currentComp = comp;
    stateIndex = 0;

    //컴포넌트를 호출함.
    const view = comp();

    //결과로 받은 view객체의 이전 상태와 비교하여 증분렌더링
  });

  //다시 초기화해 줌.
  updatedComponents.clear();
};

여기까지의 구조를 보면

  1. updatedComponents에 이번 프레임에서 호출되어야할 컴포넌트를 모아둡니다.
  2. 스케쥴러에서 updatedComponents를 순회하면서 호출한 뒤 비워줍니다.
  3. 루프 내부를 보면 외부 상태인 currentComp에 이번에 호출할 컴포넌트를 지정해줍니다.
  4. 이걸로 state들이 가리킬 대상을 정할 수 있고 함수 내에서 useState를 부를 때 그 컴포넌트 전용 속성들이 불러지게 됩니다.
  5. 마찬가지로 stateIndex를 초기화하는데, 이 인덱스는 함수 내부에서 useState를 부를 때마다 하나씩 증가시켜서
  6. 현재 컴포넌트와 연결된 state 중에서도 인덱스 순서에 맞춘 걸 가져올 수 있게 합니다.

결국 useState는 comp()가 호출되어 실행되는 사이에 사용됩니다. comp()에 들어가기 전에 외부에서 상태를 지정하는 방법으로 comp()내부의 useState에서는 별다른 설정없이 자기에게 맞는 state를 가져올 수 있는 거죠.
그럼 useState도 개념 상의 구현을 해보죠.

export useState =def=>{

  //states맵에 현 컴포넌트 state가 없다면 배열생성
  if(!states.has(currentComp)) states.set(currentComp, []);

  //현 컴포넌트용 state배열을 가져옴
  const state = states.get(currentComp);

  //만약 배열 안에 호출 순번에 맞는 데이터가 존재하지 않는다면
  if(!state[stateIndex]){

    //state 구조체 생성
    const v = {value:def}; //이 때만 기본값을 사용함!
    const comp = currentComp;

    //set함수를 넣어줌
    v.set =newV=>{
      //구조체의 값을 새값으로 바꾸고
      v.value = newV;
      //현 컴포넌트를 업데이트 대상에 넣음
      updatedComponents.add(comp);
    };

    //이렇게 value, set이 설정된 구조체를 인덱스에 맞게 설정함
    state[stateIndex] = v;
  }

  //인덱스에 맞는 구조체를 가져오면서 인덱스를 하나 증가시킨다.
  const curr = state[stateIndex++];

  //구조체로부터 value, set을 얻어 배열로 반환한다.
  return [curr.value, curr.set];
};

주석으로 이미 자세히 설명되어서 딱히 추가할 내용은 없습니다만,
1. 개념적으로 useState훅이 어떻게 함수 내에서 호출만 한건데 상태를 유지하는지,
2. 더 나아가 setState가 어떤 방식으로 컴포넌트의 재호출을 일으키는지 간단히 이해할 수 있습니다.

실제 리액트는 state뿐만 아니라 이로부터 파생되는 prop도 있고 자식 컴포넌트를 연쇄로 호출하는 등 보다 복잡한 기능이 많이 있지만 단편적이라도 좀더 useState의 원리를 알 수 있는 예로 보시면 될 듯합니다.

컴포넌트에서 매번 같은 순서로 useState를 부를 걸 어떻게 알아요?
그래서 순서가 깨질까봐 if같은 분기 내부에서 use를 못하게 하는 거라능!

setState의 활용top

state의 구조와 컴포넌트의 호출관계, 더 나아가 증분 렌더링까지의 여정을 이해했다면 이것을 전재로 한 단계 더 MVVM의 기반을 잡을 수 있습니다.
위에서 좀 더 상세하게 살펴본 useState의 특징, 특히 반환되는 setter의 특징을 보면, 최초 state구조체가 만들어질 때 생성된 후부터는 똑같은 함수를 반환해주고 있습니다.
이미 setter가 생성되는 순간 클로저를 통해 필요한 정보가 확정되어있으므로 그 이후부터는 똑같은 setter함수를 반환하는 것이죠.

이건 뭐랄까 프라미스를 깊이 쓰게 되면 resolve를 외부에 노출하여 다양한 예외패턴을 처리하는 것처럼 setter만 외부에 노출한다면 컴포넌트의 상태와 렌더링을 갱신시킬 수 있다는 의미입니다.

MVVM에서는 뷰를 대신하여 뷰모델을 컨트롤하는 것으로 뷰가 참조하는 상태를 변경하며 이 변경이 자동으로 뷰에 반영되도록 바인더를 사용합니다.
리액트는 이러한 MVVM의 바인더라고 볼 수 있습니다.

더 나아가 MS의 MVVM에서는 이벤트 리스너 등의 유저 인터렉션을 처리하는 경우 커맨드패턴을 기반으로 하는 구조를 따로 구성합니다.
하지만 개별 커맨드를 하나하나 코드로 관리하는 것은 까다롭기 때문에 뷰가 참고할 커맨드를 모아서 일종의 컨트롤러를 구성할 수 있을 것입니다.

즉 “리액트 컴포넌트 + 컨트롤러 + 뷰모델” 구조로 재편하여 리액트 컴포넌트는 오직 바인더로서 컨트롤러와 뷰모델의 변화에 그대로 그려지기만 하는 형태를 취할 수 있는 것이죠.

이러한 청사진의 기반이 될 첫 번째 관문이 state와 state의 setter를 활용하는 것입니다.
이미 복잡한 state의 초기화를 간단히 정리한 useLiteState 훅을 만들어봤습니다. 이를 좀더 활용하여 state의 setter를 표준적으로 관리할 수 있는 방법을 생각해보죠.
(여기에서부터는 타입스크립트를 이용해 설명하겠습니다. 아무래도 프로토콜을 형으로 정의하는 편이 편리하기 때문이죠)

export interface StateSetter {stateSetter: any;}

export const setState = (origin:StateSetter, newone:StateSetter)=>origin.stateSetter && (newone.stateSetter = origin.setState)(newone);

export function useStateSetter<T extends StateSetter, R>(factory:()=>T):T{
    const [g, s] = useState<T>(factory);
    g.stateSetter = s;
    return g;
}

우선 StateSetter는 매우 간단한 인터페이스로 내부에는 state의 setter를 잡아둘 stateSetter라는 멤버를 정의하는 게 전부입니다.
간단하지만 implements StateSetter 한 클래스는 반드시 stateSetter라는 속성이 있다는 보장이 있으므로 이를 활용할 수 있습니다.
미래의 확장을 위해 추상클래스가 아닌 인터페이스로 정의했으므로 어쩔 수 없이 메소드 대신 외부에 이를 활용하는 함수를 제공합니다.
추상클래스로 만들면 일체감이 더 있겠지만, extends를 제한하게 되므로 이쪽 디자인을 선택했습니다.

setState함수의 구성을 보면 원본 origin에 포함된 stateSetter를 새로운 값인 newone에게 옮겨주면서 곧장 newone을 stateSetter에 넣어 컴포넌트를 갱신합니다.

마지막으로 useStateSetter훅은 팩토리 함수를 받아 항상 얻어진 객체에 setState를 실제 state의 stateSetter로 설정해주는 간단한 훅입니다.

이 몇 줄 안되는 간단한 장치로 StateSetter를 implements하는 인스턴스는 useStateSetter에 팩토리를 던져 받을 수 있고,
반대로 그 인스턴스 내부에서는 setState를 이용해 상태를 손쉽게 갱신할 수 있습니다.

이제 이를 활용하여 본격적인 뷰모델을 만들어봅시다.

state의 세터는 항상 똑같으니까 stateSetter의 설정은 한 번만 해도 되는거 아닌가요?
그럼 if 트랩을 또 걸어야하니까!

클래스의 state와 useState의 차이점top

우선 뷰모델을 도입하기 전의 상태를 만들어보고 생각해보죠. useLiteState로 가볍게 활용하겠습니다.

let titleCount = 1, contentsCount = 1;

const titleFactory =setTitle=>setTitle(`테스트${++titleCount}`);
const contentsFactory =setContents=>setContents(`본문${++contentsCount}`);

const View1 =()=>{

  const [title, setTitle] = useState("테스트1");
  const [contents, setContents] = useState("본문1");

  const changeTitle = useLiteState(titleFactory, setTitle);
  const changeContents = useLiteState(contentsFactory, setContents);

  return (<>
    <h2 onClick={changeTitle}>{title}</h2>
    <div onClick={changeContents }>{contents}</h2>
  </>);
};

이 예제는 useState가 기존 클래스컴포넌트의 state와 어떤 개념적 차이가 있는지 보여줍니다.
클래스 컴포넌트의 state는 하나만 갖을 수 있기 때문에 복수의 상태를 관리하려면 반드시 state에 객체를 넣어야했습니다.

하지만 useState는 몇 번이라도 호출할 수 있기 때문에 굳이 객체를 값을 넣을 이유는 없습니다.
따라서 기본값 생성부하를 크게 걱정하지 않고 useState를 연발하는 것으로 해결할 수 있죠.

컴포넌트 훅 내부에서 상태관리를 다 하려고 한다면, 객체를 사용하지 않고 useState의 복수 호출 조합으로 상태를 관리하는 편이 낫다고 생각합니다.
단지 이렇게 되면 컴포넌트 내부에 모델파트를 관리하는 복잡한 로직이 들어가게 됩니다. 이런 면에서 엔터프라이즈 개발 입장에서는 모델파트의 코드량이 상당하여 이 부분을 분리시킬 때는 단일 객체에 의한 state가 유리할 수 있습니다.

그럼 원래부터 click리스너 같은 객체 타입은 어떻게 해요?
그래서 useLiteState로 if트랩패턴을 소개한거라능!

뷰모델의 분리top

외부에 state관리를 일임하기 위해 뷰모델을 만들어볼 건데, 이미 위에서 객체 state의 초기화 문제는 충분히 해결되었으므로 객체로 된 state를 가정하여 외부 모델로 빼내보죠.
(앞 서 설명한 useStateSetter을 사용하게 되므로 잊으셨다면 다시 한번 위에서 useStateSetter부분을 봐주세요)

export class View1Model implements StateSetter{

  //커스텀 훅
  private static FACTORY =()=>new ViewModel();
  static use =()=>useStateSetter(View1Model.FACTORY);

  //state갱신 setter
  stateSetter:any;

  //실데이터
  data:any;

  constructor(data?:any){
    //data가 넘어온 경우는 할당하고 아니면 생성
    this.data = data ?? {
      titleCount:1,
      contentsCount:1,
      title:"테스트",
      contents:"본문"
    };
  }

  //title용 게터
  get title(){return `${this.data.title}${this.data.titleCount}`;}

  //카운트를 올리는 경우
  addTitleCount =()=>{
    this.data.titleCount++;

    //여기서 state를 갱신한다.
    setState(new View1Model(this.data));
  }

  //상동
  get contents(){return `${this.data.contents}${this.data.contentsCount}`;}
  addContentsCount = ()=>{
    this.data.contentsCount++;
    setState(new View1Model(this.data));
  }
}

우선 모델이 될 클래스부터 살펴보겠습니다.
1. 이 모델은 View1Model.use()를 통해 얻을 수 있도록 static훅을 제공합니다.
2. 이 때 앞서 정의했던 useStateSetter를 활용하므로 이미 이 인스턴스의 stateSetter에는 실제 state의 세터가 들어있을 것입니다.
3. state는 ===로 비교하기 때문에 객체참조값만 달라지면 됩니다. 따라서 내부에 data는 똑같아도 상관없죠.
4. 값을 바꿀 때마다 굳이 매번 복제본을 만들 필요없이 가볍게 껍데기만 교체하는 방법을 씁니다.
5. title값은 편리한 getter를 제공해주고
6. 카운트를 올리는 메소드에서는 내부적으로 setState함수를 활용하여 변경된 값을 갖는 새 객체를 할당하여 갱신합니다.

이제 View1에 포함되었던 복잡한 데이터 로직과 state갱신을 완전히 외부 모델에 위임할 수 있게 되었습니다. 재작성된 View1은 다음과 같습니다.

const View1 =()=>{
  const model = View1Model.use();
  return (<>
    <h2 onClick={model.addTitleCount}>{model.title}</h2>
    <div onClick={model.addCountentsCount}>{model.contents}</h2>
  </>);
};

모델과 관련된 모든 로직은 깨끗하게 모델클래스로 이전되고 View1컴포넌트에는 오직 그 모델을 어떻게 렌더링할지만 남게 됩니다.
또한 추가적인 기본 값 생성부하나 쓸데없는 증분 렌더링을 억제하고 있습니다. 해체를 활용하면 보다 간단하게 기술되겠죠.

const View1 =()=>{
  const {title, addTitleCount, contents, addCountentsCount} = View1Model.use();
  return (<>
    <h2 onClick={addTitleCount}>{title}</h2>
    <div onClick={addCountentsCount}>{contents}</h2>
  </>);
};
1. 꼭 모델을 컴포넌트 밖에 빼야하나요?
코드가 길면 관리하기 힘드니 목적별로 나눠서 짧게 관리하는 거라능!
2. 불변성으로 상태를 관리하라고 들었는데요?
성능과 동시성에 대한 안정성은 교환적인데 격리만 잘 되어 있다면 싱글쓰레드에서는 큰 문제 없다능!
3. 상태 복원 같은 건요?
그게 필요한 모델에만 커맨드패턴을 적용하면 된다능!

행위를 위한 커맨드 도입top

이제 컴포넌트는 순수하게 렌더링만 담당하고 모델은 모델클래스에서 담당할 수 있게 되었습니다.
이는 모델-렌더의 기초적인 구조가 됩니다. 하지만 MVVM은 모델을 조작하는 행위를 커맨드로 분리하여 관리합니다.
사실 현재 onClick에 할당되어있는 리스너는 직접 모델을 조작하는 모델의 메소드입니다만 중간에 이러한 액션을 관리하는 커맨드 레이어를 둠으로서 제어를 한군데서 관리할 수 있습니다.

예를 들어 console에 행위를 로그로 보여주면서 모델을 갱신하려고 한다면 어찌해야할까요? 이대로는 모델클래스에 모델 자체에 대한 변경이 아닌 코드가 삽입됩니다.
이렇듯 행위는 모델의 조작 외에도 네트웍처리 등 다양한 비모델적인 일을 포함합니다. 따라서 행위부분은 직접 모델클래스에 기술하지 않고 이것만 따로 전담하는 구조가 훨씬 유연합니다.

여기서는 상징적인 의미(WPF의 ^^)로 커맨드 클래스라 명명하겠습니다. 개별 행위를 일일히 정의하기 보다 View1의 행위 전체를 묶어서 하나의 커맨드 클래스가 처리하도록 하려는 것이죠.

현재 필요한 행위는 h2의 클릭과 div의 클릭이므로 이를 처리할 커맨드를 만들어보죠.

export class View1Command{
  private static factory =()=>new View1Command();
  static use =()=>{
    const [command] = useState(View1Comment.factory);
    command.model = View1Model.use();
    return command;
  }

  model?:View1Commend;

  h2Click =()=>{
    console.log("h2Click");
    this.model?.addTitleCount();
  };

  divClick =()=>{
    console.log("divClick");
    this.model?.addContentsCount();
  };
}

여기서도 마찬가지로 static use훅을 제공합니다. 단지 커맨드입장에서는 반드시 모델을 알아야 합니다. 따라서 커맨드가 모델을 만들어내고 그 모델의 참조를 갖게 됩니다. 모델이 갱신되어도 매번 갱신되기 때문에 커맨드는 항상 현재 모델을 알고 있게 됩니다.
공개된 각 행위용 메소드는 모델을 직접 다루는 코드를 캡슐화합니다. 이제 이 커맨드를 사용하도록 View1을 바꿔보죠.

const View1 =()=>{
  const {model:{title, contents}, h2Click, divClick} = View1Command.use();
  return (<>
    <h2 onClick={h2Click}>{title}</h2>
    <div onClick={divClick}>{contents}</h2>
  </>);
};

이제 행위를 담당하는 커맨드와 데이터를 담당하는 모델, 그리고 렌더링에 바인딩될 뷰가 완전히 분리되었습니다.

모델에 다 넣어도 될거 같아요!
아까도 말했지만 코드가 길어지면 관리가 힘들어서 목적을 기준으로 더 나눌 수 있게 한거라능!

결론top

MVVM의 골조가 되는 여러가지가 있는데 사실 이 패턴이 성립하려면 모델변화가 뷰에 자동으로 변화되는 바인더라는 시스템의 지원이 꼭 필요합니다.
아니면 모델의 변경에 대해 일일히 뷰에 반영해주는 로직이 관여하기 때문에 모델-렌더가 제어역전되지 않기 때문이죠.
리액트는 바인더로서 굉장히 훌륭한 기능을 제공해주는 라이브러리입니다.
당연히 이렇기 때문에 리액트가 바라봐야하는 상태 또한 리액트 안에서 제공합니다. 그게 state인거죠.
그리고 그 state는 단지 증분렌더링의 기준일 뿐이지 도메인 상의 설계 상의 모델로 의미를 갖게 하는 건 오롯히 개발자의 몫입니다.

MVVM은 인스턴스화 되는 각각의 뷰컴포넌트에 인스턴스로 매칭되는 뷰모델과 커맨드를 제공하게 됩니다.
실제 뷰모델이 어떤 모델을 바라볼 지, 커맨드가 어떤 부가작업을 감당하게 되는지는 구현에 달려있다고 할 수 있죠.

이번 포스팅에서는 리액트를 바인더로 사용하면서 리액트의 특성과 state구조에 맞춰 외부에서 뷰모델과 커맨드를 정의하는 예를 살펴봤습니다.

다음 포스팅에서는 싱글톤 개념을 가진 컴포넌트와 인스턴스 개념을 가진 컴포넌트의 차이점, 이를 통제하기 위한 구조와 리액트contextAPI와의 관계등을 더 깊이 살펴봅니다. 더불어 useEffect등 추가적인 리액트의 상태지원 시스템과 커맨드, 뷰모델이 어떻게 연동할지와 비동기 통신을 비롯한 다양한 io처리를 어떻게 할지도 짬짬히 다뤄보도록 하죠.

아직도 쓸게 남았나요?
이제 시작하려고 밑밥 깐 거라능!