요즘 저는 개발팀 동료분과 개발 스터디를 하고 있습니다. 그 첫번째 스터디로 라이브러리, 프레임워크 없이 Observer Pattern으로 SPA 앱의 ‘상태’를 구현하고, Virtual DOM 개념을 모사한 MVC 구조의 간단한 앱을 만들어보는 연습을 하고 있습니다.
원래 저는 React를 사용했었고, 회사에선 Vue를 사용하고 있습니다. 이번 스터디를 진행하면서 근래의 개발 환경이 이토록 편하게 발전하도록 기여햔 SPA 프레임워크들에게 새삼 고마움을 느꼈습니다.
오늘은 Render 함수를 구현하는 중 DOM을 효율적으로 다루는 과정을 고민하던 중 DocumentFragment
에 대해 알게 되었고, 제가 배운 것을 글로 남겨봅니다.
DocumentFragment은 웹 문서의 메인 DOM 트리에 포함되지 않는, 가상 메모리에 존재하는 DOM 노드 객체입니다. DocumentFragment 노드를 사용하면 메인 DOM 트리 외부에 경량화된 DOM을 만들 수 있어 브라우저 repaint 영향 없이 메모리에서 DOM 조작이 가능합니다.
메인 DOM의 조작(manipulation)이 필요할때 페이지 reflow 등 성능적 영향을 최소한으로 줄이기 위해 사용합니다.
Document Fragment를 루트 노드로 만든 DOM Tree를 사용하면, DOM과 관련된 작업을 페이지에 영향을 주지 않고 적용해서, 한번만 DOM 접근으로 적용할 수 있습니다.
객체는 이렇게 생겼습니다.
예를 들어 자바스크립트 DOM API로 200개의 리스트를 Actual DOM에 삽입해야 한다고 가정해보죠.
잘못된 예
(function () {
const targetNode = document.querySelector('ul') // 새로운 노드 트리를 집어넣을 곳
for (let i=0; i < 200; i++) {
const li = document.createElement('li')
li.innerText = i + '번째 리스트입니다.'
targetNode.append(li)
}
})()
위와 같은 방법을 사용한다면 n개의 노드를 추가하기 위해 n번의 DOM 접근이 필요합니다.
이는 매우 비효율적이며, DOM을 직접 조작하는 것은 리소스가 많이 드는 비싼 작업이기 때문에 줄일 수 있으면 최소한으로 해야 합니다.
굳이 Fragment 객체를 사용하지 않아도 DOM에 한 번만 접근할 수도 있을겁니다.
(function () {
const targetNode = document.querySelector('#target') // 다른 타겟 노드를 잡습니다
const ul = document.createElement('ul') //
for (let i=0; i < 200; i++) {
const li = document.createElement('li')
li.innerText = i + '번째 리스트입니다.'
ul.append(li)
}
targetNode.append(ul)
})()
그리고 아래는 DocumentFragment 생성자를 이용해서 Fragment 객체를 만들어 DOM에 접근하는 방식입니다.
(function () {
const targetNode = document.querySelector("#target");
const fragment = document.createElement("fragment");
fragment.append(document.createElement("ul"));
for (let i = 0; i < 200; i++) {
const li = document.createElement("li");
li.innerText = i + "번째 리스트입니다.";
fragment.append(li);
}
targetNode.append(fragment);
})();
같은 결과를 내지만 아래와 같이 fragment 객체는 온데간데 없이 사라지고 ul 리스트와 200개의 자식 컴포넌트 li만 실제 DOM에 삽입된 것을 볼 수 있습니다.
이제 하나의 ul 태그와 하위 노드로 이루어진 트리를 만들어 넣는 것과 fragment 객체 트리를 넣는 것이 뭐가 다른건지 궁금해집니다.
상위 루트 노드 없이 여러개의 루트 노드로 이루어진 트리를 한번에 DOM에 삽입할 수 있다.
우선 첫번째 명백한 차이점은 document fragment를 DOM에 삽입하는 순간, fragment 객체가 감싸고 있던 하위 노드 객체들만 삽입되고 fragment는 사라진다는 점입니다.
만약 아래와 같이 형제 노드들로 이루어진 트리를 삽입한다면 각 상위 노드 개수만큼 DOM에 접근해야 합니다.
(function() {
const targetNode = document.querySelector('#target')
targetNode.append(document.createElement('div')) // 첫번째 접근
targetNode.append(document.createElement('h1')) // 두번째 접근
targetNode.append(document.createElement('p')) // 세번째 접근
})()
Fragment 객체를 사용한다면 단 한번만 DOM에 접근하고 같은 결과를 가져옵니다.
(function () {
const targetNode = document.querySelector("#target")
const fragment = new DocumentFragment()
fragment.append(document.createElement("div"))
fragment.append(document.createElement("h1"))
fragment.append(document.createElement("p"))
targetNode.append(fragment) // 단 한번만 DOM에 접근합니다
})();
사용성에 큰 차이가 없다면 브라우저가 원활히 태스크를 수행할 수 있는지 성능 비교를 해봐야 하겠죠?
스택오버플로우에 저와 같은 궁금증을 가진 분들의 글을 보았습니다.
Root 노드로써 createElement와 createDocumentFragment 어떤 것이 더 빠른지 벤치마크 테스트를 해보았습니다. 두개의 방법으로 10,000개의 노드를 루트 객체에 담아 DOM에 접근하는 케이스를 테스트합니다.
앗, 기대했던 것과 결과가 다르네요, createElement를 루트 노드로 삼은 케이스가 61.98 ops/sec로 더 빠른 결과를 보여줍니다.
다른 예제를 보아도 오히려 createElement가 월등합니다.
DocumentFragment를 주제로서 다루는 포스팅이라 주인공이 더 월등한 성능을 보여주는 것이 좋은 그림이었는데 아쉽습니다.
하지만 의외의 변수가 있습니다. 어떤 환경에서 테스트하는지에 따라 결과가 다를 수 있다는 점입니다.
윈도우와 안드로이드 환경의 크롬에선 오히려 createDocumentFragment 메소드가 월등히 빠른 결과를 보입니다. 각 OS의 브라우저마다 DOM을 처리하는 방식이 다른지, 테스트의 신뢰성이 떨어지는지 더 자세히 알 수는 없었지만, 성능상 무엇을 사용하는 것이 더 좋은 선택이라고 확정지어 말하기는 어려워졌습니다.
아무데서도 참조하지 않는 DOM 노드는 JS의 Garbage Collector에 의해 정리된다고 합니다. 이렇게 참조되지 않는 정리대상의 DOM node를 “detached” DOM 노드라고 한답니다. 하지만 설명과 달리 detached 꼬리표를 남기고 정리되지 않은 채 메모리 누수를 야기한다고 합니다.
Fragment 객체로 만든 트리는 하위 구조가 라이브 DOM에 적용될때 메모리에서 옮겨지고 빈 객체가 되기 때문에 때문에 설득력이 있어보입니다.
fragment 객체로 메모리 누수를 막을 수 있는지에 대한 내용은 나와있지 않습니다. 그리고 아직 HEAP 스냅샷을 해석하는 방법도 모릅니다. 따라서 메모리 누수가 있을 수 있다는 점만 인지하고 저의 정신 건강을 위해 넘어가기로 했습니다.
Wonkook Lee
Frontend Developer
LinkedIn