virtual DOM, hooks 바닐라로 구현

Kyung yup Lee·2021년 7월 20일
27

프론트엔드

목록 보기
18/20

조건

외부 라이브러리는 사용할 수 없었으며 번들링 라이브러리만 사용할 수 있었다. 그래서 예전에 자두 캘린더 프로젝트를 할 때 했었던, 상태에 따른 렌더링을 할 것인지, DOM 을 직접 조작할 것인지를 결정해야 했다.

두 번이나 똑같은 일을 하는건 발전이 없다 생각하고, 컴포넌트 기반 상태 렌더링을 시도해보기로 했다.

진행

그럼 이제 객체 컴포넌트를 사용할 것인지, 함수 컴포넌트를 사용할 것인지 선택해야 한다. 객체 컴포넌트는 상태를 객체 자체에서 관리할 수 있기 때문에, 개인적으로는 난이도가 쉽다고 느껴졌다. 가지고 있는 상태를 기반으로 렌더링함수를 작성한 후에, 각 컴포넌트 객체에 렌더링한 DOM 을 이어 붙여줄 노드를 전달만 해주면 그 안에 innerHTML을 통해 만들어주면 크게 어렵지 않게 구현할 수 있을 거라 생각했다.

그래서 함수 컴포넌트를 하기로 마음 먹었다. 쉽지 않을 것 같아서 선택한 것도 있지만, 함수 컴포넌트가 가독성이 훨씬 좋다고 생각한다.

또한 클래스 컴포넌트는 this 를 남발해야되는데, 이 this가 mutable하기 때문에 만들어내는 버그가 상당하다. 즉, 직관적이지 못하다.

함수 컴포넌트

시작

처음에는 그냥 모든 컴포넌트에서 html 문자열을 만들어내서 최상단 컴포넌트까지 리턴하고, 최상단 컴포넌트에서 innerHTML 로 한번에 돔 트리를 만들어 낼 생각을 했다.

const Component = () => {
 
  return `
	<div>컴포넌트</div>
`
}

const App = () => {
  const $root = document.getElementById('root');
  const components = Component();
  
  $root.innerHTML = components;
}

이런식으로 처리하면 동기 처리에서는 문제가 없지만, 네트워크나 이벤트처리가 불가능해진다. 즉 비동기 처리에서 문제가 발생한다. 왜냐하면 return 문은 비동기처리가 안되기 때문이다. 특정 비동기 처리를 기다리는 동안 return은 이미 다 완료되어 내가 요청한 데이터를 출력하는게 아니라, Promise 객체를 출력하던가 undefined를 출력하게 된다.

내가 원하던건 이런게 아니었기 때문에, 모두 폐기 처리하고 다시 고민을 시작했다.

개선

비동기 처리를 위해서는 비동기 처리가 끝나고 상태가 바뀔 때, 다시 렌더링을 해주는 함수가 필요하다고 생각했다. 즉, 상태의 변경을 감지해, 변경된 모든 컴포넌트를 반영하는 렌더링 함수가 필요했다. 그럴려면, 먼저 전체 컴포넌트를 가지고 있어야 한다.

물론 새로운 컴포넌트는 무조건 렌더링 해버린다고 하는 것도 가능하다. 하지만 렌더링은 브라우저의 성능에 가장 많은 영향을 끼치는 요소 중 하나이고, 이를 개선하지 못한다면 성능 저하가 명확하게 예상된다.

일단 가장 우선적으로 상태가 변화하면(비동기 처리가 완료되어 데이터를 받아오면) 재렌더링 해주는 함수가 필요했다. 이건 리액트의 hooks useState 와 비슷한 개념이라고 생각해, hooks 를 공부하기 시작했다.

hooks의 구현

말했듯이 바닐라 js 를 사용해야 하므로, 이 hooks를 직접 만들어서 써야 했다. 이를 위해 hooks의 내부 구현을 공부하기 시작했다. 먼저 가장
기본적으로 상태를 저장하고 이 상태를 변경시키는 함수를 작성했다.

const useState = initialState =>{
  let state = initialState;
  
  const setState = newState => {
     state = newState;
  }
  
  return [state, setState]
}

이렇게 훅스를 작성하고 렌더링 함수를 만들어 setState 안에 넣어주면 되겠다라고 생각했다. 오산이었다. 내가 함수 컴포넌트에 대해서 잘 모르고 있었다는 게 여실히 드러났다.
우리가 함수 컴포넌트에서 useState 를 사용하는 가장 일반적인 형태는 아래와 같다.

const Component = () =>{
  const [state, setState] = useState();
  
  return;
}

이 컴포넌트가 실제로 작동하는건 이 Component 함수가 호출될 때 이다. 즉, useState가 컴포넌트가 호출될 때마다 호출된다는 것. useState도 본질은 함수이다.

그러면 useState 가 매 컴포넌트가 호출될 때(재렌더링이 일어날 때) 마다 initialState를 전달하고 이 initialState로 상태가 초기화 되게 된다.

그러면 이 저장된 state를 기억해, initialState와 비교해 이미 가지고 있는 값이 있다면 유지하고, setState로만 이 상태를 변경할 수 있게 만들어주어야 한다.

여기서 써야하는 것이 클로저이다. 이미 useState의 setState가 클로저이다. 하지만 이 useState 또한 클로저로 만들어 상태를 저장해 두는 메모리가 필요한 것이다.

const hooks = (() =>{
   let stateStatus = null;

   const useState = initialState =>{
    let state = stateStatus || initialState;

    const setState = newState => {
       state = newState;
    }

    return [state, setState]
  } 
})()

이렇게 작성하면 useState 의 state를 저장해두고 함수 컴포넌트가 초기화 되더라도 저장된 state를 가져와서 initialState와 비교할 수 있다.

즉 hooks 를 한마디로 정의하자면 프로그램 전체 컴포넌트의 상태 저장소라고 할 수 있다. 지금까지 이 개념에 대해 이해하지 못했지만, 직접 구현해보니 알겠다. 모든 useState 함수는 이 hooks의 stateStatus 에 상태를 저장한다.

그러면 또 문제가 되는게, 지금의 hooks는 useState를 한 개밖에 사용할 수 없다. 왜냐하면 일반 변수이기 때문에, 새로운 useState가 선언되면 이미 state가 존재할 것이고, initialState 대신 기존의 상태를 적용할 것이기 때문에, 제대로 작동하지 않을 것이다.

이를 해결하기 위해 hooks의 상태 저장소를 배열로 선언해야 한다.

const hooks = (() =>{
  const stateStatus = [];
  let hookPointer = 0;
  
  const render = () =>{
    // 렌더 함수 호출
    hookPointer = 0; 
  }
  
  const useState = initialState =>{
    let state = stateStatus[hookPointer] || initialState;
    const pointerIdx = hookPointer;
    
    const setState = newState => {
      stateStatus[pointerIdx] = newState; 
      render();
    }
    hookPointer += 1;
    return [stateStatus[pointerIdx], setState];
  }
})()

상태 저장소를 배열로 만들고 이 배열의 포인터를 만들어줬다. useState 함수는 이 hookPointer를 pointerIdx 라는 변수에 담아두고, 이 변수를 계속 기억하게 될 것이다. (사실 기억하는 게 아니라, useState 선언 순서가 변하지 않으므로 항상 같은 pointer가 가리키는 걸 보장할 수 있다)

hooks 의 동작 순서는 아래와 같다.

  1. 컴포넌트에서 useState가 3개가 선언되어있다 하자.
  2. 컴포넌트가 렌더링 되기 위해서 호출된다.
  3. 선언된 useState를 호출한다.
  4. 가장 초기의 hookPointer는 항상 0이다. 렌더링할 때마다 hookPointer를 0으로 바꿔주기 때문에.
  5. useState는 호출되면서 기존에 stateStatus 배열에 내용이 저장되어있는지 확인하고 없다면 초기 상태를 저장한다.
  6. 그리고 이 hookPointer를 다음 useState를 위해 1을 더해준다. 그리고 자신의 포인터 인덱스도 기억해두어야 하기 때문에, 다른 변수에 옮겨 담아놓는다. (currentIdx)
  7. setState는 클로저기 때문에 이 setState가 호출될 때는 currentIdx 의 상태를 기억하고 있고, 이를 통해 stateStatus 배열에 접근해, 해당 상태를 변경할 수 있다.
  8. 해당 상태를 변경한다면, 상태가 변경되었으므르 렌더링을 다시 해주고 hookPointer를 0으로 초기화 해준다. 그러면 다시 useState를 호출하게 되고 위의 내용이 반복된다.

정말 어려운 내용이었다. 삽질도 많이 했다. 하지만 역시 이 hooks를 이용해 상태에 따른 렌더링을 구현했을 때 쾌감은 개발을 계속하게 하는 원동력이다.

렌더 함수

객체 리턴

그러면 이제 렌더링을 어떻게 할 지 고민해야 한다. 그냥 모든 리턴을 문자열로 하고 루트에서 innerHTML을 하는 방법이 있다.

하지만 렌더링은 로딩 중에 가장 많은 시간을 소요한다는 점, 상태 변화는 자주 일어날 것이라는 점(사용자와의 모든 인터렉션은 상태변화이다) 에서 이런 전체 렌더링을 다시 하는 것은 비효율적이라는 생각이 들었다.

그래서 문자열을 리턴하는 것이 아닌, 객체를 리턴해서 virtual DOM 을 구현해봐야 겠다고 생각했다. 아이러니하게도 함수 컴포넌트를 작성하면서 문자열을 리턴해야 겠다고 생각한 건 JSX 때문이었다. 하지만 객체를 리턴해야 겠다고 생각하게 된것도 JSX 였다. 리액트에서 JSX가 파싱되어 객체를 만들어낸다는 것이 머리를 스쳐갔고, 해당 부분에서 아이디어를 얻어 자바스크립트 객체를 반환하는 것을 생각했다. 그리고 이 리턴된 자바스크립트 객체 트리를 이용해서 실제 DOM 을 만들어내는 방법을 생각했다. 이 방식이 virtual DOM 과 비슷한 구현방법일 거라 생각했다.

const App = () => {
  const [state, setState] = hooks.useState([]);

  return {
    type: 'div',
    props: [{ class: 'container' }],
    children: [{
      type: 'section',
      props: [],
      children: [list(state, setState)],
    },
    {
      type: 'section',
      props: [],
      children: [Form(state, setState)],
    },
    ],
  };
};

위와 같이 헤드 컴포넌트를 구성했다.

참고
왜 jsx에서 두 개이상의 헤드 컴포넌트를 가질 경우에 반드시 빈 fragment 를 두어야 하는지도 알 수 있었다. return 하는 노드가 하나여야지 타고 들어갈 수 있기 때문.

이렇게 구성하면 div 노드 아래에 section 노드 두 개 그리고 또 다시 컴포넌트를 만들어 트리 형식으로 객체가 만들어지게 된다.

지금 생각해보니 굳이 section을 두 개 만들 필요가 없다. div 의 children에 각각 컴포넌트를 호출해 주면 layer 하나를 줄일 수 있다.

virtual DOM 트리 저장

먼저 나의 기본적인 논리는 이전 virtual DOM 트리와 상태 변화 이후의 VDOM 트리를 비교하는 것이다. 이를 위해서 이전 VDOM 트리를 저장해두는 공간이 필요했다.

const DOM = (() => {
  let _tree = null;
  return {
    setTree: newNode => {
      _tree = newNode;
    },
    getTree: () => _tree,
  };
})();

간단하게 한번도 렌더링 되지 않았다면, null로 초기화 되어있고, 그 이후에 렌더링된다면 tree에 저장되게 된다.

그리고 본격적으로 VDOM을 비교하면서 실제 DOM 트리를 만들어내는 함수를 만들어야 했다.

VDOM handler 함수

VDOM 을 비교하는 방법은 정말 추상화 하면 3가지이다.

  • 노드가 추가되거나,
  • 노드가 삭제되었거나,
  • 노드가 변경되었거나,

이 세 가지 부분을 처리하면 가장 기본적인 렌더링이 가능하다.

createElement

일단 가장 중요한 것은 새로운 노드를 생성해내는 것이다. 왜냐하면 첫 렌더링은 무조건 모든 노드를 새로 만들어내는 것이기 때문에.

const createElement = node => {
      if (typeof node === 'string') {
        return document.createTextNode(node);
      }
      const $el = document.createElement(node.type);
      node.props.forEach(item => $el.setAttribute(Object.keys(item)[0], Object.values(item)[0]));
      node.children.map(createElement).forEach($el.appendChild.bind($el));
      return $el;
    };

가장 먼저 생각해야 할 것은, 해당 노드가 텍스트 노드인지 확인하는 것이다. 대부분의 리프 노드는 텍스트이다. 그래서 이 부분을 먼저 체크해줘서 해당 노드가 텍스트 노드라면 텍스트 노드를 만들어낸다. 그리고 이 텍스트 노드를 리턴해 붙여준다.

만약 텍스트 노드가 아니라면 새로운 타입(tag)의 노드를 만들어낸다. 그리고 property가 존재한다면 해당 프로퍼티들을 순회하면서 setAttribute 해준다. 마지막으로 자식 요소들을 재귀적으로 순회해야 한다.
재귀적으로 순회하면서 모든 노드를 돔 요소로 만들어준 다음, 상위 노드에 연결해주어야 한다. 이 과정을 forEach 에서 해준다.

$el.appendChild 라는 함수(메서드)를 콜백함수로 넘겨주면, 자동으로 인수로 각각의 돔요소가 넘어간다. 이 과정에서 콜백함수로 넘어가면 this 가 전역객체를 가리키기 때문에, 이 this를 $el 로 다시 바인딩해주는 과정이 필요하다.

updateElement

이제 노드를 모두 비교해가면서 추가, 삭제, 변경을 탐색해주어야 한다.

먼저 나는 VDOM 의 루트 부터 모두 비교하기 때문에, 이전 트리를 항상 저장해두어야 한다. 렌더링을 할 때마다 저장이 일어나야 되기 때문에 update의 시작점에서 시작 노드를 항상 저장해야 한다.

 const updateElement = ($parent, newNode, oldNode, index = 0) => {
      // old 돔트리를 바꿔줌
      if (!isSavedTree) {
        DOM.setTree(newNode);
        isSavedTree = true;
      }
      // 바뀌는 V돔 트리에만 노드가 존재하는 경우(추가)
      if (!oldNode) {
        $parent.appendChild(createElement(newNode));
      }
      // 바뀌는 V돔 트리에 노드가 없는 경우(삭제)
      else if (!newNode) {
        $parent.removeChild($parent.childNodes[index]);
      }
      // 변경이 있는 경우(변경)
      else if (changed(newNode, oldNode)) {
        $parent.replaceChild(createElement(newNode), $parent.childNodes[index]);
      }
      // 완전히 같은 상태일 경우, 텍스트 노드가 아니면
      else if (newNode.type) {
        for (let i = 0; i < newNode.children.length || i < oldNode.children.length; i++) {
          // 재귀적으로 하위 노드를 타고 들어감
          updateElement(
            $parent.childNodes[index],
            newNode.children[i],
            oldNode.children[i],
            i,
          );
        }
      }
    };
    updateElement($parent, newNode, oldNode, index);
    isSavedTree = false;
  };

이게 전체 업데이트 코드이다. 그 중에

      if (!isSavedTree) {
        DOM.setTree(newNode);
        isSavedTree = true;
      }

이 부분이 트리가 저장되었는지 확인하는 코드이고, 시작 부분에서만 실행되도록 구현했다.

양쪽 트리에서 모두 노드가 존재하지 않는다면 (한쪽만 존재한다면) 재귀를 타고 들어올 때, 둘 중 하나는 인수가 전달되지 않을 것이다. oldNode 가 없는 경우는 새로운 노드가 추가된 경우일 것이고, newNode가 없는 경우는 해당 노드가 삭제되었다는 뜻일 것이다.

이를 처리하기 위해 반드시 부모 노드를 전달해야 한다. 왜냐하면 부모 노드의 하위 노드를 삭제하거나 새로 그 부모노드에 추가해주는 것이기 때문이다. 시작 점은 $root 노드이다.

changed 함수

다음은 변경이 있는 경우를 처리해야 한다.
논리는 아래와 같다.
1. 노드의 타입이 다른가? 노드에는 문자열과, 객체가 있다. 이 타입이 다르다면 true를 리턴한다.
이 타입이 같다면 false를 리턴한다. 즉, 둘 다 문자열이거나, 둘 다 객체이다.

2-1. 1번에서 true를 리턴한 경우 둘의 타입이 다르다는 뜻이다. or 연산에서는 앞에 연산이 true라면 뒤를 연산하지 않는다.

2-2. 1번에서 false를 리턴한 경우 둘의 타입이 같다는 뜻이다. 이 때는 or 연산의 뒷부분을 확인해야 한다. 만약 타입이 String이라면 true이므로 뒤의 and 연산을 실행해야 한다. 객체라면 and 연산을 실행하지 않고 false를 반환한다.

3-1. 둘의 타입이 다른 경우 node1 이 string 인지 상관없이 true를 반환해 node1, node2 를 비교한다. 하지만 둘의 타입이 다른데 같을 수가 없다. 바로 true를 반환한다. 뒤는 or 연산자이기 때문에 연산하지 않고 true를 리턴한다. 즉 변화가 일어난 것.

3-2. 노드의 타입이 같고, 객체라면 객체 내부의 값들을 비교해서 확인해야 한다. props 가 같은지, type이 같은지 확인해서 하나라도 다르다면 변화가 있는 것이다.

여기서 하나 실수 한게, 모든 VDOM은 매번 재생성되기 때문에 객체 자체를 비교하면 항상 다르게 나온다. props 내부의 값들을 비교해주는 과정이 필요했는데, 이 과정을 놓쳤다.

정리하자면 changed 함수에서 실행하는 것은

둘의 노드를 비교해서 객체인지 문자열인지 확인해서 객체라면

노드의 타입이 같다면, 이 노드가 문자열인지 객체인지 확인해야 한다. 둘의 타입이 다르다면? 당연히 객체값도 다를 것이고 변화가 일어난 것. 만약 둘의 타입이 같다면, 문자열인지 확인해야 한다. 문자열인지 확인해서 문자열이라면, 변화가 있었는지 확인. 문자열이 아니라면, 둘의 props와 type이 같은지 확인. 이 과정이다.

여기에서 최적화가 더 가능할 것 같은데 현재는 변화사항을 감지하면 이 노드부터 하위 노드까지 모두 리렌더링을 하게 된다. 하지만, 이 변경사항에 따라 property면 property만 바꾸고, 하는 등에 전체 리렌더링은 하지 않는 방법이 가능하다고 생각한다.

    const changed = (node1, node2) => 
    	typeof node1 !== typeof node2
        || typeof node1 === 'string'
        && node1 !== node2 
        || node1.props !== node2.props
        || node1.type !== node2.type;
      // 바뀌는 V돔 트리에만 노드가 존재하는 경우(추가)
      if (!oldNode) {
        $parent.appendChild(createElement(newNode));
      }

가장 첫번째로 oldNode 가 undefined일 경우, newNode만이 존재한다는 거니까 노드를 생성해서 추가해야 한다는 것이다.

      // 바뀌는 V돔 트리에만 노드가 존재하는 경우(추가)
      else if (!newNode) {
        $parent.removeChild($parent.childNodes[index]);
      }

newNode가 undefined일 경우 과거에는 존재하는데 지금은 없다는 것이다. 즉 삭제 되었다는것. 그러므로 해당 노드를 삭제해 주어야 한다.

      else if (changed(newNode, oldNode)) {
        $parent.replaceChild(createElement(newNode), $parent.childNodes[index]);
      }

changed 함수를 통해 변경을 확인했다면, 새로운 노드로 대체해주어야 한다.

이제 현재 노드에서 할 수 있는 처리를 모두 했다. 만약 이 과정을 모두 거치고도 살아남은 노드라면, 변화가 없는 객체 노드라는 뜻이다. 그러면 이제 하위 노드로 타고 들어가 다시 변화를 찾아야 할 것이다. 이 때 재귀함수를 사용한다.

      else if (newNode.type) {
        for (let i = 0; i < newNode.children.length || i < oldNode.children.length; i++) {
          // 재귀적으로 하위 노드를 타고 들어감
          updateElement(
            $parent.childNodes[index],  
            newNode.children[i],
            oldNode.children[i],
            i,
          );
        }
      }

이런 방식으로 렌더함수를 구현했다. 인터넷에 떠돌아다니는 바닐라로 구현하는 virtual DOM 같은 내용을 많이 참고했고, 내가 구현할 수 있는 수준으로 최적화 했다.

특히 컴포넌트 단위의 재렌더링은 내가 구현을 못하겠어서 모든 재렌더링은 루트에서 시작한다. 이게 어려웠던 이유는 컴포넌트의 시작 노드를 돔트리에서 찾는 걸 못하겠어서이다. 특정 DOM 요소를 취득하기 위해서는 태그나, 클래스, 아이디로 받아와야되는데, 훅에서 렌더링을 할 때, 이 부분을 취득할 수 있는 방법이 생각이 나지 않았다.

지금 생각나는건, 내부적으로 useState를 초기화할 때, 호출하는 컴포넌트가 있으므로 각 컴포넌트가 특정 id를 가지고 있고, 이 id를 돔 요소가 가지고 렌더링되게 만들면 컴포넌트가 렌더링할 때, 이 id를 통해서 취득할 수 있지 않을까...? 라는 생각도 해본다.

이벤트 핸들링

jsx 를 보면 태그의 property에 onClick ={함수} 이런 식으로 달아놓으면 이벤트 핸들링을 자동으로 해준다. 어떤 방식인지 알아내지 못하겠어서, 이게 html 태그 내부에 이벤트를 달아주는 방식인건가...? 했는데, 그 방식은 안티패턴이라고 배웠기 때문에 배제했다.

그래서 이벤트 핸들링을 하는 함수를 따로 만들어주었다.

const eventHandler = (() => {
  const $root = document.getElementById('root');
  const addEvent = (eventType, cb) => {
    $root.addEventListener(eventType, cb);
    // 버블링을 이용한 이벤트 핸들링
  };
  return {
    addEvent,
  };
})();

먼저 root는 html 파일에 있기 때문에 반드시 찾을 수 있는 노드이다. 그래서 이 루트 노드를 기준으로 이벤트 버블링을 이용했다. 이벤트는 최상위 객체부터 타겟노드까지 캡쳐링이 일어나고, 다시 최상위 객체까지 버블링이 일어난다. 이를 캐치해서 사용하도록 했다.

  if (commentsState.length === 0) {
    eventHandler.addEvent('keyup', submitController(setState).submit);
    eventHandler.addEvent('input', typeInput);
  }

keyup 이벤트와 input 이벤트를 등록했다. keyup 이벤트의 경우 엔터를 감지하는데 사용한다. 또한 input은 실제로 입력될 시에, 글자 수를 세서 validate하는 함수를 구현하기 위해 사용한다.

이렇게 루트로부터 버블링을 시키는 이유는 내가 이벤트를 달아주려는 노드가 아직 DOM 요소로 만들어지지 않았을 수 있기 때문이다. 이런 경우에 해당 DOM 요소에 접근하려고 하면 존재하지 않는 노드에 접근하려고 하는 것이기 때문에 에러가 발생한다. root 돔 요소는 반드시 존재하기 때문에, 이런 문제를 피할 수 있다.

아쉬운 부분

상태에 따른 렌더링이 너무 어려워 DOM을 직접 조작

인풋을 구현하면서 인풋 상태에 따라 class 를 다르게 해서 렌더링을 하고 싶었다. 상태 기반의 웹 프로그램을 작성한다면 모든 렌더링은 상태 데이터가 변화할 때 일어나야 한다. 하지만 input의 데이터의 변화가 일어날 때 리렌더링이 일어나고 input의 내용이 모두 지워진 후 포커스가 아웃되는 현상이 일어났다.

결국 이 문제를 해결하지 못하고 돔 요소를 직접 조작했다. 리액트에서 value 프로퍼티에 상태값을 넣어줘서 이를 유지하는 걸 알았는데, 내가 해당 돔 요소에 value 값을 넣어주니 작동하지 않았다.

해결방법을 조금 찾음
이벤트 콜백 함수가 실행될 때 외부 변수를 참조하고 있는데, 이 이벤트 바인딩이 계속 유지되다 보니, 외부 변수의 초기값에서 갱신이 되지 않아, 생기는 문제였음. 이를 위해 이벤트 바인딩을 리덴더링할 때마다, 갱신해주는 코드를 넣었더니, 값을 유지하기는 함.

but 인풋이 발생할 때 마다 리렌더링이 발생해, 포커스를 잃음.

이 부분은 해결을 못했다. 해결 했다.
문제는 여러 가지 였다. 이벤트 리스너 중복 등록이 첫번째 문제, 이벤트 리스너가 등록할 때의 상태를 기억해, 계속 똑같은 값을 시작값으로 삼는 것, focus 아웃 문제가 있었다.

.onevent 메서드를 이용해 해결했다. addEventListener는 이벤트에 대해 이벤트 콜백을 중복등록할 수 있게 해준다. 하지만 onevent 메서드는 이 중복등록을 막고 마지막에 등록한 콜백만 사용할 수 있게 만들어준다. 이를 사용하니, 이벤트 리스너 중복 문제와 상태를 기억하는 문제를 해결했다. 또한 focus는 특정 돔 요소로 focus를 주는 focus() 메소드가 있어 이를 통해 해결했다.

크로스 브라우징을 위해 XHR을 사용했는데, promise를 사용하지 않음

콜백 함수를 사용했다. 하지만 콜백함수는 가독성이 떨어지고, 프로그램의 복잡도를 높히며, 에러처리에 불리하다. 그러므로 async await를 접목한 프로미스를 사용하는 것이 좋다.

네트워크 요청을 연속해서 보냈을 때, 마지막 한번만 요청할 것인가, 모두 쌓아둘 것인가?

이건 합의의 영역이라고 생각한다. 나는 광클을 할 경우를 고려해야 한다고 생각했다. 하지만 그 만큼 서버 부하가 늘어나는 것은 알고 있어야 한다.

최적화해서 VDOM 핸들러를 수정하면 리플로우를 최적화할 수 있다고 생각

현재 노드에서 변경이 일어나는 부분은 텍스트 뿐이다. 그 외에는 삭제나 추가밖에 일어나지 않는다. 그러므로 앱에 최적화되는 VDOM 핸들러를 구성한다면, 변경이 일어난다면 text 노드만 변경해주는 것이 가능하다고 생각한다. 이렇게 하면 리플로우가 적게 발생하기 때문에, 성능이 개선될 수 있다.

전역상태에 대한 고민

중간에 두 개의 형제 컴포넌트에서 함께 사용하는 상태가 있었는데, 이를 위해 부모 컴포넌트에서 해당 useState 를 호출해서 props로 내려주는 코드가 있다. 전역 상태를 사용한다면 이런 불필요한 코드를 줄일 수 있지 않을까? 또한 session storage 를 사용하면서 변수의 상태에 대해 고민하지 않았는데, 사실 pageNum 과 comments 의 상태는 독립적이지 않다. pageNum 변수가 comments의 상태를 유발하는 것이다. 그러므로 pageNum 도 상태관리를 하는게 맞다.

전역상태 관리 라이브러리를 한 번도 사용해보지 않아서 해당 내용을 어떻게 구현해야 될지는 잘 모르겠지만, 전역객체를 만들고 상태 변화에 대한 함수를 전역 객체에 위임하는 방식을 사용할 것 같다. 현재 훅에서 setState를 통해 상태를 변화시키고, render 함수를 호출하는데 이를 전역 상태관리 하는 주체에 위임하는 것이다.

기타 구현 내용

모듈

모듈을 사용했다. 모듈을 사용하면, 유지보수성, 네임스페이스화, 재사용성을 챙길 수 있다. 코드를 분리시킴으로써 유지보수할 부분을 구분하고 파일 명을 부여해 더 쉽게 해당 코드부분을 찾을 수 있도록 해준다. 또한 모듈은 파일 스코프를 가져 전역 스코프에 의해 변수가 오염되지 않도록 해준다. 모듈을 분리해 기능을 구분해 놓으면, 다른 곳에서 여러번 호출해서 사용하도록 할 수 있다. 특히 이번에 만든 virtual DOM 함수와 hooks는 어디서나 호출해서 사용할 수 있다.

세션 스토리지

사용성을 위해 세션 스토리지를 사용했다. 세션 스토리지는 탭이 유지되는 동안만 데이터가 유지되는데, 새로고침이나, 다른 탭에 이동했다가 돌아오는 경우, 해당 페이지 데이터를 유지할 수 있도록 해 사용성을 높혔다.

즉시 실행함수

즉시 실행함수는 함수가 정의되자마자 실행되는 함수를 뜻한다. 즉시 실행함수는 전역변수 오염을 막기 위해 사용되거나, 은닉화를 위해 사용된다. 함수 실행 결과를 특정 변수에 저장해두고, 한번만 실행하도록 해 객체 처럼 사용하는 방법을 많이 사용한다. 일반적으로 함수는 호출되면 내부 변수를 모두 초기화 하기 때문에, 클로저로 사용할 수 없다. 그렇기 때문에 즉시 실행 함수를 사용해, 한번의 호출만 해서 사용해야 한다. 즉, 상태를 저장하고 유지해야 되는 경우 즉시 실행 함수를 사용한다.

널 병합 연산자

querySelector를 사용할 때 쓰는데, 참조하는 객체가 null이거나 undefined 일 경우 일반적으로 에러를 뱉는다. 하지만 이런 에러 없이 null 이나 undefined를 리턴하도록 해, 프로그램이 유지되도록 막아준다.

profile
성장하는 개발자

3개의 댓글

comment-user-thumbnail
2021년 8월 25일

완성된 코드의 레포지토리는 없을까요?? ㅠㅠ

1개의 답글
comment-user-thumbnail
2021년 8월 27일

너무 좋은 내용이네요..... 감사합니다!

답글 달기