웹 브라우저는 서버로부터 HTML, CSS, JavaScript 및 다양한 파일들을 응답받습니다. 렌더링 엔진에 의해 HTML과 CSS를 파싱하여 결합된 렌더 트리를 기반으로 웹 페이지를 표시하며, JavaScript는 JavaScript 엔진에 의해 인터프리트되어 실행됩니다.

 

이러한 과정에서 최적화되지 않은 CSS 및 JavaScript 소스는 사용자에게 보여주는 화면에 딜레이를 발생시키고, 이는 소프트웨어의 사용성과 품질에 나쁜 영향을 미칩니다.

 

이번 포스팅에서는 간단한 예시 코드를 기반으로 크롬 개발자 도구로 성능 개선 포인트를 도출하고 성능을 개선하는 방법에 대해 공유드리려 합니다.

 

 

테스트 환경 구축

특정 버튼을 클릭하면, 대량의 날짜 데이터를 생성하여 리스트로 표시하는 간단한 웹 애플리케이션 예제를 통해 성능 개선을 진행합니다. 아래와 같이 테스트를 진행하기 위한 코드를 HTML 파일에 작성합니다. 간단한 HTML, CSS와 ES6 문법의 JavaScript 코드 그리고 날짜 표시를 위한 Moment.js 라이브러리를 포함하고 있습니다.

 

 

스타일
<style>
  .container {
    width: 350px;
    height: 528px;
    overflow-y: auto;
    overflow-x: hidden;
  }

  .list {
    border: solid 1px black;
    padding: 20px;
  }
</style>

 

 

리스트 렌더링을 위한 HTML 코드
<div class="container">
  <button class="add-btn">달력 생성</button>
  <div class="list-box"></div>
</div>

 

 

Script 태그
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
<script>
  // 리스트에 삽입될 Row의 템플릿
  const listTemplate = (name) => `
    <div class="list">
      <span>${name}</span>
    </div>
  `;
  
  // 리스트를 렌덩하기 위한 Class 정의
  class ListComponent {
    constructor(elem, data) {
      this._elem = elem;
      this._data = data;
    }
    addItem(index) {
      const data = this._data;
      const newId = data.length;
      data.push("날짜: " + moment().add(index, "d").format('YYYY-M-D'));
    }
    render() {
      const listDOM = this._elem;
      const data = this._data;
      listDOM.innerHTML = '';
      for (let index in data) {
        let div = document.createElement("div")
        div.innerHTML = listTemplate(data[index]);
        listDOM.appendChild(div);
      }
    }
  }

  document.addEventListener('DOMContentLoaded', () => {
    const listDOM = document.querySelector('.list-box');
    const btnToAddItem = document.querySelector('.add-btn');
    const data = [];
    
    // 리스트 컴포넌트 생성
    let listComponent = new ListComponent(listDOM, data);
    
    // 날짜 생성 이벤트 등록
    btnToAddItem.addEventListener('click', () => {
      // 날짜 데이터 생성
      console.time("AddItem");
      for (let index = 0; index < 100000; index++) {
        listComponent.addItem(index);
      }
      console.timeEnd("AddItem");
      
      // 날짜 리스트 렌더링
      console.time("Render");
      listComponent.render();
      console.timeEnd("Render")
    });
  });
</script>

 

 

Chrome 다운로드 및 개발자 도구 실행

다운로드 링크 - https://www.google.com/intl/ko/chrome/

 

다운로드 및 설치가 완료되면 예시 코드를 크롬 브라우저를 통해 실행합니다. 개발자 도구 실행 방법은 아래와 같습니다.

 

  1. 상단 오른쪽에 옵션 버튼을 클릭
  2. 도구 더 보기 클릭
  3. 개발자 도구 항목 클릭
  4. 또는 F12 키를 입력하거나 마우스 오른쪽 버튼 클릭 후 요소 검사 항목을 클릭

 

 

 

예제 코드 실행 화면

 

 

 

Performance

개요

Performance 도구는 페이지를 로드하는 시간이 너무 많이 소요되거나 버튼 클릭 시 브라우저가 멈추는 현상 등 사용자에게 부정적인 인상을 줄 수 있는 요소들을 제거하기 위해 브라우저 내부에서 일어나는 동작을 측정하여 보여줍니다.

 

 

레코딩 생성

개발자 도구 내 Performance 탭을 클릭합니다.

 

 

 

성능을 측정할 액션 또는 페이지 로딩 전에 개발자 도구 창의 상단 왼쪽에 보이는 Record 버튼을 클릭하거나  Ctrl + E  키를 입력하여 레코딩을 진행할 수 있습니다.

 

 

 

 

레코딩이 시작되면 날짜 데이터를 생성하고, 리스트를 렌더링하는 액션을 시행하기 위해, 예시의 웹 애플리케이션의 ‘날짜 생성’ 버튼을 클릭 합니다. 날짜 리스트의 렌더링이 완료되면, 다시 Record 버튼을 클릭 하거나  Ctrl + E  키를 입력하여 레코딩을 중단합니다. 아래와 같이 결과를 확인할 수 있으며, 리스트를 표시하기 위해 소요되는 시간은 약 17초 임을 알 수 있습니다.

 

 

 

 

 

 

Performance 분석

Performance 패널 개요
  1. Controls : 레코딩 시작 및 중단을 할 수 있으며, 레코딩 중에 캡처할 정보(시간 흐름에 따른 Screenshots 및 Heap Memory 상태)를 구성합니다.
  2. Overview : 레코딩이 진행되는 동안 전체적인 흐름을 보여주는 영역
  3. Timeline : 프레임 정보와 Overview에서 선택한 구간에 포함된 사용자의 인터렉션 이벤트 등의 상세정보를 제공합니다.
  4. Details : Overview의 Main에서 선택한 특정 항목에 대한 상세 정보가 표시됩니다. 선택한 항목이 없는 경우, 이 창에는 선택한 타임 프레임에 대한 정보가 표시됩니다.

 

 

 
타임라인(Timeline)
  1. Frames : 페이지 로드 시 프레임 정보를 표시합니다.

  2. Interactions : 웹 애플리케이션에서 발생한 인터렉션을 표시합니다. (Mouse Up, MouseLeave 등)

  3. Console : 사용자가 정의한 Console Time 표시합니다.

  4. Main : 브라우저에서 웹 페이지 랜더링 시 각 파트 별 소요되는 시간을 표시합니다.

 

 

 

 

성능 개선하기

JavaScript

‘달력 생성’ 버튼을 클릭하면, 100,000개의 날짜 데이터가 생성하는 이벤트 핸들러가 호출됩니다. Main 섹션의 JavaScript Task를 살펴보면, 아래와 같이 해당 이벤트 핸들러의 로직 시행에는 1초 이상의 시간이 소요되며, 굉장히 많은 콜 스택이 쌓여있음을 확인할 수 있습니다.

 

 

 

반복적으로 시행되는 콜 스택과 같이 해당 Task의 상세한 내역은 마우스 휠 또는 키보드의 W, A, S, D 키를 통해 확인할 수 있습니다. 아래의 스크린샷 처럼 addItem을 클릭할수 있을때까지 확대합니다.
addItem을 클릭하면 Summary 탭에서 스크립트 소요시간이 21ms가 소요되는것을 알수있습니다. 어떤한 경로를 통해 함수가 호출되는지 자세한 정보를 얻기 위해 요약정보가 아닌 Event Log를 클릭합니다.

 

 

 

 

Event Log 를 확인해보면 addItem에서 호출되는 함수들은 모두 moment에서 호출되는것을 확인되었고 날짜를 추가 및 출력하는 간단한 동작에 비해 너무 많은 콜스택이 생성되어 해당 부분에 대해 개선을 진행하면 성능 향상에 도움 될 수있다는 것 을 확인할 수 있습니다.

 

 

 

addItem 메소드 내에는 날짜 형식을 정의하기 위한 Moment.js 라이브러리를 사용하고 있으며, 생성되는 날짜 데이터 갯수(100,000)만큼, moment 객체를 참조하고 있습니다.

 

addItem(index) {
  const data = this._data;
  const newId = data.length;
  data.push("날짜: " + moment().add(index, "d").format('YYYY-M-D'));
}

 

아래와 예시와 같이 순수 JavaScript를 사용하여, 날짜 문자열의 형식을 정의하고 출력하는 코드로 대체합니다.

 

class ListComponent {
  constructor(elem, data) {
    this._elem = elem;
    this._data = data;
    this._today = new Date();
    }
    addItem() {
      let today = this._today
      today.setDate(today.getDate() + 1);
      this._data.push("날짜: " + today.getFullYear() + '-' + today.getMonth() + '-' + today.getDate());
    }
    ...
}

 

페이지를 리로드하고, 레코딩을 재시행한 결과, 기존 1.32초에서 0.35초로 약 61%의 소요 시간이 단축된 것을 확인할 수 있습니다.

 

 

 

 

Style

개선한 JavaScript 코드를 통해 리스트를 다시 출력하였습니다. 예시 웹 애플리케이션에서 리스트의 홀수 번째 요소들은 Gray 색상이 배경색으로 적용됩니다. Chrome 개발자 도구의 Performance 분석을 통해 Style 에서 1.18초의 시간이 소요됨을 발견하였습니다. 이를 수정하여 개선하도록 합니다.

 

 

 

Style을 최적화 하는 경우, 일반적으로 CSS가 적용되는 DOM의 수를 줄이거나, 정의된 Selector의 Depth를 최소화하는 방법이 있습니다. 예시 웹 애플리케이션에서는 최소한의 CSS 코드를 적용하고 있으므로, Selector 복잡성을 감소시키는 방법을 진행하겠습니다. 기존 Style 태그의 코드를 아래와 같이 수정합니다.

 

<style>
  .container {
    width: 350px;
    height: 528px;
    overflow-y: auto;
    overflow-x: hidden;
  }
  .container .list {
    border: solid 1px black;
    padding: 20px;
  }
  /* 성능 이슈를 발생하는 코드 삭제
  .container .list:nth-last-child( 2n + 1 ) {
    background-color: darkgrey;
  }
  */    
  .container .list.gray {
    background-color: darkgrey;
  }
</style>

 

또한 클래스를 삽입하기 위한 JavaScript의 코드 아래와 같이 수정합니다.

 

<script>
  const listTemplate = (name, color) => `
    <div class="list ${color}">
        <span>${name}</span>
    </div>
  `;
  class ListComponent {
    render() {
      const listDOM = this._elem;
      const data = this._data;
      listDOM.innerHTML = '';
      let len = data.length;
      let index = 0;
      let toggleClass = true;
      while (index < len) {
          listDOM.insertAdjacentHTML('beforebegin', listTemplate(data[index], toggleClass ? 'gray' : ''));
          index++;
          toggleClass = !toggleClass
      }
    }
  }
</script>

 

레코딩 단계를 다시 시행하여, Main 섹션의 결과를 살펴보면, 아래와 같이 1.18초에서 1.02초로 기존보다 약 14% 단축 되었음을 것을 확인할 수 있습니다.

 

 

 

 

Layout

예시 웹 애플리케이션은 100,000개의 Row를 렌더링하고 있습니다. 이는 날짜 데이터의 수 만큼, DOM을 추가하게되고, 그 과정에서 브라우저가 지속적으로 DOM의 위치를 재계산하게 되므로, Layout 단계의 비용이 증가하는 성능 이슈를 야기하게 됩니다. 아래와 같이 Performance 레코딩 결과를 보면, Layout으로 소요되는 시간은 3.63초 입니다. 단순한 문자열을 리스트의 형태로 렌더링하는 로직에서 3초 이상의 시간이 소요되는 것은 심각한 성능 문제라고 볼 수 있습니다.

 

 

 

일반적으로 Layout의 비용을 줄이기 위해서는 렌더링 되는 DOM의 수를 줄이거나, 불필요한 Reflow 과정이 발생하는 상황을 피해야 합니다. 예시 애플리케이션에서는 JavaScript 코드를 수정하여 Virtual Scroll(가상 스크롤) 기법을 활용하여 성능 개선을 진행합니다. Virtual Scroll의 간략한 의미는 아래와 같습니다.

웹 브라우저 상에 렌더링된 DOM의 수가 많은 경우, 이를 스크롤 하는 상황에 발생하는 사용성 문제를 해결하기 위한 방법입니다. DOM이 표시될 뷰포트를 정의하고, 해당 영역의 높이, 각 DOM의 높이 및 스크롤된 위치를 계산하여 필요한 DOM만 렌더링 될 수 있도록 합니다.

 

Virtual Scroll 기법을 적용한 이후, 실제 100,000개의 데이터가 존재하지만, 브라우저에 실제 렌더링되는 리스트 내에는 8개의 아이템만 표시되도록 변경될 것입니다.

 

 

스타일

Virtual Scroll 관련 스타일 정의를 추가합니다.

 

<style>
    .container {
        width: 350px;
    }

    #viewport {
        float: left;
        border: 1px solid black;
        overflow: auto;
        width: 350px;
    }

    .list-box {
        position: relative;
        overflow: hidden;
    }

    .container .list {
        position: absolute;
        left: 0px;
        width: 100%;
        border: solid 1px black;
        padding: 20px;
        line-height: 8px;
        box-sizing: border-box;
    }

    .container .list.color {
        background-color: darkgrey;
    }
</style>

 

 

리스트 렌더링을 위한 HTML 코드

뷰포트 생성을 위한 요소를 추가합니다.

 

<div class="container">
    <button class="add-btn">달력 생성</button>
    <div id="viewport">
        <div class="list-box"></div>
    </div>
</div>

 

 

JavaScript

뷰포트 생성 및 스크롤 관련 계산을 위한 로직을 추가합니다.

 

<script>
    const th = 100000; // 가상 높이
    const h = 100000; // 실제 스크롤 가능 높이
    const ph = h / 100; // 페이지 높이
    const n = Math.ceil(th / ph); // 페이지 개수
    const vp = 400; // 보이는 부분 높이
    const rh = 50; // 행 높이
    const cj = (th - h) / (n - 1); // "넘어가는" 계수

    // 리스트에 삽입될 Row의 템플릿
    const listTemplate = (name, color) => `
        <span>${name}</span>
    `;
    // 리스트를 렌덩하기 위한 Class 정의
    class ListComponent {
        // 리스트컴포넌트 생성자
        constructor(viewportElem, elem, data) {
            this._viewportElem = viewportElem;
            this._elem = elem;
            this._data = data;
            this._today = new Date();

            this.prevScrollTop = 0; // 이전 스크롤
            this.offset = 0; // 현재 페이지 위치
            this.page = 0; // 현재 페이지
            this.rows = {}; // 캐시된 행 노드

            this._viewportElem.style.height = vp + 'px';
            this._elem.style.height = h + 'px';;

            this._viewportElem.addEventListener('scroll', function (e) {
                this.onScroll(e);
            }.bind(this), false);
        }
        // 날짜 아이템 추가
        addItem() {
            let today = this._today
            today.setDate(today.getDate() + 1);
            this._data.push("날짜: " + today.getFullYear() + '-' + today.getMonth() + '-' + today.getDate());
        }
        // 최초 랜더링
        render() {
            this.onScroll();
        }
        // 스크롤 이벤트 발생시 View 데이터를 갱신하는 함수
        onScroll() {
            let scrollTop = this._viewportElem.scrollTop;
            console.log(scrollTop);
            if (Math.abs(scrollTop - this.prevScrollTop) > vp)
                this.onJump();
            else
                this.onNearScroll();
            this.renderViewport();
        }
        onNearScroll() {
            let scrollTop = this._viewportElem.scrollTop;

            if (scrollTop + this.offset > (this.page + 1) * ph) {
                this.page++;
                this.offset = Math.round(this.page * cj);
                this._viewportElem.scrollTo(0, this.prevScrollTop = scrollTop - cj);
                this.removeAllRows();
            }
            else if (scrollTop + this.offset < this.page * ph) {
                this.page--;
                this.offset = Math.round(this.page * cj);
                this._viewportElem.scrollTo(0, this.prevScrollTop = scrollTop + cj);
                this.removeAllRows();
            }
            else
                this.prevScrollTop = scrollTop;
        }

        onJump() {
            let scrollTop = this._viewportElem.scrollTop;
            this.page = Math.floor(scrollTop * ((th - vp) / (h - vp)) * (1 / ph));
            this.offset = Math.round(this.page * cj);
            this.prevScrollTop = scrollTop;
            this.removeAllRows();
        }
        // 전체 데이터 삭제
        removeAllRows() {
            let rows = this.rows;
            for (let i in rows) {
                rows[i].remove();
                delete rows[i];
            }
        }
        // 현재 스크롤 기준으로 위치를 계산하고 DOM을 생성하는 함수
        renderViewport() {
            let toggleClass = false;
            let rows = this.rows;
            let y = this._viewportElem.scrollTop + this.offset,
                buffer = vp,
                top = Math.floor((y - buffer) / rh),
                bottom = Math.ceil((y + vp + buffer) / rh);

            top = Math.max(0, top);
            bottom = Math.min(th / rh, bottom);
            for (let i in rows) {
                if (i < top || i > bottom) {
                    rows[i].remove();
                    delete rows[i];
                }
            }
            for (let i = top; i <= bottom; i++) {
                if (!rows[i])
                    rows[i] = this.renderRow(i, toggleClass);
                else
                    rows[i].setAttribute('class', 'list ' + (toggleClass ? ' color' : ''))
                toggleClass = !toggleClass;
            }
        }

        renderRow(row, toggleClass) {
            const listDOM = this._elem;
            const data = this._data;

            let div = document.createElement('div')
            div.innerHTML = listTemplate(data[row]);
            div.setAttribute('class', 'list ' + (toggleClass ? ' color' : ''))
            div.style.top = row * rh - this.offset + 'px';
            div.style.height = rh + 'px';
            return listDOM.appendChild(div);
        }
    }
    document.addEventListener('DOMContentLoaded', () => {
        const viewportDOM = document.querySelector('#viewport');
        const listDOM = document.querySelector('.list-box');
        const btnToAddItem = document.querySelector('.add-btn');
        const data = [];

        // 리스트 컴포넌트 생성
        let listComponent = new ListComponent(viewportDOM, listDOM, data)
        btnToAddItem.addEventListener('click', () => {
            for (let index = 0; index < 100000; index++) {
                // 날짜 데이터 생성
                listComponent.addItem(index);
            }
            // 날짜 리스트 렌더링
            listComponent.render();
        });
    });
</script>

 

 

결과

DOM 갯수가 100,000개에서 8개로 줄어들었기 때문에 Layout 단계에서 소요되는 비용은 1.37ms 대폭 감소하였으며, Painting 단계까지 0.2초가 소요되어 기존보다 랜더링 성능이 개선됨을 확인할 수 있었습니다.

 

 


지금까지 간단한 웹 애플리케이션을 예시로 Chrome 개발자 도구를 활용하여 성능 분석 및 개선하는 방법을 다루었습니다. 웹 페이지가 빠른 속도로 컨텐츠를 표시하는 것은 방문자 유지 및 사용자 만족도를 높이는 데 중요한 역할을 합니다. 성능 문제로 인해 어려움이 겪는 웹 개발자에게 본 포스트가 많은 도움이 되길 바랍니다.

 

 

 

Reference