[Vanilla JS 문서편집기 설명 및 개선] 3. 사이드바 렌더링 최적화 및 성능 비용 절감

사이드바 렌더링 최적화

기본적으로 사이드바의 동작은 페이지를 눌렀을 때 하위 항목을 보여주도록 되어있습니다. 

이때, 기존 코드에선 사이드바 전체를 렌더링하고 있고 이 방식은 이미 존재하는 노드를 반복해서 그리기 때문에 자원을 낭비하게 됩니다. 따라서 렌더링을 최적화하기 위해 변경되는 부분만 추가&삭제하는 방식으로 렌더링 최적화를 하려 합니다. 

 

사이드바 렌더링을 최적화하기 위해 다음과 같은 순서로 진행하였습니다. 

1. 페이지의 열림 닫힘 여부 판단

set을 이용해 열린 페이지의 id를 저장해 놓습니다. 

만약 닫혀있는 페이지를 누르면 set에 해당 id가 입력되고, 열려있는 페이지를 누르면 set에 존재하는 해당 id가 삭제되는 방식으로 동작하게 합니다. (set의 시간복잡도는 O(1)이기 때문에 효율적이라 판단해 사용)

 

정리하면, openedDetail에 id가 존재한다면 열려있는 페이지이고 그렇지 않다면 닫혀있는 페이지겠죠?

export default class SideBarPages {
  constructor({ $target, initialState, editorsetState }) {
	...
	this.openedDetail = new Set();
	...
	}
    
	setOpendDetail = (id) => {
    	if (!this.openedDetail.has(id)) {
        this.openedDetail.add(id);
      } else {
        this.openedDetail.delete(id);
      }
  }
}

 

2. 페이지의 열림 닫힘 여부에 따라 선택 요소 노드 교체하기

  1. querySelector를 이용해 클릭한 노드를 선택한다.
  2. findClickedById를 통해 자신이 클릭한 객체만을 가져온다. (하위 목록 포함)
  3. replaceWith을 통해 해당 노드 만 바꿔치기한다. 
setOpenedDetail = (id) => {
    const updatedFileContainer = this.$sideBarPages.querySelector(
      `.sidebar__pages--container [data-id="${id}"]`
    );

    if (updatedFileContainer) {
      if (!this.openedDetail.has(id)) {
        this.openedDetail.add(id);
      } else {
        this.openedDetail.delete(id);
      }
      const selectedList = [this.findClickedById(this.state.list, id)];
      updatedFileContainer.replaceWith(
        this.printFile(this.$sideBarPages, selectedList)
      );
    }
  };
  
  findClickedById(data, targetId) {
    for (const item of data) {
      if (item.id == targetId) {
        return item;
      } else {
        if (item.documents && item.documents.length) {
          const parent = this.findClickedById(item.documents, targetId);
          if (parent !== null) {
            return parent;
          }
        }
      }
    }
    return null;
  }

 

문제 발생 및 해결

해당 코드로 실행을 시켜본 결과 제 생각과 다르게 다시 눌러도 페이지가 없어지지도 않고, 반복적으로 누르면 오히려 같은 노드가 점점 누적되는 것을 볼 수 있습니다. 😭

 

replaceWith을 통해 해당 노드 만 바꿔치기할 수 있을 줄 알았는데 어떤 게 문제였을까요?

 

기존 노드는 다음과 같이 sidebar__pages--container로 한번 감싸주고 하위 페이지를 만들어주는 방식으로 만들어져 있습니다. container에 data-id가 지정되어 있지 않아 다음의 코드로 원하는 노드를 가져올 수 없었고, 이 때문에 대체가 되긴 하는데 선택되는 노드가 원하는 부분이 아닌 다른 부분이 잡혀 문제가 발생하는 모습을 볼 수 있었던 것입니다. 

const updatedFileContainer = this.$sideBarPages.querySelector(
      `.sidebar__pages--container [data-id="${id}"]`
    );

 

해결 방법

sidebar__pages--container에 data-id를 추가해 줍니다. 

(이전에는 이미 detail이 container의 자식요소이기 때문에 가져올 수 있었습니다.)

data-id를 통해 원하는 위치가 선택되어 잘 동작됨을 알 수 있습니다. 

 

성능 비용 절감

현재 contents와 toolkit의 배치를 위해 아래와 같이 구조를 구축했고,

toolkit의 경우 sidebar__pages--detail-container에 마우스를 올렸을 때(hover) toolkit 노드를 추가, 제거하는 방식으로 구현했습니다. 

eventAdd() {
    const $sideBarPagesDetailsToolkit = document.createElement("div");
    const $targetContainer = this.$sideBarPagesDetails.querySelector(
      ".sidebar__pages--detail-container"
    );
    const icon = this.$sideBarPagesDetails.querySelector(
      ".sidebar__pages--detail-button .material-symbols-rounded"
    );
    let isHovered = false;

    this.$sideBarPagesDetails.addEventListener("mouseover", () => {
      if (!isHovered) {
        if (this.openedDetail.has(this.state.id)) {
          icon.textContent = "keyboard_arrow_down";
        } else icon.textContent = "keyboard_arrow_right";
        isHovered = true;
        $sideBarPagesDetailsToolkit.className =
          "sidebar__pages--detail-toolkit";
        $sideBarPagesDetailsToolkit.innerHTML = `
          <button class = "sidebar__pages--detail-button" data-action='remove'>
            <span class = "material-symbols-rounded"> remove </span>
          </button>
          <button class = "sidebar__pages--detail-button" data-action='add'>
            <span class = "material-symbols-rounded"> add </span>
          </button>
        `;
        $targetContainer.appendChild($sideBarPagesDetailsToolkit);
        this.$sideBarPagesDetails.addEventListener(
          "click",
          this.toolkitEventAdd
        );
      }
    });

    this.$sideBarPagesDetails.addEventListener("mouseleave", () => {
      icon.textContent = "description";
      isHovered = false;
      $targetContainer.removeChild($sideBarPagesDetailsToolkit);
    });
  }

하지만 여기서 발견한 문제는 노드를  동적으로 추가, 제거할 때의 비용이 CSS로 노드를  보이게/숨기게 하는 비용보다 비싸다는 것입니다. 

 

그렇기 때문에 기존의 방식을 버리고 노드를 미리 추가해 놓은 뒤 CSS로 조절하는 방식으로 수정했습니다. 

(+ sidebar__pages--detail과 sidebar__pages--detail-container의 하는 일이 비슷해 분리할 이유가 없다 생각해  sidebar__pages--detail만을 사용하도록 변경했습니다.)

...
	constructor({..}){
    	...
        this.$sideBarPagesDetails = document.createElement("li");
        this.$sideBarPagesDetails.className = "sidebar__pages--detail";
        this.$sideBarPagesDetails.setAttribute("data-id", this.state.id);
        this.$sideBarPagesDetails.style.setProperty("--depth", this.$depth);
        this.render();
        ...
    }
    render() {
    this.$sideBarPagesDetails.innerHTML = `
      <div class = "sidebar__pages--detail-contents">
        <button class = "sidebar__pages--detail-button" data-action='toggle'>
          <span class = "material-symbols-rounded"> description </span>
        </button>
        <span class = 'sidebar__pages--detail-title'>
          ${this.state.title || "제목 없음"}
        </span>
      </div>
      <div class = "sidebar__pages--detail-toolkit">
        <button class = "sidebar__pages--detail-button" data-action='remove'>
          <span class = "material-symbols-rounded"> remove </span>
        </button>
        <button class = "sidebar__pages--detail-button" data-action='add'>
          <span class = "material-symbols-rounded"> add </span>
        </button>
      </div>
    `;
    this.$target.appendChild(this.$sideBarPagesDetails);
  }
...
    
    
css

.sidebar__pages--detail-toolkit {
  display: none;
}

.sidebar__pages--detail:hover .sidebar__pages--detail-toolkit {
  display: flex;
}

 

data-action의 경우엔 이벤트를 위임하는 방법으로 링크를 참고해주세요.

 

"하위 페이지 없음" 중복 생성 오류 해결

현재 하위 페이지를 열어놓고 상위 페이지를 닫았다 연뒤 하위 페이지를 닫았다 열면 "하위 페이지 없음"이 해당 행위를 반복할 때마다 늘어나는 문제가 있는데 이를 해결한 방법을 소개하려 합니다.

페이지를 열고 닫았을 때 다음 노드 구조를 갖고 있습니다.

페이지 열었을 때
페이지 닫았을 때

그리고 하위 페이지를 열어놓고 상위 페이지를 닫았을 때, 하위 페이지를 열고 닫을 때 다음 노드 구조를 가집니다. 

하위 페이지를 열어놓고 상위 페이지를 닫았다 열었을 때
위의 상태에서 하위 페이지를 닫았을 때
위의 상태에서 하위 페이지를 다시 열었을 때

여기서 차이점을 발견하셨나요?

 

 

차이점은 바로 처음 DOM을 그릴 때는 한 겹의 sidebar__pages--container로 감싸져 있지 않다 부분재 랜더링시 추가적인 sidebar__pages--container로 감싸진다는 점입니다. 

<ul class="sidebar__pages--container" data-id="89756">
<ul class="sidebar__pages--container" data-id="94739">
    <li class="sidebar__pages--detail" data-id="94739" style="--depth: 1;">...</li>
    <div class="sidebar__pages--empty" style="--depth: 2;">하위 페이지 없음</div>
</ul>
</ul>


<ul class="sidebar__pages--container" data-id="89756">
	<li class="sidebar__pages--detail" data-id="94739" style="--depth: 1;">...</li>
    <div class="sidebar__pages--empty" style="--depth: 2;">하위 페이지 없음</div>
</ul>

기존 코드에선 해당 노드는 코드가 변경하는 동작 범위 밖이었기 때문에 제거가 되지 않고 그대로 있던 것이었습니다.

 

그렇기 때문에 저는 3가지의 해결방법이 있다고 판단했습니다.

  1. container를 일괄적으로 추가되지 않도록 만들기
  2. 일괄적으로 container를 추가되도록 만들기
  3. 기존 방식을 유지하되 "sidebar__pages--empty"가 존재하는지 인식해 제거하기

1. container를 일괄적으로 추가되지 않도록 만들기

기존 노드에 변경되는 노드를 대체하는 방식으로 구현할 수 있는데, 불필요한 DOM 노드를 생성하지 않고 요소를 그룹화하기 위해 fragment를 사용했습니다.

const newPrintFileElement = this.printFile(
      this.$sideBarPages,
      selectedList.documents,
      selectedList.id,
      depth
    );

const fragment = document.createDocumentFragment();

fragment.appendChild(updatedFileContainer.cloneNode(true)); 
fragment.appendChild(newPrintFileElement); 

updatedFileContainer.replaceWith(fragment);

제 의도대로 노드의 형태는 잡혔지만, cloneNode를 하게 되면서 기존에 적용되었던 event listener들은 복사가 되지 않기 때문에 동작이 의도대로 되지 않았습니다. 

 

2. 일괄적으로 container를 추가되도록 만들기

이 방법은 코드의 많은 변경을 요구했고, 불필요한 부분도 노드를 추가해 주는 방식이기 때문에 비용면에서 비효율적이라 생각해 구현하지 않았습니다.

 

3. 기존 방식을 유지하되 "sidebar__pages--empty"가 존재하는지 인식해 제거하기

처음엔 printFile 위치에서 "sidebar__pages--empty" 클래스명을 가진 노드가 존재하면 추가하지 않도록 작성했는데, 

else 부분에서 sidebar__pages--empty를 추가하기 전 판단하기 위해 $ target을 출력해 JS로 완성된 노드가 출력은 되지만 querySelector로 가져올 때는 찾을 수 없다는 것을 발견했습니다. 

 

해당 노드를 appendChild를 하기 전이기 때문에 실제 DOM에서는 해당 부분을 찾을 수 없었기 때문입니다.

printFile = ($target, pageList, parentId = "", depth = 0) => {
    if (pageList.length) {
      let $sideBarPagesContainer = document.createElement("ul");
      $sideBarPagesContainer.className = "sidebar__pages--container";
      $sideBarPagesContainer.setAttribute("data-id", parentId);
      pageList.map((pageObject) => {
        new SideBarPagesDetails({
          $target: $sideBarPagesContainer,
          pageObject,
          setOpenedDetail: this.setOpenedDetail,
          openedDetail: this.openedDetail,
          depth,
        });
        if (this.openedDetail.has(pageObject.id)) {
          $sideBarPagesContainer.appendChild(
            this.printFile(
              $sideBarPagesContainer,
              pageObject.documents,
              pageObject.id,
              depth + 1
            )
          );
        }
      });
      return $sideBarPagesContainer;
    } else {
      let $sideBarPagesContainerEmpty = document.createElement("div");
      $sideBarPagesContainerEmpty.className = "sidebar__pages--empty";
      $sideBarPagesContainerEmpty.textContent = "하위 페이지 없음";
      $sideBarPagesContainerEmpty.style.setProperty("--depth", depth);
      return $sideBarPagesContainerEmpty;
    }
  };

 

그렇기 때문에 판단하는 위치를 변경해 setOpendDetail 함수에서 판단을 한 뒤 필요 없는 "sidebar__pages--empty" 노드를 지우고 새로 만드는 방식으로 변경을 했습니다. 

setOpenedDetail = (id) => {
    const updatedFileContainer = this.$sideBarPages.querySelector(
      `.sidebar__pages--container [data-id="${id}"]`
    );
    const updatedFileContainerNextSibling =
      updatedFileContainer.nextElementSibling;
    const [selectedList, depth] = this.findClickedById(this.state.list, id);

    if (updatedFileContainer) {
      if (!this.openedDetail.has(id)) {
        this.openedDetail.add(id);
      } else {
        this.openedDetail.delete(id);

        if (
          updatedFileContainerNextSibling?.className === "sidebar__pages--empty"
        ) {
          updatedFileContainerNextSibling.remove();
        }
      }

      updatedFileContainer.replaceWith(
        this.printFile(
          this.$sideBarPages,
          [selectedList],
          selectedList.id,
          depth
        )
      );
    }
  };

 

아래의 노드를 보면 문제의 상황에서 선택한 부분인 <li class="sidebar__pages--detail" data-id="94739" style="--depth: 1;">...</li>의 바로 다음 형제 요소가 항상 "sidebar__pages--empty"으로 판단됩니다. 

<ul class="sidebar__pages--container" data-id="89756">
	<li class="sidebar__pages--detail" data-id="94739" style="--depth: 1;">...</li>
    <div class="sidebar__pages--empty" style="--depth: 2;">하위 페이지 없음</div>
</ul>

 

그렇기 때문에 updateFileContainer의 nextSibling을 가져와  "sidebar__pages--empty"라면 제거해 주는 방식으로 문제를 해결했습니다. 

 

(추가)

미처 생각하지 못했던 부분이 있는데, 하위 페이지 없음 말고도 다른 페이지도 중복 된다는 점을 간과했습니다. 따라서 조건을 추가해서 해결했습니다.

if ( updatedFileContainerNextSibling?.className === "sidebar__pages--empty" 
	|| updatedFileContainerNextSibling?.querySelector(
    `.sidebar__pages--detail[data-id="${selectedList.documents[0]?.id}"]`)
) {
    updatedFileContainerNextSibling.remove();
}