본문 바로가기
Programming/Javascript

자바스크립트로 만들어 보는 리액트 프레임워크 - 3. hook을 이용한 상태처리

by peter paak 2021. 6. 21.
728x90

이번 시간에는 hook을 이용하여 컴포넌트의 상태를 관리해보도록 하겠습니다.

  1. 자바스크립트로 만들어 보는 리액트 프레임워크 - 1. 기본 컨셉
  2. 자바스크립트로 만들어 보는 리액트 프레임워크 - 2. jsx
  3. 자바스크립트로 만들어 보는 리액트 프레임워크 - 3. hook
  4. 자바스크립트로 만들어 보는 리액트 프레임워크 - 4. redux
  5. 자바스크립트로 만들어 보는 리액트 프레임워크 - 5. thunk

모든 소스 코드는 github를 참고해주시기 바랍니다

hook

hook은 함수형 컴포넌트에서 상태관리나 라이프사이클을 제어하도록 리액트에서 제공하는 함수입니다. 예를들어 useState의 경우 함수형 컴포넌트 내에서 컴포넌트의 상태를 변경시킬 수 있습니다

function App() {

    const [name, setName] = useState("박병길")

    return (
        <div className="a">안녕하세요. {name}</div>
    )
}

함수형 컴포넌트는 내부에 hook을 가지고 있습니다. 리액트에서 상태가 변경되면 컴포넌트를 지우고 다시 그리게 됩니다.

render 메커니즘

이전 시간에 element로 dom을 생성하여 화면에 렌더링 했었습니다. 그림으로 표현하면 다음과 같습니다.

jsx를 사용하거나 직접 element를 만들어서, element객체로 dom 트리를 생성하여 화면에 표현하였습니다. 우리가 자바스크립트로 생성한 dom 트리가 바로 리액트에서의 virtual dom입니다. 실제 dom이 아닌 자바스크립트로 메모리 상에 만들어 놓은 dom이 바로 virtual dom입니다. (맥락은 그렇습니다)

조금 더 정확히 표현하면 element는 트리구조 입니다. element의 각 노드는 children이라는 또 다른 element라는 노드들이 있고 이런 구조가 반복되게 됩니다. 즉, element 트리로 dom 트리를 생성해온 것입니다. 자연스럽게 트리의 각 노드는 1대1로 매칭됩니다.

이제 상태가 변경되었을 때 화면에 리렌더링 되는 메커니즘을 살펴보겠습니다

rerender 메커니즘

리액트에서 화면에 렌더링하는 기본 메커니즘은 모두 지우고 다시 그린다 입니다.

함수형 컴포넌트 또한 모두 지우고 다시 그려집니다. 이라는 컴포넌트 또한 지워지고 다시 그려집니다. 그러면 App 함수 컴포넌트 내부에 정의된 useState 함수도 App 컴포넌트가 지워지고 다시 그려질 때 다시 호출합니다. 그렇기 때문에 hook은 함수형 컴포넌트 내부에만 정의 되어야 합니다. 함수형 컴포넌트 밖에서 정의한다면 에러가 발생하게 됩니다.

useCallback이라는 함수는 사용하는 이유도 컴포넌트 내의 함수는 리렌더링 시 지워지고 새로 재정의하는 것을 방지하고자 만들어졌습니다.

정리하면 virtual dom이라고 불리는 dom 트리를 지우고 다시 렌더링 합니다. 함수형 컴포넌트와 내부 함수가 모두 지워지고 다시 그려질 때 함수가 다시 재정의 됩니다.

하지만 리액트는 변경된 부분만 렌더링 하는게 아닌가 라는 생각을 하시게 될 것입니다. 맞습니다. 하지만 여기서는 모두 지우고 다시 그린다 라는 큰 맥락으로 구현해보려고 합니다. 실제로 내부적으로는 조금 더 복잡합니다. 궁금하신 분들은 다음 내용을 읽어주시고 그렇지 않다면 넘어가셔도 됩니다

참고. 리액트의 render 매커니즘

리액트는 fiber이라는 자료구조를 이용하여 dom 트리를 표현합니다.

fiber는 각 컴포넌트들이 히스토리와, 생성, 수정, 삭제 정보, hook 등을 다양한 정보들을 저장한 뚱뚱한 dom 트리라고 생각할 수 있습니다. 메모리에서는 fiber 트리를 가지고 있고, 상태가 변경되었을 때 트리를 자식노드로 재귀적으로 탐색하면서 각 노드가 가지는 정보들을 비교합니다. 그리고 형제 혹은 형제의 부모, 자신의 부모를 탐색하며 root 노드까지 재귀적으로 거꾸로 탐색합니다. 예를들어 div라는 노드가 className="b"로 변경되면 자신이 가진 history 정보를 확인합니다. history 정보에는 변경 이전 내역이 그대로 있어 비교를 할 수 있습니다. className이 변경됨을 확인하면 스스로가 "수정"이라는 정보를 가집니다. 메모리에 있는 fiber 트리 구조에서 div만 변경하면 되므로 해당 노드만 수정을 해줍니다. 이것이 리액트가 virtual dom에서 변경된 부분만 리렌더링 하는 메커니즘입니다.

이 과정을 reconciliation이라고 합니다. 직역하면 관계의 복구입니다. 즉, fiber 트리에서 노드의 상태만 변경시켜 노드간의 관계를 복구한다고 이해할 수 있습니다. 실제 리액트가 reconciliation하는 작업은 훨씬 더 복잡합니다. 이런 개념을 바탕으로 자세히 알아보시면 좋을 것 같습니다. 아래는 관련 링크이니 참고로 봐주시기 바랍니다

정리하면 메모리에는 기존의 fiber 트리가 있고 상태가 변경되면 트리를 탐색하면서 변경되는 노드만 수정하여 렌더링합니다. 이 글에서는 fiber의 reconciliation 과정없이 화면을 모두 지우고 다시 렌더링 할 것이므로 같은 맥락을 가진 일반적인 dom 트리로 구현을 해보았습니다

만들어보기

다음과 같은 순서로 구현해보겠습니다

  1. App 컴포넌트 생성
  2. createElement 함수 정의
  3. render 함수 호출
  4. render 함수 생성
  5. renderDom 함수 생성
  6. dom 생성
  7. setState 생성

1. App 컴포넌트 생성

이전 시간에 작성한 내용과 비슷합니다. 다만 이번시간에는 상태변경을 해줘야 되므로 useState를 호출하였습니다. useState 훅은 나중에 정의하도록 하겠습니다.

/** @jsx createElement */
function App() {

  const [number, setNumber] = useState(1);

  function handleClick() {
    setNumber(number + 1)
  }

  return (
    <div>
      <div>숫자 {number}</div>
      <button onclick={handleClick}>+1</button>
    </div>
  );
}

2. createElement 함수 정의

createElement 함수를 정의합니다. jsx는 바벨로 transpile하면 createElement를 자동으로 함수를 호출하고, createElement는 element를 반환합니다.

function createElement(type, props = {}, ...children) {

  children = children.map((child) =>
    typeof child === "object" ? 
      child : 
      createTextElement(child)
  );

  return {
    type,
    props: {
      ...props,
      children,
    },
  };
}

function createTextElement(text) {
  return {
    type: "TEXT",
    props: {
      nodeValue: text,
      children: [],
    },
  };
}

3. render 함수 호출

render 함수를 호출합니다. 컴포넌트로 생성된 dom을 html에 append시킵니다.

render(<App />, document.getElementById("root"));

4. render 함수 정의

이제 render 함수를 정의하겠습니다. 주목할 점은 element 트리를 생성하여 관리한다는 점입니다.

let elementTree = null;
let rootNode = null;

function render(element, container) {

  rootNode = container

  elementTree = {
    dom: container,
    children: [element],
  };

  renderDom();
}

이전 시간에는 element로 dom을 생성하고 바로 렌더링 하였습니다. 만약 리렌더링 해야한다면 어떻게 해야할까요? 바로 초기에 생성된 element 트리를 어딘가 저장하여, 상태변경 이후 저장된 element 트리로 dom 트리를 생성하여 리렌더링 할 수 있어야 할 것입니다. 왜냐면 element 트리는 이라는 함수형 컴포넌트가 처음 호출될때만 생성되기 때문입니다.

이전 시간에 했던 작업이 다음과 같습니다. element 트리를 생성한 뒤, 저장하지 않고 바로 dom 트리를 생성하고 렌더링했습니다. 그렇다 보니 리렌더링 할 때, element 트리를 재생성할 방법이 없게 됩니다.

그래서 우리는 처음 element 트리를 생성 시에 메모리에 저장하여 리렌더링 시 재사용할 예정입니다.

4. renderDom 함수 생성

이 함수는 초기 렌더링 및 리렌더링 시 사용할 예정입니다.

function renderDom() {
  appendDom(createDomTree(elementTree))
}

createDomTree

  • 앞서 전역변수에 저장한 elementTree를 재귀적으로 돌면서 dom 트리를 다시 생성합니다.
  • 리랜더링할 경우, dom을 모두 지우고 저장된 element 트리로 새롭게 dom을 생성하겠습니다.
  • 실제로 리액트에서는 reconciliation이라는 변경된 부분만 dom 트리에서 변경하는 과정을 가집니다.

appendDom

  • 생성한 dom 트리를 재귀적으로 돌면서 실제 dom에 append하는 과정을 반복합니다.

5. Dom 생성

이 부분은 조금 복잡할 수 있지만 dom을 생성하고 실제 dom에 append하는 맥락은 동일합니다. 하나씩 살펴보겠습니다.

const isFunctionComponent = (element) => typeof element.type === "function"
const isProperties = (key) => key !== "children" && !key.startsWith("on")
const isEvent = (key) => key.startsWith("on")

function createNode (element) {

  /* 1. 함수형 컴포넌트인 경우 */
  if(isFunctionComponent(element)) {

    const node = element.type(null, element.props, element.children)

    return createNode({
      type: node.type, 
      props: node.props
    })
  }

  /* 2. 일반 element인 경우 */
  const node = element.type === "TEXT"
    ? document.createTextNode(element)
    : document.createElement(element.type);

    /* attribute 바인딩 */
    Object.keys(element.props)
      .filter(isProperties)
      .forEach((name) => node[name] = element.props[name]);

    /* event 바인딩 */
    Object.keys(element.props)
      .filter(isEvent)
      .forEach((name) => {
        const eventType = name.toLowerCase().substring(2);
        node.addEventListener(eventType, element.props[name])})

  return {
    dom: node,
    children: element.props.children
  }
}

/* dom 생성 */
function createDomTree(node) {

  /* dom 초기화 */
  rootNode.innerHTML = '';

  const newNode = {...node};

  newNode.children = newNode.children
    .map(createNode)
    .map(createDomTree)

  return newNode;
}

/* 노드 트리를 끝까지 탐색(DFS)하여 dom에 append */
function appendDom(node) {
  const {dom, children} = node;

  children.map(appendDom)
    .forEach(child => dom.appendChild(child))  

  return dom
}

createNode

  • element로 노드를 생성합니다.
  • 여기서는 element가 함수형 컴포넌트인지 일반 요소인지 구분을 합니다
  • 이유는 함수형 컴포넌트의 경우, 함수를 호출하는 과정이 추가가 됩니다. 그래야 일반적인 element 형태로 변환할 수 있습니다. (조금 어려운 내용일 수 있습니다. 이전 코드를 이해하시면 좋습니다)
  • 일반 element의 경우는 노드를 생성하고 각 프로퍼티 및 이벤트 함수를 연결합니다
  • 이 과정을 재귀적으로 반복합니다

createDomTree

  • createNode로 생성한 노드들을 재귀적으로 돌면서 dom 트리를 생성합니다
  • 함수가 호출되면 rootNode.innerHTML = ''를 호출하여 화면을 비우게 됩니다
  • 이것을 리액트에서 사용하는 방법이 아니며 렌더링의 맥락을 이해하기 쉽게 하기위해 사용한 트릭입니다.

appendDom

  • createDomTree로 생성한 dom 트리를 실제 dom에 재귀적으로 append 시키는 과정입니다.

이 모든 과정이 렌더링 과정이 됩니다. 정리하면 렌더링이 시작되면, 화면을 지운 뒤, 저장해놓은 element 트리를 재귀적으로 돌면서 dom 트리를 생성하고 실제 dom에 append하여 화면에 출력합니다. 이 과정을 리렌더링에서도 동일하게 동작합니다

5. setState 생성

이제 hook을 정의해보겠습니다. setState는 상태를 변경하면 컴포넌트를 리렌더링 합니다. 우리는 리렌더링을 하는 방법을 구현해놓았으니 상태를 변경하는 방법만 구현하면 됩니다.

앞서 컴포넌트는 hook을 가진다고 했습니다. 컴포넌트가 리렌더링 될 때 컴포넌트의 모든 내용이 사라지면서 hook도 함께 사라지게 됩니다. 이는 함수형 컴포넌트를 나타내는 element 정보에 hook 정보도 포함되기 때문입니다. (추가. 실제로는 배열로 따로 관리합니다. dom 트리에서 탐색하며 함수형 컴포넌트를 렌더링하는 시점은 항상 일정하니, 컴포넌트 렌더링 순서에 맞추어 배열에서 hook을 꺼내어 사용하는 방식입니다.) 현재 element 트리의 제일 상단에는 이라는 함수형 컴포넌트를 가지고 있습니다. 개략적으로 나타내면 아래와 같습니다

const element = {
    type: App(),
    props: {
        children: [...]
    }
}

이 element는 App 컴포넌트가 가진 모든 정보들을 가지고 있습니다. 앞서 컴포넌트를 hook을 가지고 있다고 하였으니 element에 hook 정보를 추가(추가. 실제로는 컴포넌트 당 hook의 정보를 배열로 따로 관리하지만, 컴포넌트의 렌더링 라이프사이클과 hook의 라이프사이클이 같다는 맥락에서 이해해주시기 바랍니다. 다른 구조로 업데이트를 하도록 하겠습니다) 하면 됩니다

const element = {
    type: App(),
    props: {
        children: [...]
    },
    hook: {
        state: {
            number: 1
        }
    }
}

함수를 리랜더링할 때 element 트리로 dom 트리를 생성하고 실제 dom에 append 합니다. 이 때 hook이 가진 상태 정보를 렌더링 과정에서 dom에 append하면 됩니다.

이제 구현을 해보겠습니다.

function useState(initial) {

  if(!elementTree.hook) {
      elementTree = {
        ...elementTree,
        hook: {
          state: initial
        }
      }
  }

  function setNumber (value) {
    elementTree.hook.state = value;
    renderDom();
  }

  return [elementTree.hook.state, setNumber]
}

initial

  • 상태의 초기값
  • elementTree에 hook이 없으면 초기 상태를 저장합니다.
  • 컴포넌트가 리렌더링 될 때마다 useState를 재호출하므로 초기에 한번만 실행됩니다.

setNumber

  • setNumber로 새로운 값을 상태값으로 저장합니다.
  • 상태를 immutable 하게 저장됩니다. (여기서는 가독성을 위해 생략하였습니다)
  • 그리고 renderDom으로 리렌더링을 시작합니다

이 모든 과정을 끝내면 아래와 같이 동작하게 됩니다.

정리

이번 시간에는 element 트리를 메모리에 저장하고 렌더링 혹은 리렌더링 될 때 element 트리로 dom 트리를 생성하고 실제 dom에 append하는 방법을 살펴보았습니다. 그리고 setState라는 hook을 생성하여 컴포넌트의 상태를 관리해보았습니다. 리액트의 virtual dom은 element로 생성된 dom 트리 구조라는 것 또한 살펴보았습니다.

실제로 리액트는 더욱 더 복잡한 방법을 사용합니다. requestIdleCallback으로 브라우저가 쉴 때(idle 상태) 계속 렌더링 작업을 시도합니다. 또한 fiber 이라는 자료구조를 통하여 reconcilication이라는 트리를 비교하여 트리를 탐색하고 다시 root 노드로 올라오면서 변경된 부분만 렌더링하는 작업 등이 추가됩니다. 컴포넌트의 상태가 어떻게 관리되고 렌더링이 어떻게 되는지 큰 맥락에서 한번 살펴보았습니다

다음은 리덕스를 이용하여 전역적으로 상태를 관리하는 법에 대해 살펴보겠습니다.

관련 컨텐츠

  1. 자바스크립트로 만들어 보는 리액트 프레임워크 - 1. 기본 컨셉
  2. 자바스크립트로 만들어 보는 리액트 프레임워크 - 2. jsx
  3. 자바스크립트로 만들어 보는 리액트 프레임워크 - 3. hook
  4. 자바스크립트로 만들어 보는 리액트 프레임워크 - 4. redux
  5. 자바스크립트로 만들어 보는 리액트 프레임워크 - 5. thunk
728x90