#183: React Hook과 Microstate로 가계도 애플리케이션 만들기

박정환 edited this page Nov 14, 2018 · 4 revisions

React Hook과 Microstate로 가계도 애플리케이션 만들기

원문 : https://frontside.io/blog/2018/11/06/build-a-family-tree-maker-using-react-hooks-and-microstates/

React를 사용한다면 아마 ReactConf에서 발표된 React Hooks RFC에 대해 들어봤을 것이다. 이 짜릿한 제안은 클래스 컴포넌트가 가진 힘을 함수 컴포넌트로 뺏어올 것이다. 그리고 이 제안은 일급 API처럼 느껴지는 확장 기능을 만드는 React 에코시스템의 컨벤션도 된다. 이렇듯 React Hook API와 Microstate는 고비용의 React의 함수 컴포넌트를 전혀 새로운 차원으로 바꿔놓았다.

이번 튜토리얼에서는 React Hook Microstate를 이용해서 가계도 애플리케이션을 만들어 볼 예정이다. 가계도는 사용자의 이름을 입력받고, 부모님의 이름과 부모님의 부모님의 이름, 또다시 부모님의 부모님의 부모님의 이름을 반복해서 더는 기억이 나지 않을 때까지 적을 수 있도록 할 것이다.

여러분이 따라올 순서는 다음과 같다.

  1. React 알파 버전과 Create React App으로 새로운 프로젝트를 만든다.
  2. Microstate와 React Hook을 사용한 useType hook을 만든다.
  3. 재귀적인 가계도 트리 컴포넌트를 작성한다.
  4. 컴포넌트의 state는 LocalStorage에 저장한다.
  5. 컴포넌트의 render를 최적화한다.

그럼 이제 시작해보자.

React Alpha로 React 애플리케이션 만들기

React Hook을 사용하기 위해서는 React Alpha가 필요하다. React팀이 부탁하길 React Alpha는 React의 실험 버전임을 확실하게 알려달라고 했다. 따라서 신뢰도가 중요한 프로젝트에서는 이 버전의 릴리스를 사용하지 않아야 한다. 알파 버전은 그저 React의 실험적인 기능들을 가지고 놀기 위해 만들어졌다. 지금 우리가 하는 것처럼 말이다. :)

그럼 create-react-app을 이용해서 앱을 생성해보자. 만약 create-react-app을 설치하지 않았다면 create-react-app 웹사이트의 안내를 따라서 설치하면 된다.

create-react-app family-tree-app
cd family-tree-app # go into created directory

이제 React 알파 버전을 사용하기 위해 package.json을 수정해야 한다. package.json파일을 열고 reactreact-dom 버전을 16.7.0-alpha.0으로 변경하면 된다. 그다음 npm install을 실행하면 새로운 버전을 받을 수 있다.

"dependencies": {
    "react": "16.7.0-alpha.0",
    "react-dom": "16.7.0-alpha.0",
    "react-scripts": "2.1.1"
  },

이제 npm start를 사용해서 서버를 시작할 수 있고, 서버가 시작되면 http://loaclhost:3000로 접속해서 React로고를 확인할 수 있다. React 로고가 보인다면 React 알파 릴리스가 설치된 것이라 볼 수 있다. 잘 설치 되었는지는 hook을 사용하려고 할때 확실히 알 수 있을 것이다. 다음 단계로 가보자.

Microstate와 React Hook을 이용한 useType hook 만들기

대부분의 독자가 React의 Microstate를 사용하는데 익숙하지 않을 것이므로, useType hook을 이용하는 과정을 좀 더 자세하게 살펴보도록 하자. 차근차근 진행하기 위해 Microstate의 간략한 설명으로 시작해서 Microstate를 클래스 컴포넌트에 적용하는 방법을 보여줄 것이다. 그다음엔 실제 useType hook을 구현해볼 것이다.

useType은 가계도 트리에 state를 전달해 줄 것이다. useType에 사용할 state와 state를 변경할 수 있는 트랜지션(transition)을 만들기 위해 Microstate를 이용할 것이다.

만약 여러분이 Redux를 사용하는 데 익숙하다면, Microstate를 타입 정보로 만든 불변성(Immutable)의 store정도로 생각할 것이다. Microstate는 자동으로 주어진 타입에 대한 리듀서를 작성하므로 Redux처럼 리듀서를 직접 작성할 필요가 없다. microstates.js를 들어본 적이 한 번도 없다면 잠깐 시간을 내서 README 를 읽어보자. README를 전부 읽고 왔다는 가정하에 다음 내용을 진행할 것이다. 잠깐 시간을 주겠다.⏱

React 컴포넌트에 Microstate를 연결하려면 다음 세 가지를 해야한다.

  1. microstate를 설치한다.
  2. microstate를 만든다.
  3. microstate를 관찰한다.

microstate 설치

microstate는 npm install —save microstates 나 yarn add microstates를 통해서 설치할 수 있다.

Microstate 만들기

create함수를 이용해서 microstate를 만들 수 있다. 이 함수는 타입과 값을 받아서 microstate를 반환한다.

import { create } from "microstates";

let meaningOfLife = create(Number, 42);

meaningOfLife.state;
    // 42

생성된 microstate로 microstate의 변경을 일으킬 수 있고, 트랜지션의 결과로 새로운 microstate를 받을 수 있다.

let next = meaningOfLife.increment();

next.state;
//> 43

microstate에서의 트랜지션은 항상 실행 당시(트랜지션이 호출된 위치)에서 트랜지션 결과를 반환한다. 만약 이벤트 핸들러 내부에서 트랜지션을 호출하면 다음 state는 이벤트 핸들러에서 받게 될 것이다. 이건 React에 필요하지 않은 기능이다. React에서는 다음 state를 잡아서 컴포넌트의 state로 설정할 수 있어야 한다. 이런 일을 Store를 통해서 할 수 있다.

microstate 관찰하기

Store함수는 microstate와 콜백함수를 받아서 store 인스턴스를 반환한다. 만약 store 인스턴스의 변경을 유발하면 store는 변경 결과와 함께 콜백 함수를 호출한다.

import { Store, create } from "microstates";

let store = Store(create(Number, 42), next => console.log(next.state))

store.increment();
//> 43

Store를 이용해서 변경이 일어나면 state를 업데이트하는 React 컴포넌트를 만들 수 있다. 자, 이제 React 클래스 컴포넌트를 이용해서 첫 컴포넌트를 구현한 뒤 React Hook을 이용해서 리팩토링 해보자. 아래 코드를 보면 microstate store를 생성해서 state로 설정해주었다. state 변경이 일어나면 store의 콜백함수는 setState를 호출해서 컴포넌트의 state를 업데이트한다.

import React, { Component } from "react";
import { Store, create } from "microstates";

class App extends Component {

  // set the next store onto the state of the component
  update = counter => this.setState({ counter });

  state = {
    // create the store when the component is instantiated
    // the update function will be called with next state
    counter: Store(create(Number, 42), this.update)
  };

  render() {
    let { counter } = this.state;

    return (
      <button onClick={() => counter.increment()}>
        Increment {counter.state}
      </button>
    );
  }
}

CodeSandbox에서 예제를 확인해보자.

자, 이제 같은 기능을 useState hook을 이용해서 구현해보자. useState 는 초기 state의 참조를 받아서 let [state, setState] = useState(initialState) 와 같은 형태로 비구조화 할당 가능한 배열을 반환한다. initialState 는 특정 state의 key로 이용되므로 변경되지 않고 그대로 유지된다는 점을 주목하자.

initailState의 참조가 컴포넌트가 다시 렌더링 되는 사이에 바뀐다면, 새로운 state를 생성할 것이다. 그렇게 되면 그 컴포넌트는 정상적으로 동작하지 않는다. 이런 이유로 컴포넌트가 다시 렌더링 될 때 initialState가 변경되지 않게 조심해야 한다.

예제 코드의 경우 initialState는 타입과 값으로 생성된 microstate다. 살짝 들여다보면 microstate를 생성해서 useState로 전달해준 것처럼 생각할 수 있다. 하지만 이것은 컴포넌트가 렌더링될 때마다 항상 새로운 참조가 생성되기 때문에 정상적으로 동작하지 않을 것이다. useState를 새로운 참조를 넘겨서 호출하면 새로운 state 객체가 생성된다.

import React, { useState } from "react";
import { create } from "microstates";

function App() {
  // this is wrong, do not copy and paste this
  let counter = useState(create(Number, 42));

  return (
    <button onClick={() => counter.increment()}>
      Increment {counter.state}
    </button>
  );
}

이 문제를 해결하려면 매번 렌더링 할 때마다 변경되지 않는 안정적인 참조 값이 필요하다. 이제 useMemo가 등장할 차례다. useMemo 를 이용하면 특정 값에 대해 이전 값을 저장(memoize)할 수 있다. 함수를 저장하는 것은 그 함수를 실행하고 반환 값이 저장되는 것을 뜻한다. 함수가 수행한 계산값이 변하지 않는 한, 함수는 다시 실행될 필요가 없으므로 이전 값이 사용될 수 있다.

useMemo 를 사용해서 create 함수의 타입과 값을 의존적인 값으로 저장할 수 있다. 이를 통해 컴포넌트가 다시 렌더링되더라도 언제든 동일한 microstate를 받을 수 있다. CodeSandbox에서 한번 확인해보자.

import React, { useState, useMemo } from "react";
import { create } from "microstates";

function App() {
  let initialState = useMemo(() => create(Number, 42), [Number, 42]);

  let [counter, setState] = useState(initialState);

  return (
    <button onClick={() => setState(counter.increment())}>
      Increment {counter.state}
    </button>
  );
}

이제 점점 처음 잡았던 목표에 가까워지고 있지만, 이 방법은 state가 변경될 때마다 setState 를 호출해야 한다. 다시 말해 사용하기에 매우 불편하다. 그렇다면, microstate를 Store 로 감싸서 setState 를 명시적으로 호출하지 않고 트랜지션을 유발해보자. 이 단계는 조금 까다로운데, 이번 RFC가 React의 정식으로 추가기 전에는 사용이 더 쉬워지길 바란다.

여기서 까다로운 부분은 Store는 useState가 반환한 setState 함수가 필요하지만 initialState를 생성할 때도 이 함수를 사용해야 한다는 것이다. 이제 이 문제를 해결하기 위해 useMemo를 호출하고 참조 값이 Store에 생성되기 전에 변수를 정의할 것이다. CodeSandbox에서 확인해 보자.

import React, { useState, useMemo } from "react";
import { Store, create } from "microstates";

function App() {
  let state;

  let initialState = useMemo(
    () =>
        Store(create(Number, 42), next => {
            // at index 1 is setState function
            // effectively calling setState(s)
            state[1](next);
        }),
        [Number, 42]
  );

  // assign state to make it available to the store callback
  state = useState(initialState);

  let counter = state[0]; // at index 0 is state

  return (
    <button onClick={() => counter.increment()}>
      Increment {counter.state}
    </button>
  );
}

앞서 까다로워질 거라고 언급했던 부분이다. 이런 종류의 우회적인 트릭 말고 더 좋은 방법이 있을 거라는 기대를 품어보자. 그렇다면 이제 우리가 기다려오던 순간이다. useType함수를 만들어서 이 코드들을 정리해보자. useType는 타입과 값을 받아서 컴포넌트가 다시 렌더링되도록 유발할 수 있는 트랜지션과 함께 state 객체를 반환한다.

import React, { useState, useMemo } from "react";
import { Store, create } from "microstates";
    
function useType(type, value) {
    let state;
    
    let initialState = useMemo(
        () =>Store(create(type, value), next => {
            // at index 1 is setState function
            // effectively calling setState(s)
            state[1](next);
          }),
        [type, value]
    );
    
    // assign state to make it available to the store callback
    state = useState(initialState);
    
    return state[0]; // at index 0 is state
}
    
function App() {
    let counter = useType(Number, 42);

    return (
        <button onClick={() => counter.increment()}>
            Increment {counter.state}
        </button>
    );
}

useStateuseMemo, Microstate를 사용해서 useType hook을 만들었다. 많이 작긴 하지만 다음에 useType@microtates/react 패키지에 포함될 것이다. 이 hook을 이용해서 함수 컴포넌트의 어떤 Microstate 타입도 만들 수 있다. 다음은 useType 을 사용해서 가계도 트리 마커에 state를 연결해보자.

재귀적인 가계도 트리 생성 컴포넌트 만들기

사용자는 컴포넌트에 자신의 이름을 입력할 수 있다. 자신의 이름을 입력하면 어머니와 아버지의 이름을 입력하는 입력란이 표시되고, 부모님의 이름을 입력하면 다시 부모님의 부모님 이름 입력란이 표시된다. 이렇게 계속 반복해서 사용자의 인내심 깊이 만큼 재귀적으로 생성된다.

이런 종류의 컴포넌트를 만들 때 그 state를 관리하는 건 매우 어렵다. 하지만 다행히도 Microstates와 useType 를 사용하면 아주 쉽게 만들 수 있다. 우리가 필요한 건 한명의 이름과 그의 어머니, 아버지의 이름을 저장할 수 있는 타입을 만드는 것이다. 그 타입도 컴포넌트와 마찬가지로 재귀적이어야 한다. Microstates를 사용한다면 아주 쉽게 만들 수 있다.

class Person {
      name = String;
      // name of the person
      mother = Person;
      // mother is of type 
      Personfather = Person;
      // father is of type Person
  }

타입 생성이 끝났다. 우리가 만들고자 하는 재귀 데이터 구조를 만드는데 필요한 모든 게 다 들어있다. Microstate는 트랜지션으로 계산하고, 재귀적인 데이터 구조의 불변성을 처리한다. 이제는 Person 타입과 useType hook을 이용해서 컴포넌트를 작성할 것이다. 그럼 미래의 FamilyTree컴포넌트에 넘겨줄 state를 만드는 것부터 시작해보자.

function App() {
  let person = useType(Person);
  return <FamilyTree person={person} />;
}

우리는 이제 FamilyTree컴포넌트를 작성해야 한다. 먼저 이 컴포넌트는 사용자 이름 입력을 위한 입력 필드가 있다. 이름 입력란이 비어있지 않다면 컴포넌트는 FamilyTree 컴포넌트를 렌더링해서 어머니와 아버지의 이름을 입력받을 것이다.

function FamilyTree({ person )} {
    return (
        <>
            <input
              value={person.name.state}
              onChange={e => person.name.set(e.target.value)}
            />
            {person.name.state !== "" && (
                <>
                    Father: <FamilyTree person={person.father} />
                    <br />
                    Mother: <FamilyTree person={person.mother} />
                </>
            )}
        </>
    );
}

(역: <><React.Fragment>의 축약형이다.)

구현은 이런 형태가 될 것이다. 인풋의 onChange 핸들러를 한번 살펴보자. person.name.set 트랜지션은 노드의 이름을 변경하는 리듀서와 닮아있다. Microstate는 불변적(immutably)으로 값을 업데이트하고, 이로 인해 컴포넌트는 useType hook을 통해서 업데이트된다.

LocalStorage에 컴포넌트 state 저장

모든 트랜지션은 이후에 state를 복원하는데 사용할 수 있는 일련의 새로운 값을 생성한다. 이것을 이용해서 LocalStorage에 state를 저장할 수 있다. 사용자가 자신이 보고 있는 페이지를 새로 고침 할 때, 가계도 트리는 LocalStorage에서 복원될 것이다. 이를 위해서는 기존 애플리케이션을 조금 바꿔주어야 한다.

  1. localStorage에서 state 복원하기
  2. 복원된 state에서 microstate 생성하기
  3. localStorage에 state 저장하기

localStorage에서 state 복원하기

컴포넌트가 렌더링되면 localStorage에서 key 하나를 가져와서 JSON.parse를 이용해서 파싱 해야 한다. 이 부분은 React Hook이나 Microstate 고유의 작업만은 아니다.

const initial = JSON.parse(localStorage.getItem("family-tree") || "{}");

문자열로 둘러싸인 객체 "{ }" 를 주목하자. 이것은 빈 객체를 직렬화한 것이다. 혹시나 localStorage에 아무것도 저장되지 않았을 경우를 대비해서 추가했다. 이런 일은 컴포넌트의 첫 렌더링에서 일어나기 때문이다.

복원된 state에서 microstate 만들기

우선 초기 State를 localStorage에서 가져오면 App 컴포넌트에 전달해서 전달받은 값으로부터 microstate를 생성할 수 있다.

const initial = JSON.parse(localStorage.getItem("family-tree") || "{}");

function App({ initial }) {
  let person = useType(Person, initial);

  return <FamilyTree person={person} />;
}

ReactDOM.render(<App initial={initial} />, document.getElementById("root"));

이렇게 하면 값을 가져올 수 있다. 하지만 이 값을 localStorage에 저장하려면 어떻게 해야할까? 다음에 살펴볼 또 다른 hook을 이용해야 한다.

localStorage에 state 저장하기

이제 localStorage에 저장된 가계도 트리 state가 필요하다. 그러려면 먼저 microstate에서 직렬화된 state를 추출해야 한다. valueOf 함수를 사용해서 localStorage로 저장할 값을 만들 수 있다.

import { valueOf } from "microstates";

function App({ initial }) {
  let person = useType(Person, initial);

  let value = valueOf(person);

  return <FamilyTree person={person} />;
}

먼저 microstate에서 값을 가져오면 useEffecthook을 이용해서 localStorage.setItem 연산을 한 줄로 세운다. useEffect hook은 render가 완료된 이후에 실행되는데, 이 hook은 잠재적으로 느린 연산을 처리해도 렌더링이 느려지지 않도록 방지해준다. 또한 선언한 값에 의존하는 값들의 불필요한 호출을 막아준다. 아래에서 모습을 확인해보자.

import React, { useEffect } from "react";
import { valueOf } from "microstates";

function App({ initial }) {
  let person = useType(Person, initial);

  let value = valueOf(person);

  useEffect(
    () => {
      let serialized = JSON.stringify(value);
      localStorage.setItem("family-tree", serialized);
    },
    [value]
  );

  return <FamilyTree person={person} />;
}

이번 절에서 우리는 사용자의 입력을 localStorage로 저장하고, 그들이 다시 페이지로 돌아왔을 때 입력 내용을 복원하는 것을 가능하게 만들었다. valueOf로 microstate에서 값을 추출하고, useEffect hook 을 통해서 저장하는 연산을 호출해도 렌더링을 멈추지 않도록 대처했다. 이 튜토리얼의 끝으로 우리가 만든 컴포넌트를 최적화해서 실제로 변경된 부분만 렌더링 되도록 해볼 것이다.

가계도 트리 컴포넌트 렌더링 최적화

이렇게 프로젝트의 막바지에서 성능을 개선하도록 내버려 두는 것이 드문 일은 아니지만, 이 경우 성능을 개선하기가 어려워질 수 있다. 운 좋게도, Microstate는 React의 성능 최적화를 쉽게 하는 방법으로 만들어져있다. 이번 장에서는 기존의 컴포넌트를 어떻게 최적화해야 변경된 부분만 다시 그리도록 바꿀 수 있는지 보여줄 것이다.

React 렌더링 최적화가 잘 되었는지 확인하려면 변경된 컴포넌트만 업데이트 되었는지 확인하면 된다. React Devtools는 “Highlight Updates”라고 하는 기능을 가지고 있다. 이 기능으로 우리의 애플리케이션과 상호작용 할 때마다 어떤 컴포넌트가 다시 그려지는지 쉽게 알 수 있다.

“Highlight Updates”가 켜져 있는 동안 React DevTool은 업데이트된 컴포넌트 트리 영역을 하이라이팅 할 것이다. 위의 예제를 보면 키 입력마다 모든 컴포넌트가 업데이트 되는 것을 볼 수 있다. 이것은 매우 불필요한 re-render다.

이제 변경된 state의 자손들만 업데이트 되도록 바꿔보자. Microstate는 불변성이라는 사실을 떠올리면, microstate가 변하지 않으면 그 참조도 달라지지 않는다는 것을 알 수 있다. 만약 그 값이 바뀌지 않았다면 microstate는 재사용할 수 있는 것이다.

이런 참조 투명성과 useMemo hook을 조합해서 컴포넌트가 사용하는 microstate를 기반으로 컴포넌트를 저장해보자. 이렇게 하면 microstate가 변경되면 저장된 컴포넌트가 유효하지 않음을 확신할 수 있다. 그럼 microstate를 기준으로 부모님 컴포넌트를 저장하도록 수정해보자.

function FamilyTree({ person }) {
  const father = useMemo(() => <FamilyTree person={person.father} />, [
    person.father
  ]);

  const mother = useMemo(() => <FamilyTree person={person.mother} />, [
    person.mother
  ]);

  return (
    <>
      <input
        value={person.name.state}
        onChange={e => person.name.set(e.target.value)}
      />
      {person.name.state && (
        <ul>
          <li>Father: {father}</li>
          <li>Mother: {mother}</li>
        </ul>
      )}
    </>
  );
}

수정을 다 했다면 얼마나 성능 개선이 있었는지 결과를 한번 확인해보자. 이제 사용자가 입력 필드를 수정하더라도 모든 컴포넌트가 영향을 받지 않는다. 업데이트되는 컴포넌트는 microstate의 변화에 영향이 있는 현재 수정중인 컴포넌트와 상위 컴포넌트들이다.

우리는 여기서 한 개의 컴포넌트의 값을 바꾸었는데도 모든 부모 컴포넌트가 바뀐 것으로 나타나는 것에 주목해야 한다. 이것은 microstate의 변경이 중첩되었을 때 부모의 microstate 또한 불변성의 원칙에 따라 새로 생성되어야 하기 때문이다.

결론

이번 튜토리얼을 통해 우리는 useType hook을 구현하고, Microstate에 대해 조금 배웠으며, Microstate를 이용한 재귀 컴포넌트를 만들었고, state를 localStorage에 저장하고 개발한 컴포넌트의 re-render 최적화까지 살펴보았다.

만약 이런 과정이 쉽게 느껴졌다면, 80줄 남짓의 React와 Microstate 코드로 얼마나 많은 것을 할 수 있었는지 생각해보자. 이 튜토리얼의 최종 코드는 GitHub Repo 와 CodeSandbox에서 볼 수 있다.

반대로 이 과정이 어려웠다면, 어떤 부분이 어려웠는지 issue로 알려주었으면 한다.

Clone this wiki locally
You can’t perform that action at this time.
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session.
Press h to open a hovercard with more details.