[react] 리액트 훅 MVVM #4

개요top

1부에서는 모델-렌더의 기초 개념을
2부에서는 state를 기반으로 하는 뷰모델과 커맨드의 구조를 만들어봤습니다.
이번 포스팅은 3부에 이어 to-do앱을 만들어갑니다. 3부를 안보신 분들은 꼭 3부부터 보세요.

3부부터 꼭 봐야하나요?
생각해보니 4부부터 보고 3부봐도 큰 문제는 없을거라능!

인스턴스를 사용하는 컴포넌트top

컴포넌트가 유일한 객체로 작동하지 않고 여러개의 인스턴스로 작동하는 경우는 언제일까요?
쉽게 생각해볼 수 있는 건 컴포넌트가 props에만 의존하는 경우가 있습니다. 이렇게 구성된 컴포넌트는 자신의 props 스펙에 맞춰 값만 들어오면 정상적으로 작동하게 되므로 어디에서나 재활용할 수 있습니다.

예를들어 여러 게시판에서 공통으로 이용할 댓글을 처리해주는 컴포넌트를 생각해보죠.

export Comment = (props:CommentModel)=>{
  return (<>
    <CommentInput vm={props}/>
    {props.list.map(v=><CommentItem vm={v}/>)}
  </>);
};

이 컴포넌트는 완전하게 props에만 의존하고 있습니다. 즉 어떤 경우에라도 CommentModel의 규격에 맞게 값을 내려준다면 재활용가능한 컴포넌트인 것이죠.
이렇게 작성된 컴포넌트는 한군데가 아니라 여러곳에서 재활용될 수 있습니다.

마찬가지로 이런 props로 브릿지할 수 있다면 어떤 컴포넌트도 인스턴스 컨텍스트로 사용할 수 있습니다.

잠시 프로젝트 완성시의 그림을 다시 확인하고 가겠습니다.

그리고 이에 맞게 to-do앱의 App은 다음과 같이 구성되어있습니다.

export const App =()=>{
  Category.use();
  return (<>
    <h1>Todos</h1>
    <CategoryView/>
    <TodoView/>
  </>);
};

여기서 TodoView가 모든 카테고리별 할 일 목록을 처리한다면 이것은 TodoView를 싱글톤으로 사용하는 것입니다. 그것도 나쁜 구성은 아닙니다. TodoView 컴포넌트 자체는 싱글톤으로 두고 카테고리별 할 일을 모델 수준에서만 분리해서 사용하는 전략이죠. 많은 경우 이쪽을 택하게 됩니다. flux철학은 그 방식으로 자연스레 유도합니다. 하지만 이번 포스팅에서 인스턴스 컨텍스트를 사용하는 방법을 배우기 위해 이 방법을 택하지 않을 것입니다.

만약 TodoView자체를 카테고리별 컴포넌트를 각각의 인스턴스로 두고 싶다면 어떻게 해야할까요?

개념 상 이를 실현하려면 TodoView는 실제 할 일 목록을 보여주는 컴포넌트가 아니라 할 일 목록 컴포넌트를 관리하는 라우터로 만들면 됩니다.
이 패턴은 언제나 응용할 수 있는 것으로, 특정 컴포넌트를 인스턴스화하려면 해당 컴포넌트를 라우터로 바꾸고 그 안에서 인스턴스별 컴포넌트로 분기해준다 라는 전략입니다.

실제로 TodoView를 실제 목록을 그려주는 TodoList의 라우터로 사용해보죠.

그럼 컴포넌트는 props만 써야하나요?
state는 상황마다 다르지만, context를 참조하면 재활용이 정말 힘들다능!

다른 컴포넌트를 라우팅해주는 컴포넌트top

TodoView의 구성은 매우 간단합니다.

export const TodoView = ()=>{
  return (<section>{TodoListRouter.use()}</section>);
};

실제 라우터역할을 수행하는 TodoListRouter가 상황에 맞는 뷰를 넘겨주는 방식입니다. 헌데 왜 use()를 사용할까요?
이를 이해하려면 라우터역할을 하는 저 뷰의 상태에 대해 자세히 생각해볼 필요가 있습니다. 지금 그리게 되는 TodoView는 to-do앱에서 우측을 담당하고 있습니다. 그러면 이 우측 업데이트되는 경우는 언제일까요?

  1. 좌측에서 다른 카테고리를 골랐을 때
  2. 우측 할 일 목록에서 목록이 추가, 삭제 등의 변화가 생겼을 때

만약 라우터가 카테고리별로 다 다른 뷰를 쓴다면 뷰 내부에서 상태를 관리하는 것으로 충분합니다. 이 원리를 리액트라우터가 사용합니다. 그래서 리액트라우터에서는 라우팅 테이블에 맞춰 다른 뷰를 넣어줘야합니다.
하지만 카테고리가 다를지라도 같은 뷰에 데이터만 다르게 쓴다면 어떨까요?
리액트는 결국 뷰컴포넌트가 반환하는 VDOM의 증분비교만 하니 업데이트되지 않거나 기존에 다른 카테고리가 그린 것과 병합되어버립니다.

따라서 가장 안전한 방법은 TodoView수준에서 오른쪽 전체를 다시 그리게 하는 것입니다.

여기서 뷰라우팅의 기본적인 전략을 정리해볼 수 있습니다.
1. 라우팅된 뷰컴포넌트가 전부 다른 컴포넌트를 사용한다면 각 뷰에게 업데이트의 책임을 넘긴다.
2. 라우팅 결과로 같은 뷰컴포넌트가 두 개 이상 등장한다면 라우터 레벨의 업데이트를 한다.

결론적으로 2번은 항상 문제가 없긴 합니다만, 라우팅에 참가하는 모든 뷰컴포넌트가 라우터를 구체적으로 알아야 한다는 점이 약점입니다.
디커플링 관점에서는 1번이 좋지만 리액트의 증분업데이트 때문에 절대로 같은 뷰컴포넌트가 두 번 나오면 안된다는 제약이 있죠.
이 예제에서는 모든 할 일 목록이 같은 뷰컴포넌트를 사용할 예정이므로 1번 방법을 사용하겠습니다.

하지만 TodoListRouter는 state와 연결되어 리액트에게 업데이트를 일으키는 트리거 역할을 할 뿐 기본적으로는 싱글톤입니다. 따라서 클래스의 작성은 전부 싱글톤으로 할 수 있습니다.
게다가 state의 갱신이라고는 해도 컴포넌트를 재호출하기 위한 목적이므로 최소한의 값 변화만 일으키면 됩니다. 굉장히 무성의한 state업데이트를 작성해보죠 ^^

export class TodoListRouter{

  //라우팅 테이블
  private static readonly map = new Map<string, TodoListCommand>();

  //단조 증가하는 업데이터
  private static update = 0;

  //state의 세터를 잡아둠
  private static setState:any;

  //외부에서 라우터를 업데이트하게 공개된 메소드. update를 증가시킴.
  static flush(){TodoListRouter.setState(++TodoListRouter.update);}

  static use(){

    //setState만 static에 잡아둔다.    
    TodoListRouter.setState = useState(TodoListRouter.update)[1];

    //현재 카테고리값이 라우터키가 됨.
    const curr = Category.current;
    if(!curr) return (<></>);

    const {map} = TodoListRouter;
    //라우팅 테이블에 없는 경우 리스트용 커맨드를 생성한다.
    if(!map.has(curr)) map.set(curr, new TodoListCommand(curr));

    //해당 커맨드로부터 뷰를 인수하여 반환한다.
    return map.get(curr)!!.view;
  }
}

TodoListRouter자체는 싱글톤이므로 인스턴스는 필요없습니다. 전부 static레벨로 이뤄지고 update를 하나씩 증가시켜가며 flush()를 호출당할 때마다 우측 전체를 다시 그리게 합니다.
라우팅 테이블은 간단히 없으면 TodoListCommand를 만들고 그 커맨드로부터 뷰를 가져와 뿌립니다.
향후 카테고리의 타입이나 특성에 맞게 다른 뷰를 뿌리고 싶다면 라우팅 테이블 부분만 개선하면 될 것입니다.

이렇게 의미없게 state를 사용하면 잘못된거 아닌가요?
state란 그저 리액트에게 이 컴포넌트를 다시 호출하라는 트리거 일뿐이라능!

할 일 목록 그리기top

할 일 목록의 실질적인 주인공은 커맨드 객체로 커맨드 객체가 대상이 되는 뷰를 알선하고 뷰가 소비할 뷰모델을 생성하여 매핑해줍니다.
이 얘기인 즉슨 인스턴스 컨텍스트로 생성되는 뷰컴포넌트가 완전히 props에만 의존적이란 의미입니다. 먼저 커맨드가 뿌릴 뷰컴포넌트부터 살펴보죠.

export const TodoListView = ({command:{cat, keydown, remove, toggle, vm:{items}}}) =>(<>
  <h2>{cat}</h2>
  <input onKeyDown={keydown}/>
  <ul>
    {[...items].map((item, i) =>{
      const deco = item.isComplete ? "text-decoration:line-through" : ""
      return (<li key={i}>
        <span onClick={_=>toggle(item)} STYLE={deco}>{item.title}</span>
        <span onClick={_=>remove(item)}>x</span>
      </li>);
    })}
 </ul>
</>);

이 뷰컴포넌트는 완전히 props로 넘어온 command에게만 의존하여 그려지는 뷰입니다.
command로부터 행동에 관한 keydown, remove, toggle등을 받아들이고, 데이터와 관련되어 cat과 뷰모델 내부의 items를 받아와 타이틀과 리스트를 그려내고 있습니다.

이렇듯 뷰가 props에만 종속적인 경우 완전한 인스턴스로 재활용하기 쉽습니다. 사실 상 상태나 행위를 완전히 외부에서 통제할 수 있기 때문이죠.
이 뷰에 맞춰 command를 구현해보죠.

//할 일 하나의 형식
export type Item = {title:string, isComplete:boolean};

export class TodoListCommand{

  //외부에서 주입받은 카테고리
  cat = "";
  //실제 할일 목록
  vm = new TodoList();
    
  constructor(cat:string){
    this.cat = cat; 
  }

  //키다운 이벤트 엔터치면 모델에 추가한다.
  keydown = ({target, keyCode}:KeyboardEvent) => {
    const input = target as HTMLInputElement;
    if (!input.value || keyCode !== 13) return;
    this.vm.add(input.value);
    input.value = "";
  };
  //삭제와 토클 행위
  remove = (item:Item)=>this.vm.remove(item);
  toggle = (item:Item)=>this.vm.toggle(item);
    
  //라우터로부터 요청받은 뷰를 반환한다.
  get view(){

    //뷰에게 props를 통해 커맨드를 내려줌
    return (<TodoListView command={this}/>);
  }
}

커맨드 역시 간단합니다. 이미 뷰컴포넌트의 생명주기는 죄다 라우터에게 맡겼으므로 그 하위에 있는 구성물인 커맨드나 뷰컴포넌트는 state를 관리하지 않고 평범한 클래스로 작성하면 됩니다.
커맨드는 원래 할 일 그대로 행위만 기술하고 실제 결과는 전부 뷰모델에 반영합니다. 그러므로 마지막 조각인 뷰모델을 살펴보죠.

export class TodoList{
  items = new Set<Item>();
  add =(title:string)=>{
    this.items.add({title, isComplete:false});
    TodoListRouter.flush();
  };
  remove =(todo:Item)=>{
    this.items.delete(todo);
    TodoListRouter.flush();
  };
  toggle =(todo:Item)=>{
    todo.isComplete = !todo.isComplete;
    TodoListRouter.flush();
  };
}

뷰모델 자체는 특이할 것은 없으나 state를 갱신할 때 라우터의 flush를 이용한다는 점이 다릅니다. 즉 뷰모델의 상태가 바뀌면 특정 하위 뷰컴포넌트를 호출하지 않고 우측 라우터 구간 전체를 갱신하려는 것이죠.
이 간단한 장치로 라우터 내의 모든 모델의 업데이트를 리액트가 알아차리게 할 수 있습니다.

3부까지는 뷰컴포넌트에서 command를 만들었는데 지금은 반대로 command가 뷰컴포넌트를 만들어요!
정확하게는 command가 자신의 뷰컴포넌트를 알고 있는거라능!
이럴거면 혼동이 되지 않게 무조건 command에게 view를 요청하는 방식으로 통일할 수는 없나요?
가능하지만 복잡한 추상층을 필요해서 이 예제에서는 구현 안할거라능!

결론top

이번에 등장한 코드 전체는 다음과 같습니다.

//라우터
export class TodoListRouter{
  private static readonly map = new Map<string, TodoListCommand>();
  private static update = 0;
  private static setState:any;
  static flush(){TodoListRouter.setState(++TodoListRouter.update);}
  static use(){
    TodoListRouter.setState = useState(TodoListRouter.update)[1];
    const curr = Category.current;
    if(!curr) return (<></>);
    const {map} = TodoListRouter;
    if(!map.has(curr)) map.set(curr, new TodoListCommand(curr));
    return map.get(curr)!!.view;
  }
}

//아이템형식
export type Item = {title:string, isComplete:boolean};

//커맨드
export class TodoListCommand{
  cat = "";
  vm = new TodoList();
  constructor(cat:string){
    this.cat = cat; 
  }
  keydown = ({target, keyCode}:KeyboardEvent) => {
    const input = target as HTMLInputElement;
    if (!input.value || keyCode !== 13) return;
    this.vm.add(input.value);
    input.value = "";
  };
  remove = (item:Item)=>this.vm.remove(item);
  toggle = (item:Item)=>this.vm.toggle(item);
  get view(){
    return (<TodoListView command={this}/>);
  }
}

//뷰모델
export class TodoList{
  items = new Set<Item>();
  add =(title:string)=>{
    this.items.add({title, isComplete:false});
    TodoListRouter.flush();
  };
  remove =(todo:Item)=>{
    this.items.delete(todo);
    TodoListRouter.flush();
  };
  toggle =(todo:Item)=>{
    todo.isComplete = !todo.isComplete;
    TodoListRouter.flush();
  };
}

//뷰컴포넌트
export const TodoListView = ({command:{
  cat, keydown, remove, toggle, vm:{items}
}}) =>(<>
  <h2>{cat}</h2>
  <input onKeyDown={keydown}/>
  <ul>
    {[...items].map((item, i) =>{
      const deco = item.isComplete ? "text-decoration:line-through" : ""
      return (<li key={i}>
        <span onClick={_=>toggle(item)} STYLE={deco}>{item.title}</span>
        <span onClick={_=>remove(item)}>x</span>
      </li>);
    })}
 </ul>
</>);

우측도 좌측에 이어 완전히 뷰모델-커맨드-뷰로 이쁘게 나눠졌습니다. 이번 포스팅에서는 인스턴스 컨텍스트를 활용하고 간이 라우터를 작성해봤습니다.
3회차 포스팅의 싱글톤 컨텍스트와 이번의 인스턴스 컨텍스트를 잘 구분하는 것은 구현 시 장황한 코드를 억제하고 쓸데없는 부하를 줄이게 됩니다.
flux구조는 알게 모르게 거의 모두 싱글톤으로 몰아넣는 경우가 많습니다.

to-do앱은 4부까지로 종료하고 5부에서는 채널통신, ref를 쓸 수 밖에 없는 경우, STYLE꼼수 등을 다루게 됩니다.
끝으로 가볍게 to-do앱에 더 해 볼만한 것을 적어둡니다.

  1. 카테고리를 생성시 타입을 지정할 수 있게 한다.
  2. 카테고리 타입에 따라 다른 형태의 목록이 나오게 한다.
다른 리액트 to-do앱보다 코드가 엄청 많은 것 같아요.
엔터프라이즈급이 되면 이렇게 코드를 역할에 맞게 나눌 수 있는 구조가 아니면 유지보수가 불가능한데 그 연습이라능!