본문 바로가기

3.구현/HTML5&Javascript

[javascript] 리액트 같은 UI 라이브러리 개발 2 - 랜더링 개선과 useState() 추가

들어가기

이전에는 가상 DOM을 사용해서 실제 DOM을 생성하여 화면에 출력하는 부분까지 작업하였다. 화면 출력하기 위해 랜더링 작업에 대한 요청을 직접 코딩하여 필요시 매번 호출해야 하는 단점이 있다. 그리고 특정 UI 컴포넌트 작업에서 로컬의 상태를 사용하려면 전역 변수를 사용해야한다. 그렇기 때문에 재사용위해 반복해서 사용할 경우 문제가 발생한다. 이때 필요한 것이 useState()이고 이를 사용해서 상태를 컴포넌트 별로 관리할수 있게할 수 있다. 추가로 값이 변경되는 상태를 감지해서 자동으로 랜더링을 한다.

작성자: http://ospace.tistory.com/ (ospace114@empal.com)

랜더링 개선

이전에 랜더링 작업은 랜더링 함수를 직접 작성해서 호출하는 구조로 되어 있다.

function renderDemo() {
  const root = document.querySelector("#msgList tbody");
  o.render(root, TableData(data));
}
renderDemo();

이렇게 매번 함수를 작성해서 호출하기가 번거롭다. 렌더링하는 과정을 좀더 간단하게 만들어보자. 이를 위해 랜더링 대상을 등록해서 내부적으로 등록된 렌더링 목록을 호출하도록 개선해보자.

먼저 랜더링 대상을 저장할 변수를 정의해보자.

const renderList = [];

renderList 배열에 랜더링 정보를 저장한다. 랜더링 정보는 실제 DOM에 포함될 루트와 가상 DOM을 생성위한 랜더 함수로 구성되어 있다.

{ root: "#msgList tbody"), render: () => TableData(data) }

renderList에 랜더링 정보를 등록할 함수를 정의해보자.

function App(opts) {
  renderList.push({ root: opts.root, render: opts.render });
}

App()을 좀더 개선처리해보자. 예외적인 상황을 고려해보았다.

function App(opts) {
  let root = opts.root;
  if ("string" === typeof root) {
    root = document.querySelector(root);
  }

  if (isNil(root)) throw Error("root is nil!!");

  renderList.push({ root, render: opts.render });
}

혹시나 해서 root가 없을 경우에 대한 예외처리도 추가한다. 좀더 유연한 구조가 되었다. 물론 문자열을 그대로 저장해두었다가 추후에 실행 시점에 찾아서 적용할 수도 있다. 그러나 루트 노드는 동적으로 변하지 않고 고정된 경우라고 가정하는게 적합하다 판단했다. 그리고 마지막으로 renderList에 있는 렌더링 정보를 사용해서 랜더링을 수행할 함수를 정의해보자.

  function renderAll() {
    renderList.forEach((it) => {
          const vdoms = [it.render()].flat();
        render(it.root, vdoms);
    });
  }

renderAll()의 실제 작업은 단순한다. renderList의 각 항목별로 가져와서 이전에 정의한 render()를 호출한다. 마지막으로 App()에 호출할 경우 자동으로 렌더링하도록 수정해보자.

 function App(opts) {
    let root = opts.root;
    if ("string" === typeof root) {
      root = document.querySelector(root);
    }

    if (isNil(root)) throw Error("root is nil!!");

    renderList.push({ parent: root, render: opts.render });

    renderAll();
  }

랜더링 개선 작업이후 수정된 코드이다.

o.App({ root: "#msgToolbar", render: () => ToolBar(data) });
o.App({ root: "#msgList tbody", render: () => TableData(data) });

여러 렌더링 정보가 추가되도 내부적으로 랜더링 목록을 관리해서 한번에 랜더링할 수 있게 됐다. 중간에 렌더링 필요할 경우 renderAll()을 호출하면 된다. 뭐가 정리된 느낌을 갖는다.

useState() 추가

useState() 추가해보자. useState()은 UI 컴포넌트별로 상태를 관리할 수 있게 한다. 어떻게 보면 개념적으로 컴포넌트의 private 변수와 비슷하다. usetState()가 필요한 이유는 매번 가상 DOM을 렌더링을 하는 과정은 처음부터 새로 호출해서 실제 DOM을 업데이트하게 된다. 그렇기 때문에 모든 로직이 새로 초기화된 상태에서 호출하면서 private 변수가 유지하지 못한다. 물론 컴포넌트에 인자를 통해서 값을 받아서 처리하거나 전역 변수를 정의해서 사용할 수도 있다. 인자로 받아서 처리하거나 전역 변수를 처리한다고 컴포넌트를 재사용할 경우 매번 같은 객체를 참조하는 문제가 발생한다. 또한 전역 변수는 사용에 있어서 매우 주의해야 한다. 그래서 useState()을 사용해서 컴포넌트의 private 변수를 정의할 수 있다. 그럼 useState()의 동작을 이해하고 구현해보자.

먼저 상태를 저장하고 관리하기 위한 공간이 필요하다. 다양한 데이터 구조가 가능하지만 여기서는 이해하기 쉽게 배열을 가지고 정의했다.

const stateStore = [];
let idxStateStore = 0;

stateStore은 상태가 저장되는 배열이다. idxStateStore은 현재 데이터 참조하기 위한 위치 값이 저장된 변수이다. 다음은 useState()의 기본적인 알고리즘이다.

  1. 참조 위치를 획득
  2. 참조위치에 상태가 없다면 초기 상태를 저장
  3. 참조위치는 최근 상태를 추출
  4. 참조 위치 상태의 setter를 정의
  5. 최근 상태와 setter를 반환

실제 코드를 살펴보자. 먼저 처음에는 사용한 상태가 없기 때문에 상태에 대한 새로운 공간을 할당해야 한다.

function useState(initState)( {
    const idx = idxStateStore++;
  if (idx === stateStore.length) {
    stateStore.push(initState);
  }
}

“idx = idxStateStore++”에 의해 현재 참조 위치를 가져오고 다음에 사용할 참조 위치를 위해 증가시킨다. 다음에 현재 참조 위치에 해당하는 stateStore에 데이터가 없다면 새롭게 상태를 할당해서 초기 상태를 저장한다.

다음으로 상태 추출 및 setter 정의하는 부분이다.

function useState(initState) {
    //...
  const state = stateStore[idx];
  const setState = (nextState) => {
    stateStore[idx] = nextState;
    renderAll();
  };
}

현재 참조 위치에서 최근 상태를 추출하여 state에 저장한다. 현재 참조위치의 상태를 변경할 수 있는 setter를 정의한다. 또한 setState()에 의해 상태가 변경되면 renderAll()을 통해 UI를 렌더링한다.

마지막으로 최근 상태와 setter를 배열 객체로 만들어서 반환한다.

function useState(initState) {
    //...
  return [state, setState];
}

그리고 마지막으로 중요한 부분이 새로 렌더링하여 화면을 그릴때 마다 참조위치 변수인 idxStateStore를 초기화해줘야 한다. 결국, 각 컴포넌트가 정확한 상태 정보를 가져올 수 있다.

  function renderAll() {
      idxStateStore = 0; // 중요!!!
    renderList.forEach((it) => {
          const vdoms = [it.render()].flat();
        render(it.parent, vdoms);
    });
  }

useState()의 핵심은 전체 UI 로직은 순서대로 호출하기 때문에 useState() 호출 순서마다 초기화했던 상태를 순서대로 꺼내서 사용하는 방식이다. 외부에 각 컴포넌트 호출할 때마다 접근할 외부 private 변수 관리하는 저장소를 만들다고 할 수 있다. 그렇기 때문에 같은 컴포넌트라고 해도 재사용하게 되면 개별적으로 상태가 분리해서 사용할 수 있다.

아래는 TableData 컴포넌트에 적용한 예이다.

function TableData(data) {
  let [items, setItems] = o.useState(data);

  function delItem(idx) {
      items.splice(idx, 1);
      setItems(items);
  }
  //...
}

사용 예

이를 적용한 사용예를 보자. 이전에 다루었던 UI에서 새로운 값을 입력받는 UI를 추가로 확장했다.

테스트 화면

먼저 HTML를 보자.

<main class="container my-4">
  <div id="msgToolbar"></div>
  <table id="msgList" class="table table-striped">
    <colgroup>
      <col width="10%" />
      <col width="75%" />
      <col width="15%" />
    </colgroup>
    <thead>
      <tr>
        <th>#</th>
        <th>Name</th>
        <th>Action</th>
      </tr>
    </thead>
    <tbody></tbody>
  </table>
  </ol>
</main>

HTML 코드를 보면 id가 msgToolbar인 div를 추가했다. 여기에 입력 텍스트 박스와 ADD 버튼이 추가하는 Toolbar()를 보자.

function ToolBar(data) {
  let [items, setItems] = useState(data);
  let [msg, setMsg] = useState("");

  function addItem() {
    if (!msg) return alert("Please, input a tag name.");
    items.push(msg);
    setMsg("");
    setItems(items);
  }

  return o.h(
    "div",
    { class: "row" },
    o.h(
      "div",
      { class: "col-2" },
      o.h("label", { class: "col-form-label", for: "inputMsg" }, "Tag")
    ),
    o.h(
      "div",
      { class: "col-8" },
      o.h("input", {
        type: "text",
        class: "form-control",
        value: "" + msg,
        id: "inputMsg",
        onInput(ev) {
          setMsg(ev.target.value);
        }
      })
    ),
    o.h(
      "div",
      { class: "col-2" },
      o.h(
        "button",
        {
          class: "btn btn-primary",
          onClick: addItem
        },
        "ADD"
      )
    )
  );
}

ToolBar()에서 입력 텍스트 폼과 ADD 버튼에 대한 가상 DOM을 정의했다. 그리고 입력 테스트에서 현재 msg값을 value에 설정해 입력된 값을 표시하고 onInput 이벤트 핸들러에 값이 변경되면 msg 상태에 저장한다. 또한 ADD 버튼이 클릭할 경우 현재 msg 값을 items에 추가하고 이 items을 setItems()를 통해 저장한다. 윤념할 부분이 msg와 items의 값을 변경한다고 해서 상태가 저장되는 것이 아니라 setMsg()나 setItems()를 호출해야 저장된다. 이는 앞의 useState() 구현코드를 보면 쉽게 이해된다.

다음으로 TableData()를 보자.

function TableData(data) {
  let [items, setItems] = useState(data);

  function delItem(idx) {
    items.splice(idx, 1);
    setItems(items);
  }

  return items.map((it, i) =>
    o.h(
      "tr",
      null,
      o.h("td", null, i),
      o.h("td", null, it),
      o.h(
        "td",
        null,
        o.h(
          "button",
          {
            class: "btn btn-danger",
            onClick: () => delItem(i)
          }, 
          "DEL"
        )
      )
    )
  );
}

이전에 구현 코드와 비슷하다. onClick()에서 delItem()만 있고 렌더링 호출은 사라졌다. delItem()에 의해 내부적으로 렌더링을 자동으로 시작한다.

마지막으로 이전 UI 컴포넌트를 적용하는 코드이다

let data = ["foo", "bar"];

o.App({ root: "#msgToolbar", render: () => ToolBar(data) });
o.App({ root: "#msgList tbody", render: () => TableData(data) });

코드가 더 깔끔해졌다. 기분탓인가?

추가 보완 사항

렌더링 목록에 다수 대상들이 있을 경우 renderAll()에 의해서 렌더링 소요시간이 다소 걸릴 경우가 많다. 그리고 setter에 의해서 변경이 빈번하게 호출될 경우 동일한 렌더링 작업이 여러 번 호출하게 된다. 그렇게 되면 필요없는 렌더링 작업이 반복되고 렌더링 시간이 오래 걸릴 수 있다. 이를 해결하기 위해 이미 렌더링 작업이 진행 중이라면 다시 렌더링할 필요 없이 현재 렌더링만 실행되고 추가 요청은 무시하는 전략을 선택하였다.

function renderAll() {
  debounce(() => {
    idxStateStore = 0;
    renderList.forEach((it) => render(it.parent, [it.render()].flat()));
  });
}

debounce()에 의해서 비동기적으로 실행하고, 실행 중인 경우 현재 호출을 취소한다.

  let isNextPending = false;
  function debounce(callback) {
    if (isNextPending) return;

    isNextPending = true;
    requestAnimationFrame(() => {
      callback();
      isNextPending = false;
    });
  }

코드 자체는 크게 어렵지 않다. isNextPending에 실행 중인 경우 true로 변경하고 끝나면 false로 변경한다. isNextPending가 true인 경우 실행 상태이므로 더이상 실행하지 않고 리턴한다. 주의할 부분은 debounce()는 callback 함수가 다르더라도 이를 고려하지 않고 동일하게 처리되기 때문에 오동작을 할 수 있다.

물론 추가로 갱신되는 데이터가 변경되었는지 확인해서 변경되지 않았다면 렌더링하지 않도록 할 수도 있다. 이때 고려야할 사항이 객체의 깊은 비교가 필요할 수 도 있다.

데모

https://codepen.io/ospace/embed/QwEvYoZ?default-tab=result

마무리

랜더링 개선 부분은 고민을 많이 한 부분이다. 리액터처럼 컴포넌트 기능을 강화시켜 컴포넌트를 활용하듯 UI에서 정의만 하면 바로 사용할 수 있게 할 것인지, 기존 함수를 정의해서 등록할지 등에 대해서 고민했다. 물론 vue.js 처럼 App 객체를 생성하는 방식도 고려했다. 그러나 이런 부분을 구현하기 위해서는 별도로 다뤄할 정도이기 때문에 여기서는 최대한 단순하게 처리했다. 그리고 App()에 함수명도 최상위 컴포넌트로 사용하는 메인 컨테이너 역활을 하기에 함수명에 사용했다. 그리고 추가적인 기능을 확정하기 위해서 객체형태로 App() 호출에 인자로 사용했다.

useState()는 배열 방식을 사용했지만 단방향 연결리스트 형태로도 구현할 수도 있다. 이는 구조가 확장에는 더 좋을 수 있지만 사용할 경우 고려할 부분이 조금 있어서 생략했다. 만약 실무에서라면 연결리스트를 사용했을 것이다. 추가로 usetState()에 의해 배열을 직접 조작하고 있다. 사실 이 방식은 지금은 문제는 없지만, 차후 렌더링 최적화에 상태 변경여부 확인할 때에는 문제가 발생할 수 있다.

마지막으로 UI 구조가 복잡해지고 갱신 작업이 빈번해질 수록 UI 렌더링 관리가 중요해지게 된다. 브라우저에서 화면을 리페인팅하는 경웅경우에 성능상 이슈들이 많이 있다. 아마도 UI 라이브러리를 제작할 경우 렌더링관련 성능 이슈로 고민이 많이 필요해 보이네요.

부족한 글이지만 여러분에게 도움이 되었으면 하네요. 모두 즐프하세요. ospace.

참조

[1] RookieAND, React의 state, 그리고 useState에 대해 더 알고 싶어졌다., https://velog.io/@rookieand/React의-state-그리고-useState에-대해-더-알고-싶어졌다, 2023/02/12

[2] ospace, 리액터 같은 UI 라이브러리 개발 1 - VDOM 만들기, https://ospace.tistory.com/1012, 2026/01/30

반응형