Pro-Pro 프로젝를 회고하며 웹 성능 해결과 관련된 내용을 정리한다.
프로젝트 당시 페이지에 새로운 게시글 컴포넌트들을 생성할 때마다 appendChild 메소드로 부모 노드에 붙이는 방식이었다. 하지만, 이런 방식은 새로운 컴포넌트를 붙일 때마다 브라우저가 요소를 재배치한다. 개발 단계에는 많은 게시글을 보여주지 않기 때문에 큰 성능 차이가 보이지 않았지만, 배포 이후 사용자가 몰린다면 성능 저하로 직결되는 문제라 생각했다.
브라우저의 렌더링 과정을 되짚으며 문제가 일어난 단계와 해결방안을 고민하기로 했다. 이전에 읽었던 Naver D2의 “브라우저는 어떻게 동작하는가?” 포스팅이 생각나 이 글을 보며 어느 과정에서 문제가 발생했는지 고민했다.
나는 컴포넌트를 새로 화면에 표시하는 작업에 문제가 생겼기 때문에 렌더링 엔진 부분에 주목해야 한다.
다음은 렌더링 엔진의 전체적인 동작과정이다.
<html>
<body>
<p>Hello World</p>
<div><img src="example.png" /></div>
</body>
</html>
다음과 같은 마크업에서 DOM 트리 그림은 아래와 같이 그려진다.
CSS 파일은 스타일 시트 객체로 파싱되고 각 객체는 CSS 규칙을 포함한다. CSS 규칙 객체는 선택자와 선언 객체 그리고 CSS 문법과 일치하는 다른 객체를 포함한다.
아래 그림은 Ucacity에서 The Render Tree - Website Performance Optimization영상의 일부다. 렌더트리 구축을 좀 더 시각적으로 표현해서 참고자료로 첨부했다.
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Critial Path: Hello world!</title>
</head>
<body>
<div style="width: 50%">
<div style="width: 50%">Hello world!</div>
</div>
</body>
</html>
위 코드를 기반으로 한 Render Tree에는 노드와 그 노드에 대한 스타일이 정의되어 있다. 하지만 실제 화면에서 어느 곳에 어떤 크기로 위치할지는 정해지지 않은 상태이다.
이를 위해 이 단계에선 Render Tree를 루트에서부터 탐색하여 실제 기기 화면의 크기에 맞춰 노드의 정확한 위치와 크기를 계산한다. 상대적이었던 측정값을 화면 안에서의 절대적인 픽셀로 변환하는 과정이다.
Render Tree를 순회하며 이미 Reflow에서 계산한 위치, 크기를 제외한 나머지 CSS 속성들을 적용하여 렌더 트리의 각 노드를 화면의 실제 픽셀로 변환한다.
나의 코드는 DOM 노드를 추가하는 코드였기 때문에 Reflow 단계에서 문제가 발생했다고 생각했다. 컴포넌트를 직접 부모 노드에 붙이다 보니 이를 재배치하기 때문이다. 성능을 개선하려면 Reflow를 줄여야 한다 생각해서 이를 해결하기 위한 방안을 고민했다.
특정 요소를 생성하여 컴포넌트들을 이 요소에 모두 붙인 뒤, 그 요소를 부모 노드에 붙인다면 해당 요소가 노드에 붙는 한 번의 Reflow만으로 페이지를 구현할 수 있다고 생각했다. 어떤 요소를 사용해야 좋을지 고민 도중 MDN에서 DocumentFragment라는 객체를 접했다.
DocumentFragment는 일반 문서처럼 노드로 구성된 문서 구조를 저장할 수 있지만 활성화된 문서 트리 구조의 일부가 아니기 때문에 내부의 트리를 변경해도 문서나 성능에 아무 영향도 주지 않는 객체다. 이는 해당 객체 내부에 다수의 컴포넌트를 붙여도 문서에 직접적인 영향을 주지 않고, 객체를 직접 문서에 붙여야 Reflow가 발생함을 의미한다. 이런 특성이 Reflow를 줄이기 위한 방안을 찾던 나에게 적합하다 생각해서 이를 활용해 코드를 리팩터링했다. 공식문서의 다음 코드를 참고했다.
const ul = document.querySelector('ul');
const fruits = ['Apple', 'Orange', 'Banana', 'Melon'];
const fragment = new DocumentFragment();
for (const fruit of fruits) {
const li = document.createElement('li');
li.textContent = fruit;
fragment.append(li);
}
ul.append(fragment);
리팩터링전 코드다.
cardRender() {
const cards = this.container.querySelector('.bookmark__cards');
this.state.cards.forEach(item => {
const card = createDom('div', {
className: 'card-wrapper',
});
new Card({
container: card,
props: {
type: 'bookmark',
post: item,
},
});
cards.appendChild(card);
});
}
크롬 성능테스트 결과는 다음과 같다.
DocumentFragment 객체를 하나 선언하고, 컴포넌트들을 직접 부모 노드에 붙이지 않고 이 객체에 붙인 뒤, 해당 객체를 부모 노드에 붙이는 방식으로 코드 스타일을 바꿨다.
cardRender() {
const cards = this.container.querySelector('.bookmark__cards');
const frag = new DocumentFragment();
this.state.cards.forEach(item => {
const card = createDom('div', {
className: 'card-wrapper',
});
new Card({
container: card,
props: {
type: 'bookmark',
post: item,
},
});
frag.appendChild(card);
});
cards.appendChild(frag);
}
크롬 성능테스트 결과는 다음과 같다.
개발 단계에서의 테스트라 사실 크게 눈에 띄는 개선은 없었다. 하지만, 기존에는 한 번도 고민 해본 적 없던 서비스 성능과 관련된 문제를 협업을 위해 도입했던 방법으로 해결하며 개발자로서 한 단계 성장할 수 있었던 경험이었다.
https://d2.naver.com/helloworld/59361
https://twofivezero.tistory.com/56
https://web.dev/critical-rendering-path-render-tree-construction/
https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment