바닐라 자바스크립트로 웹 애플리케이션을 만드는 고된 과정 속에는 DOM 조작이 필수적으로 들어간다. 그런데 이 DOM 조작은 때로는 안티 패턴(Anti-Pattern)으로 인식되기도 한다. 이번에는 DOM을 조작하는 Web API에는 주로 어떤 것들이 있고, 어떻게 하면 DOM을 조작하는 과정을 최적화할 수 있을지에 대해 알아보려고 한다.
DOM이란?
문서 객체 모델(The Document Object Model, 이하 DOM) 은 HTML, XML 문서의 프로그래밍 interface이다.
기본적으로 HTML로 된 문서에는 웹페이지의 설계도가 그려져 있는데, 이 구체적인 구조를 웹페이지에 접속할 때 HTML이라는 형식을 통해 브라우저에 전달하게 된다. 그러면 브라우저는 이 형식을 보고, 그 안의 HTML 요소들(body, div, li 등)을 실제 객체(object)로 만들어내는 것이다. HTML의 각 요소들이 브라우저에서 실제로 제작되는 것이다. 즉 DOM이란, HTML에 작성되어 있는 구조에 맞춰서 실제 Element들이 배치되고, 추가적으로 명령을 내려서 속성이나 디자인, 배치 등을 조작할 수 있도록 된 상태를 말한다. HTML 코드로 설계된 웹페이지가 브라우저 안에서 화면에 나타나고 이벤트에 반응하고 값을 입력받는 등, 기능들을 수행할 객체들로 실체화된 형태다.
DOM은 자바스크립트에 의해 제어될 수 있다. 기본적으로 브라우저에서 동작하며, 브라우저가 이해할 수 있는 유일한 언어가 JavaScript이기 때문에 보통 DOM 조작이라고 하면 자바스크립트로 이루어진다. 그러나 DOM 조작을 자바스크립트로만 할 수 있는 것은 아니다. 파이썬에서도 BeautifulSoup라는 대표적인 DOM 트리 파싱 라이브러리가 있다. 이는 DOM API가 JavaScript API가 아닌, Web API이기 때문이다.
DOM API란?
브라우저에서 제공하는 Web API 중 하나로, 이 API를 통해 원하는 대로 DOM을 찾고 조작할 수 있다. 정적인 웹 페이지에 접근하여 동적으로 웹 페이지를 변경하기 위해서는 메모리 상에 존재하는 DOM을 변경해야 하고, 이때 필요한 것이 DOM 요소들을 변경하는 프로퍼티와 메서드 집합인 DOM API인 것이다. 대표적으로는 노드를 취득하는 API (ex. document.querySelector), 그리고 노드를 추가 또는 조작하는 API (ex. Node.appendChild) 등이 있다.
노드를 취득하는 API
- 단일 노드를 취득, 즉 조회하는 API로는 대표적으로 getElementById, 그리고 querySelector가 있다. 전자는 오직 ID로 요소에 접근할 수 있는 반면, 후자는 여러 가지 CSS Selector를 사용해서 DOM에 접근할 수 있다.
- 여러 개의 element를 조회하고 싶은 경우에는 getElementsByClassName, 그리고 querySelectorAll을 통해 노드를 취득할 수 있다. 이 둘은 모두 유사 배열 객체로, 배열에서 제공하는 map, reduce와 같은 메서드 사용이 불가능하기 때문에 배열로 변환 후 사용하는 것이 권장된다.
노드를 조작하는 API
- innerHTML
- 쉽고 간편하게 새로운 요소를 기존의 노드에 삽입할 수 있다.
- 기존 노드의 모든 자식 노드를 제거하고 할당한 HTML 마크업 문자열을 파싱 하여 DOM을 변경한다. 예제와 같이 반복적인 연산을 수행할 경우 매번 자식 노드들을 파싱 해야 하므로 비용이 많이 든다.
- insertAdjacentHTML
- innerHTML의 성능상 한계를 보완하기 위해 나온 DOM API로, 기존 자식 노드를 제거하지 않으면서 위치만을 지정해 추가가 가능하다. 이미 사용 중인 element를 파싱 하지 않기 때문에 연산 속도가 innerHTML보다 훨씬 빠르다는 장점이 있다.
- appendChild
- insertAdjacentHTML과 마찬가지로 기존 요소를 제거하지 않으면서 위치를 지정하여 추가가 가능하다. 성능(속도) 역시 비슷하다.
- 다만 DOM String이 아닌 노드 객체를 직접 생성해서 인자로 넣어줘야 한다는 번거로움이 있다.
- 인자로 DOM String이 아닌 노드를 받기 때문에 오히려 보안적으로는 앞의 두 API보다 낫다.
성능 최적화를 위해 어떻게 DOM을 제어하면 좋을까?
가장 중요한 것은 DOM 조작을 최소한으로 하는 것이다. 브라우저의 렌더링 과정을 살펴보면, 렌더링 엔진은 서버로부터 받아온 html을 한 줄 한 줄씩 순차적으로 파싱 하며 DOM을 생성한다. 이때 스크립트 태그를 만나면 DOM 생성을 일시적으로 중단하고, 자바스크립트 코드를 실행하기 위해 브라우저는 렌더링 엔진으로부터 JavaScript 엔진으로 제어권을 넘기게 된다. 이후 스크립트의 파싱과 실행이 종료되면 다시 렌더링 엔진으로 제어권을 넘겨 DOM 생성을 재개한다. 이때 만약 JavaScript 코드에 의해 DOM이 변경되는 경우, 이 부분을 다시 Render Tree로 결합하고, 이에 따른 레이아웃 연산과 페인트 과정을 거쳐 브라우저 화면에 다시 렌더링 되게 된다. 이를 리플로우(Reflow)와 리페인트(Repaint)라고 한다. (리플로우와 리페인트에 대해서는 추후에 보다 자세하게 포스팅을 해보고자 한다.) 노드의 추가 또는 제거 시마다 발생하는 리플로우는 영향을 받는 모든 노드의 수치를 다시 계산(Recalculate)하여 렌더 트리를 생성하는 과정이고, 당연히 비용이 많이 드는 연산이다. 따라서 리플로우를 최대한 줄임으로써 성능을 개선시킬 수 있다.
다음은 cars라는 배열의 property를 참조해서 화면에 조건에 따라 달라지는 레이아웃을 구성한 코드다. 이 코드의 문제는, 배열의 길이만큼 DOM을 조작하여 최대한 많은 리플로우를 발생시킨다는 것이다.
const targets = document.querySelectorAll("#car .car-path");
targets.forEach((target, index) => {
if (this.cars[index].isGoing) {
target.innerHTML += `<div class="forward-icon mt-2">⬇️️</div>`;
} else {
target.innerHTML += `
<div class="d-flex justify-center mt-3">
<div class="relative spinner-container">
<span class="material spinner"></span>
</div>
</div>
`;
}
});
이러한 DOM 조작은 모아뒀다가 한 번에 처리하는 것이 당연히 성능에 유리하다. 다음과 같이 바꿔볼 수 있겠다.
const targets = document.querySelectorAll("#car .car-path");
let html = ``;
targets.forEach((target, index) => {
if (this.cars[index].isGoing) {
html += `<div class="forward-icon mt-2">⬇️️</div>`;
} else {
html += `
<div class="d-flex justify-center mt-3">
<div class="relative spinner-container">
<span class="material spinner"></span>
</div>
</div>
`;
}
});
targets.innerHTML = html;
DOM 접근 횟수가 N번에서 1번으로 줄어든 것을 확인할 수 있다. 조금 더 개선해볼 수도 있다. 앞서 innerHTML API의 단점으로, 자식 노드들을 파싱 해서 제거하고, DOM String으로 할당한 HTML 마크업 문자열을 다시 파싱해서 DOM을 변경한다는 점이 있었다. 매번 자식 노드들을 파싱해야 하므로, 특히 반복적인 연산을 할 땐 비용이 많이 드는 과정이라고 볼 수 있다. 따라서 이미 사용 중인 자식 노드들을 참조하지 않는 insertAdjacentHTML API를 통해 조금 더 최적화를 해 볼 수도 있겠다.
targets.insertAdjacentHTML = html;
이렇게 바꾸면 DOM 조작의 연산 속도를 좀 더 줄일 수 있다.
결론
리플로우는 굉장히 비싼 연산이다. JavaScript를 통한 DOM 조작을 최소화하자.
References
https://developer.mozilla.org/ko/docs/Web/API/Document_Object_Model/Introduction
'Web' 카테고리의 다른 글
서로 다른 윈도우 간 안전하게 통신하는 방법 (0) | 2024.03.17 |
---|---|
Axios 인터셉터로 JWT 토큰 로테이션 구현하기 (0) | 2024.02.18 |
헷갈리는 줄바꿈, 올바르게 제어해보자 (15) | 2023.12.10 |
JavaScript로 이미지 파일 데이터 다루기 (0) | 2023.08.31 |
브라우저 렌더링 (1) - 클라이언트 사이트 렌더링 (0) | 2022.01.26 |