이 글은 FrontendMasters.com 블로그에 기고된
Marc Grabanski
님의 글 'Patterns for Memory Efficient DOM Manipulation with Modern Vanilla JavaScript'을 한국어로 옮긴 것입니다.
DOM 업데이트를 관리할 때, 과도한 메모리 사용을 방지하고 앱을 엄청나게 빠르게(blazingly fast)™️ 만드는 모범 사례에 대해 이야기해 보겠습니다.
HTML을 렌더링할 때, 브라우저에서 렌더링된 요소들의 실시간 뷰를 DOM이라고 지칭합니다. 이는 개발자 도구의 "Elements" 인스펙터에서 살펴볼 수 있습니다.
본질적으로 DOM은 트리(tree, 나무)이며, 그 내부의 각 요소들은 리프(leaf, 잎)입니다. 이러한 요소들의 트리를 수정하는 데 특화된 전체 API 세트가 존재합니다.
일반적인 DOM API의 간략한 목록은 다음과 같습니다 :
이들은 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을 조작하여 얻을 수 있는 성능 향상보다 중요한 경우가 많다는 사실을 알고 있습니다만, 이 글에서는 내부적으로 어떠한 일이 일어나는지에 대해 이해하기 쉽게 설명해보고자 합니다.
가장 주요한 이유는 성능입니다. 프레임워크는 불필요한 데이터 구조와 리렌더링을 추가하여, 많은 모던 웹 앱에서 볼 수 있는 끊김(stuttering)이나 멈춤(freezing) 현상을 유발할 수 있습니다. 이는 모든 코드를 처리하기 위해 가비지 컬렉터가 과도하게 작동한다는 점에 기인합니다.
단점은 DOM을 직접 조작하면 코드량이 많아지고 복잡해진다는 것입니다. 그래서 DOM을 수동으로 조작하기보다는 프레임워크와 추상화를 사용하는 것이 개발자 경험 측면에서는 더 낫습니다. 하지만 그럼에도 불구하고 추가적인 성능이 필요한 경우가 있을 수 있으며, 이 가이드가 바로 그런 상황을 위한 것이라고 할 수 있습니다.
Visual Studio Code도 그러한 사례 중 하나입니다. VS Code는 "가능한 DOM에 가깝게 접근하기 위해" 바닐라 자바스크립트로 작성되었습니다. VS Code와 같은 대규모 프로젝트는 성능을 엄격하게 통제해야 할 필요가 있습니다. 플러그인 생태계로부터 강력한 파급력이 비롯되고 있기 때문에 코어는 가능한 간결하고 가볍게 유지되어야 하는데, 이는 VS Code가 널리 채택될 수 있도록 한 이유 중 하나입니다.
Microsoft Edge도 최근 같은 이유로 React에서 벗어났습니다.
프레임워크를 사용하는 것보다 더 낮은 수준(lower level)의 프로그래밍인 직접 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
를 사용하는 것입니다template
태그를 사용하여 HTML 템플릿을 만들고 appendChild
를 사용하여 새로운 HTML을 삽입하기이들은 완전한 형태의 DOM 요소를 추가하는 가장 빠른 방법입니다. <template>
태그를 사용해 HTML 템플릿을 만들고, 그런 다음 insertAdjacentElement
나 appendChild
메소드를 사용하여 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가 무한 스크롤 소셜 뉴스 피드를 처음부터 직접 구현해보는 과정을 통해 실제로 확인해 볼 수 있습니다!
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 노드를 제거할 때, 가비지 컬렉터가 관련 데이터를 정리하지 못하게 하는 참조가 남아있지 않도록 해야 합니다. 이를 위해 WeakMap
과 WeakRef
를 사용하여 참조 누수를 방지할 수 있습니다.
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 요소가 제거되어도 데이터에 대한 참조가 유지되지 않음을 보장할 수 있습니다.
다음의 예시에서는 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를 활용한 메모리 프로파일링 간단 가이드입니다 :
확인해야 할 핵심 사항 :
이렇게 하면 메모리 할당을 시각화하고 DOM 조작 중 잠재적인 누수나 불필요한 할당을 식별하는 데 도움이 됩니다.
Chrome DevTools의 Performance 탭은 메모리 프로파일링 이외에도 DOM 조작 코드를 최적화할 때 매우 중요한 자바스크립트 실행 시간을 분석하는 데 유용하게 활용할 수 있습니다.
방법은 다음과 같습니다 :
결과 타임라인이 다음과 같이 표시될 것입니다 :
다음의 항목을 찾아보세요 :
더 자세히 알아보려면 :
이러한 분석을 통해 DOM 조작 코드로 인해 성능 문제가 발생하는 정확한 지점을 파악하여 타겟팅된 최적화를 수행할 수 있습니다.
Chrome 개발 팀의 글 :
메모리 및 성능 분석과 Chrome DevTools에 대해 상세하게 다루는 강의입니다 :
효율적인 DOM 조작은 적절한 방법론을 사용하는 것뿐만 아니라 언제, 얼마나 자주 DOM과 상호작용하는지를 이해하는 것 또한 중요하다는 점을 기억하세요. 효율적인 방법을 사용하더라도 과도한 조작은 성능 문제를 야기할 수 있습니다.
성능에 민감한 웹 앱을 만들 때에는 효율적인 DOM 조작 지식이 중요합니다. 모던 프레임워크들은 편리함과 추상화를 제공하지만, 로우 레벨의 기술을 이해하고 적용한다면 특히 까다로운 요구사항들이 존재하는 시나리오에서 앱의 성능을 극적으로 향상시킬 수 있을 것입니다.
지금까지의 내용을 다시금 정리하자면 아래와 같습니다 :
textContent
, insertAdjacentHTML
, appendChild
와 같은 효율적인 메소드를 사용하세요.WeakMap
과 WeakRef
를 활용하여 참조를 주의깊게 관리하세요.AbortController
와 같은 도구를 사용하세요.DocumentFragment
를 사용하고, 광범위한 최적화 전략을 위한 가상 DOM과 같은 개념에 대해 이해하세요.모든 프로젝트에서 항상 프레임워크를 포기하고 DOM을 수동으로 조작하는 것에 목표가 있지 않음을 유념하시기 바랍니다. 오히려 이러한 원칙들을 이해하고, 언제 프레임워크를 사용하고 언제 더 낮은 수준에서 최적화해야 할지에 대해 정보에 입각한 결정을 내릴 수 있도록 하는 것이야말로 목표라고 할 수 있습니다. 메모리 프로파일링 및 성능 벤치마킹과 같은 도구는 바로 이러한 결정을 내리는 데 지침이 되어줄 수 있습니다.
아티클 시리즈
1. 모던 바닐라 자바스크립트로 TodoMVC 앱 만들기
2. 모던 바닐라 자바스크립트를 사용한 반응성 패턴
👉 모던 바닐라 자바스크립트를 사용한 메모리 효율적인 DOM 조작을 위한 패턴