[번역] 모던 바닐라 자바스크립트를 사용한 메모리 효율적인 DOM 조작을 위한 패턴

sejin kim·2024년 11월 10일
0

번역

목록 보기
9/9
post-thumbnail

이 글은 FrontendMasters.com 블로그에 기고된 Marc Grabanski 님의 글 'Patterns for Memory Efficient DOM Manipulation with Modern Vanilla JavaScript'을 한국어로 옮긴 것입니다.


DOM 업데이트를 관리할 때, 과도한 메모리 사용을 방지하고 앱을 엄청나게 빠르게(blazingly fast)™️ 만드는 모범 사례에 대해 이야기해 보겠습니다.





DOM: 문서 객체 모델 - 간략한 개요

HTML을 렌더링할 때, 브라우저에서 렌더링된 요소들의 실시간 뷰를 DOM이라고 지칭합니다. 이는 개발자 도구의 "Elements" 인스펙터에서 살펴볼 수 있습니다.



본질적으로 DOM은 트리(tree, 나무)이며, 그 내부의 각 요소들은 리프(leaf, 잎)입니다. 이러한 요소들의 트리를 수정하는 데 특화된 전체 API 세트가 존재합니다.

일반적인 DOM API의 간략한 목록은 다음과 같습니다 :


  • querySelector()
  • querySelectorAll()
  • createElement()
  • getAttribute()
  • setAttribute()
  • addEventListener()
  • appendChild()

이들은 document에 연결되어 있으므로, const el = document.querySelector("#el"); 같은 형태로 사용할 수 있습니다. 또한 모든 다른 요소에서도 사용할 수 있으므로, 특정 요소에 대한 참조가 존재한다면 이러한 메소드를 사용할 수 있으며 해당 메소드의 기능은 그 요소의 범위(스코프)로 한정됩니다.


const nav = document.querySelector("#site-nav");
const navLinks = nav.querySelectorAll("a");

이러한 메소드는 브라우저에서 DOM을 수정하는 데 사용할 수 있지만, js-dom과 같은 DOM 에뮬레이터를 사용하지 않는 한 서버 자바스크립트(Node.js 같은)에서는 사용할 수 없습니다.

통상 업계에서는 이러한 직접 렌더링 작업 대부분을 프레임워크에 위임하고 있습니다. 모든 자바스크립트 프레임워크들(React, Angular, Vue, Svelte 등)은 이러한 API를 내부적으로 사용하고 있습니다. 프레임워크가 생산성을 크게 높여준다는 장점이, 수동으로 DOM을 조작하여 얻을 수 있는 성능 향상보다 중요한 경우가 많다는 사실을 알고 있습니다만, 이 글에서는 내부적으로 어떠한 일이 일어나는지에 대해 이해하기 쉽게 설명해보고자 합니다.





왜 DOM을 직접 조작해야 할까?

가장 주요한 이유는 성능입니다. 프레임워크는 불필요한 데이터 구조와 리렌더링을 추가하여, 많은 모던 웹 앱에서 볼 수 있는 끊김(stuttering)이나 멈춤(freezing) 현상을 유발할 수 있습니다. 이는 모든 코드를 처리하기 위해 가비지 컬렉터가 과도하게 작동한다는 점에 기인합니다.

단점은 DOM을 직접 조작하면 코드량이 많아지고 복잡해진다는 것입니다. 그래서 DOM을 수동으로 조작하기보다는 프레임워크와 추상화를 사용하는 것이 개발자 경험 측면에서는 더 낫습니다. 하지만 그럼에도 불구하고 추가적인 성능이 필요한 경우가 있을 수 있으며, 이 가이드가 바로 그런 상황을 위한 것이라고 할 수 있습니다.



수동 DOM 조작을 기반으로 구축된 VS Code

Visual Studio Code도 그러한 사례 중 하나입니다. VS Code는 "가능한 DOM에 가깝게 접근하기 위해" 바닐라 자바스크립트로 작성되었습니다. VS Code와 같은 대규모 프로젝트는 성능을 엄격하게 통제해야 할 필요가 있습니다. 플러그인 생태계로부터 강력한 파급력이 비롯되고 있기 때문에 코어는 가능한 간결하고 가볍게 유지되어야 하는데, 이는 VS Code가 널리 채택될 수 있도록 한 이유 중 하나입니다.



Microsoft Edge도 최근 같은 이유로 React에서 벗어났습니다.

프레임워크를 사용하는 것보다 더 낮은 수준(lower level)의 프로그래밍인 직접 DOM 조작을 통해 성능을 최적화해야 하는 상황에 놓이게 될 때, 이 글이 도움이 되기를 바라겠습니다!





보다 효율적인 DOM 조작을 위한 팁

새로운 요소를 만들기보다 숨기기/표시하기를 선호하기

자바스크립트로 요소를 파괴하고 생성하는 대신, 요소를 숨기고 표시하여 DOM을 변경하지 않는 것이 항상 더 성능이 좋은 선택지입니다.

자바스크립트로 요소를 동적으로 생성하고 삽입하는 방식보단, 서버에서 요소를 렌더링하고 el.classList.add('show') 또는 el.style.display = 'block'과 같은 클래스(및 적절한 CSS 규칙들)를 사용하는 식으로 요소를 숨기거나 표시해야 합니다. 대부분 정적인 DOM은 가비지 컬렉션 호출과 복잡한 클라이언트 로직이 없기 때문에 훨씬 더 성능이 뛰어납니다.

가능한 클라이언트에서 DOM 노드를 동적으로 생성하지 마세요.

다만 보조 기술(assistive technology)을 유념해야 합니다. 요소를 시각적으로 숨기고, 보조 기술에서도 숨겨지도록 하려면 display: none; 을 사용하면 됩니다. 하지만 요소를 숨기되 보조 기술에서는 그대로 두고자 한다면, 콘텐츠를 숨기는 다른 방법을 살펴보시기 바랍니다.



요소의 콘텐츠를 읽을 때 innerText보다 textContent를 선호하기

innerText 메소드는 요소의 현재 스타일을 인식한다는 점에서 유용합니다. 요소가 숨겨져 있는지 아닌지를 파악할 수 있으며, 실제로 표시되는 경우에만 텍스트를 가져옵니다. 하지만 스타일을 확인하는 과정에서 리플로우가 발생하여 느려진다는 문제가 있습니다.

element.textContent로 콘텐츠를 읽는 것이 element.innerText보다 훨씬 빠르므로, 가능한 텍스트를 읽을 때에는 textContent를 사용하는 것이 좋습니다.



innerHTML 대신 insertAdjacentHTML 사용하기

insertAdjacentHTML 메소드는 innerHTML 보다 더 빠릅니다. 그 이유는 새 HTML을 삽입하기 전에 DOM을 먼저 파괴할 필요가 없기 때문입니다. 이 메소드는 새로운 HTML을 삽입할 위치를 유연하게 지정할 수 있습니다. 예를 들면 :


el.insertAdjacentHTML("afterbegin", html);
el.insertAdjacentHTML("beforeend", html);




가장 빠른 접근 방식은 insertAdjacentElement 또는 appendChild를 사용하는 것입니다

접근 방식 #1 : template 태그를 사용하여 HTML 템플릿을 만들고 appendChild를 사용하여 새로운 HTML을 삽입하기

이들은 완전한 형태의 DOM 요소를 추가하는 가장 빠른 방법입니다. <template> 태그를 사용해 HTML 템플릿을 만들고, 그런 다음 insertAdjacentElementappendChild 메소드를 사용하여 DOM에 삽입하는 것이 잘 알려진 패턴입니다.


<template id="card_template">
  <article class="card">
    <h3></h3>
    <div class="card__body">
      <div class='card__body__image'></div>
      <section class='card__body__content'>
      </section>
    </div>
  </article>
</template>

function createCardElement(title, body) {
  const template = document.getElementById('card_template');
  const element = template.content.cloneNode(true).firstElementChild;
  const [cardTitle] = element.getElementsByTagName("h3");
  const [cardBody] = element.getElementsByTagName("section");
  [cardTitle.textContent, cardBody.textContent] = [title, body];
  return element;
}

container.appendChild(createCardElement(
  "Frontend System Design: Fundamentals",
  "This is a random content"
))

이는 새로운 프론트엔드 시스템 디자인 강의에서 Evgenni가 무한 스크롤 소셜 뉴스 피드를 처음부터 직접 구현해보는 과정을 통해 실제로 확인해 볼 수 있습니다!



접근 방식 #2 : 일괄 삽입에 appendChild와 함께 createDocumentFragment 사용하기

DocumentFragment는 DOM 노드를 가질 수 있는 경량의 "빈(empty)" 문서 객체입니다. 활성 DOM 트리의 일부가 아니기 때문에, 여러 요소를 삽입하기 위해 준비하는 데 이상적입니다.


const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
  const li = document.createElement('li');
  li.textContent = `Item ${i}`;
  fragment.appendChild(li);
}
document.getElementById('myList').appendChild(fragment);

이러한 접근 방식은 모든 요소를 개별적으로 삽입하지 않고 한 번에 삽입하여, 리플로우와 리페인트 작업을 최소화합니다.





노드가 제거되었을 때 참조 관리하기

DOM 노드를 제거할 때, 가비지 컬렉터가 관련 데이터를 정리하지 못하게 하는 참조가 남아있지 않도록 해야 합니다. 이를 위해 WeakMapWeakRef를 사용하여 참조 누수를 방지할 수 있습니다.



WeakMap을 사용하여 DOM 노드에 데이터 연결하기

WeakMap을 사용하여 DOM 노드에 데이터를 연결할 수 있습니다. 이렇게 하면 나중에 DOM 노드가 제거될 때 해당 데이터에 대한 참조도 함께 완전히 제거됩니다.


let DOMdata = { 'logo': 'Frontend Masters' };
let DOMmap = new WeakMap();
let el = document.querySelector(".FmLogo");
DOMmap.set(el, DOMdata);
console.log(DOMmap.get(el)); // { 'logo': 'Frontend Masters' }
el.remove(); // DOMdata는 가비지 수집될 수 있음

WeakMap을 사용함으로써 DOM 요소가 제거되어도 데이터에 대한 참조가 유지되지 않음을 보장할 수 있습니다.



WeakRef를 사용하여 가비지 컬렉션 후 정리하기

다음의 예시에서는 DOM 노드에 대한 WeakRef를 생성합니다 :


class Counter {
  constructor(element) {
    // DOM 요소에 대한 약한 참조를 기억하기
    this.ref = new WeakRef(element);
    this.start();
  }

  start() {
    if (this.timer) {
      return;
    }

    this.count = 0;

    const tick = () => {
      // 아직 존재한다면 약한 참조에서 요소를 가져옴
      const element = this.ref.deref();
      if (element) {
        console.log("Element is still in memory, updating count.")
        element.textContent = `Counter: ${++this.count}`;
      } else {
        // 요소가 더 이상 존재하지 않음
        console.log("Garabage Collector ran and element is GONE – clean up interval");
        this.stop();
        this.ref = null;
      }
    };

    tick();
    this.timer = setInterval(tick, 1000);
  }

  stop() {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = 0;
    }
  }
}

const counter = new Counter(document.getElementById("counter"));
setTimeout(() => {
  document.getElementById("counter").remove();
}, 5000);

노드를 제거한 후에는 콘솔을 통해 실제 가비지 컬렉션 작업이 발생하는 시점을 확인하거나, 개발자 도구의 Performance 탭에서 직접 강제 실행할 수 있습니다 :



이를 통해 모든 참조가 제거되고 타이머가 정리되었음을 명확히 할 수 있습니다.

참고: WeakRef를 남용하지 않도록 주의하세요. 이러한 마법 같은 기능에는 비용이 수반됩니다. 가능한 참조를 명시적으로 관리하는 것이 성능상 더 좋습니다.





이벤트 리스너 정리하기

removeEventListener를 사용하여 수동으로 이벤트 제거하기

function handleClick() {
  console.log("Button was clicked!");
  el.removeEventListener("click", handleClick);
}

// 버튼에 이벤트 리스너 추가
const el = document.querySelector("#button");
el.addEventListener("click", handleClick);


일회성 이벤트에는 once 파라미터 사용하기

위와 동일한 동작을 "once" 파라미터로 구현할 수 있습니다 :


el.addEventListener('click', handleClick, {
  once: true
});

addEventListener의 세 번째 파라미터로 boolean 값을 추가하면, 리스너가 추가된 후 최대 한 번만 실행되도록 지정할 수 있습니다. 리스너는 실행 이후 자동으로 제거됩니다.



이벤트 위임을 사용하여 이벤트 바인딩 최소화하기

변화가 많은 동적인 컴포넌트에서 노드를 자주 생성하고 교체하는 경우, 노드를 생성할 때마다 각각의 이벤트 리스너를 설정하는 것은 많은 비용이 듭니다.

대신, 루트 레벨에 가깝게 이벤트를 바인드할 수 있습니다. 이벤트는 DOM을 따라 위로 버블링되기 때문에, event.target(이벤트가 발생한 원래의 대상)을 확인하여 이벤트를 포착하고 반응할 수 있습니다.

matches(selector)는 현재 요소만 매칭하기 때문에, 리프(말단) 노드여야 합니다.


const rootEl = document.querySelector("#root");
// root 요소에 클릭 이벤트 리스너 추가
rootEl.addEventListener('click', function (event) {
  // 클릭된 요소가 "target-element" 클래스를 가지고 있는지 확인
  if (event.target.matches('.target-element')) doSomething();
});

대부분의 경우, <div class="target-element"><p>...</p></div>와 같이 중첩된 요소를 다루게 될 것입니다. 이런 경우에는 .closest(element) 메소드를 사용해야 합니다.


const rootEl = document.querySelector("#root");

// root 요소에 클릭 이벤트 리스너 추가
rootEl.addEventListener('click', function (event) {
    // 클릭된 요소의 상위 요소 중 "target-element" 클래스를 가진 요소가 있는지 확인
  if (event.target.closest('.target-element')) doSomething();
});

이러한 방법을 사용하면 요소를 동적으로 추가한 이후에 리스너를 일일이 연결하거나 제거하는 데 신경쓰지 않아도 됩니다.





AbortController를 사용하여 이벤트 그룹 한번에 제거하기

const button = document.getElementById('button');
const controller = new AbortController();
const { signal } = controller;

button.addEventListener(
  'click', 
  () => console.log('clicked!'), 
  { signal }
);

// 리스너 제거!
controller.abort();

AbortController를 사용하면 여러 이벤트를 한 번에 제거할 수 있습니다.


let controller = new AbortController();
const { signal } = controller;

button.addEventListener('click', () => console.log('clicked!'), { signal });
window.addEventListener('resize', () => console.log('resized!'), { signal });
document.addEventListener('keyup', () => console.log('pressed!'), { signal });

// 한 번에 모든 리스너 제거:
controller.abort();

AbortController 코드 예제는 Alex MacArthur가 제공한 것을 참고했습니다.





프로파일링 & 디버깅

DOM이 너무 비대해지지 않도록 크기를 측정합니다.

아래는 Chrome DevTools를 활용한 메모리 프로파일링 간단 가이드입니다 :


  1. Chrome DevTools 열기
  2. "Memory" 탭으로 이동
  3. "Heap snapshot" 선택 후 "Take snapshot" 클릭
  4. DOM 작업 수행
  5. 다른 스냅샷 추가 캡처
  6. 스냅샷을 비교하여 메모리 증가 확인


확인해야 할 핵심 사항 :


  • 예기치 않게 유지된 DOM 요소
  • 정리되지 않은 큰 배열 또는 객체
  • 시간이 지남에 따라 증가하는 메모리 사용량 (잠재적인 메모리 누수)

  1. "Performance" 탭으로 이동
  2. 옵션에서 "Memory" 선택
  3. "Record"를 클릭
  4. DOM 작업 수행
  5. 기록을 중지하고 메모리 그래프 분석

이렇게 하면 메모리 할당을 시각화하고 DOM 조작 중 잠재적인 누수나 불필요한 할당을 식별하는 데 도움이 됩니다.



자바스크립트 실행 시간 분석

Chrome DevTools의 Performance 탭은 메모리 프로파일링 이외에도 DOM 조작 코드를 최적화할 때 매우 중요한 자바스크립트 실행 시간을 분석하는 데 유용하게 활용할 수 있습니다.

방법은 다음과 같습니다 :


  1. Chrome DevTools를 열고 "Performance" 탭으로 이동
  2. Record 버튼 클릭
  3. 분석하고자 하는 DOM 작업을 수행
  4. 기록 중지


결과 타임라인이 다음과 같이 표시될 것입니다 :

  • 자바스크립트 실행 (노란색)
  • 렌더링 작업 (보라색)
  • 페인팅 (녹색)

다음의 항목을 찾아보세요 :


  • 긴 노란색 막대는 많은 시간을 소모하는 자바스크립트 작업을 나타냅니다.
  • 짧은 노란색 막대가 빈번하게 나타나는 경우, 과도한 DOM 조작을 나타내고 있는 것일 수 있습니다.

더 자세히 알아보려면 :


  • 노란색 막대를 클릭하면 특정 함수의 호출과 실행 시간을 확인할 수 있습니다.
  • "상향(Bottom-Up)" 및 "호출 트리(Call Tree)" 탭을 확인하여 가장 많은 시간이 소요되는 함수를 찾아 보세요.

이러한 분석을 통해 DOM 조작 코드로 인해 성능 문제가 발생하는 정확한 지점을 파악하여 타겟팅된 최적화를 수행할 수 있습니다.



성능 디버깅 관련 자료

Chrome 개발 팀의 글 :



메모리 및 성능 분석과 Chrome DevTools에 대해 상세하게 다루는 강의입니다 :



효율적인 DOM 조작은 적절한 방법론을 사용하는 것뿐만 아니라 언제, 얼마나 자주 DOM과 상호작용하는지를 이해하는 것 또한 중요하다는 점을 기억하세요. 효율적인 방법을 사용하더라도 과도한 조작은 성능 문제를 야기할 수 있습니다.





DOM 최적화를 위한 핵심 사항

성능에 민감한 웹 앱을 만들 때에는 효율적인 DOM 조작 지식이 중요합니다. 모던 프레임워크들은 편리함과 추상화를 제공하지만, 로우 레벨의 기술을 이해하고 적용한다면 특히 까다로운 요구사항들이 존재하는 시나리오에서 앱의 성능을 극적으로 향상시킬 수 있을 것입니다.

지금까지의 내용을 다시금 정리하자면 아래와 같습니다 :


  1. 가능한 새 요소를 만드는 것보다는 기존 요소를 수정하세요.
  2. textContent, insertAdjacentHTML, appendChild와 같은 효율적인 메소드를 사용하세요.
  3. 메모리 누수를 방지하기 위해 WeakMapWeakRef를 활용하여 참조를 주의깊게 관리하세요.
  4. 이벤트 리스너를 적절히 정리하여 불필요한 오버헤드를 방지하세요.
  5. 보다 효율적인 이벤트 핸들링을 위해 이벤트 위임과 같은 테크닉을 고려하세요.
  6. 여러 이벤트 리스너를 더 쉽게 관리하려면 AbortController와 같은 도구를 사용하세요.
  7. 일괄 삽입을 위해 DocumentFragment를 사용하고, 광범위한 최적화 전략을 위한 가상 DOM과 같은 개념에 대해 이해하세요.

모든 프로젝트에서 항상 프레임워크를 포기하고 DOM을 수동으로 조작하는 것에 목표가 있지 않음을 유념하시기 바랍니다. 오히려 이러한 원칙들을 이해하고, 언제 프레임워크를 사용하고 언제 더 낮은 수준에서 최적화해야 할지에 대해 정보에 입각한 결정을 내릴 수 있도록 하는 것이야말로 목표라고 할 수 있습니다. 메모리 프로파일링 및 성능 벤치마킹과 같은 도구는 바로 이러한 결정을 내리는 데 지침이 되어줄 수 있습니다.


아티클 시리즈
1. 모던 바닐라 자바스크립트로 TodoMVC 앱 만들기
2. 모던 바닐라 자바스크립트를 사용한 반응성 패턴
👉 모던 바닐라 자바스크립트를 사용한 메모리 효율적인 DOM 조작을 위한 패턴

profile
퇴고를 좋아하는 주니어 웹 개발자입니다.

0개의 댓글