본문 바로가기

[프론트엔드] 성능 최적화 정리

목차

0. 개요

1. 브라우저 동작 원리

2. 성능 최적화

    2.1 페이지 로드 최적화

    2.2 페이지 렌더링 최적화

3. 성능 측정 기준

    3.1 브라우저 내부 이벤트 기준

    3.2 사용자 기준 성능 지표

    3.3 사용자 기준 성능 최적화

4. 정리


0. 개요

프론트엔드의 성능 최적화는 화면을 설계하는 당시에는 사실 고려하기 힘든 경우가 많았다.

 

회사의 납기를 준수하여야 하는 경우도 있고, 우선적으로 기능 구현을 해서 테스트를 진행하는게 중요했던 상황도 많았기 때문이다.

 

하지만 프로젝트가 끝나고 유지보수를 하거나, 다음 프로젝트를 하기 전 시간이 나는 경우에 틈틈히 프론트엔드 성능 최적화에 대해 학습하면서 적용해나가다보니, 다음 프로젝트를 착수하게 되었을 땐, "아, 이 부분을 이렇게 설계하면 성능이 더 좋겠구나"하면서 설계가 가능했던 적도 있었다.

 

역시 한번에 Full로 학습하려고 하기보다는 경험이 쌓이면서 얻어 가는게 더 오래 남는 거 같다.

 

이 포스팅도 아마 성능 최적화를 해본 경험이 쌓이면서 계속 업데이트할 것이다.

 

아래는 주로 참고하면서 실무에 적용하거나 학습했던 웹사이트들이다.

- Google Developers

- Toast UI FE Guide

 

1. 브라우저 동작 원리

먼저, 성능최적화를 학습하기 앞서 기본 배경이 되는 브라우저가 어떻게 화면을 사용자에게 보여주는지를 알아야 한다.

 

사용자가 https://www.google.co.kr을 주소창에 입력하고 Enter를 누르면 내부적으로 어떻게 동작하는지 차근차근 알아본다.

 

1.1 사용자 브라우저의 호스트파일, 브라우저 캐시에 해당 URL의 파일정보가 있는지 확인

이전에 접속한 적이 있는 페이지이고, 캐싱이 적용되었다면 별도의 DNS 요청 없이 URL을 띄울 수 있다.

 

1.2 DNS에 실 IP 주소를 요청하고, 리소스를 받을 준비

호스트의 도메인 이름을  실제 IP 주소로 변환하기 위해 DNS에 요청한다. 이제 이 IP 주소의 서버에게 리소스를 요청하여 받아올 준비를 마쳤다.

 

1.3 HTML, CSS 파싱

서버로부터 받아온 파일 중 HTML과 CSS를 각각 DOM Tree, Style Tree(혹은 CSSOM(Object Model)이라고도 함)으로 파싱한다.

 

1.4 Attachment로 렌더링 트리 생성

위에서 파싱한 결과물인 DOM Tree와 Style Tree의 시각 정보를 연결하는 Attatchment 작업을 통해 렌더링 트리를 생성한다.

렌더링 트리는 실제 페이지에서 사용되는 노드만을 포함하고 있으며, 루트부터 탐사하면서 해당 노드에 일치하는 스타일을 연결한다.

렌더링 트리 예시

참고: Google Developers

 

1.5 레이아웃(리플로우)으로 실제 위치 계산

위의 렌더링 트리에는 계산된 스타일만을 연결한 것이지 화면 상 어디에 위치할지를 계산한 것은 아니다. 즉, 페이지 내의 실제 위치를 계산하는 작업이 필요하다. 렌더링 트리를 그릴 때와 마찬가지로, 루트부터 탐사하면서 노드의 화면 상 실제 위치(px)을 계산한다. 만약 css 규칙에서 position의 top,left,bottom,right 값을 상대값(%)으로 주었다면, 레이아웃 단계에서는 절대값(px)으로 변환된다.

 

1.6 페인트 메소드 호출

레이아웃 작업이 완료되면, 브라우저는 이제 페인트 메소드를 호출하는데, 페인트 메소드를 호출하면, 렌더링 트리의 각 노드를 화면에 실제 그릴 수 있는 레이어를 생성한다. 이 단계를 "래스터화"라고 부르기도 한다.

 

1.7 레이어 합성

위에서 생성된 개별 레이어들을 합성하여 렌더링하면 이제 사용자에게 보여줄 화면이 완성된다.

 

위에서 말한 단계를 Google Developers에서는 이렇게 정리해서 결론짓는다.

다음은 브라우저의 단계를 빠르게 되짚어 보겠습니다.
  1. HTML 마크업을 처리하고 DOM 트리를 빌드합니다.
  2. CSS 마크업을 처리하고 CSSOM 트리를 빌드합니다.
  3. DOM 및 CSSOM을 결합하여 렌더링 트리를 형성합니다.
  4. 렌더링 트리에서 레이아웃을 실행하여 각 노드의 기하학적 형태를 계산합니다.
  5. 개별 노드를 화면에 페인트합니다.

페이지 로드가 끝나고, 사용자 인터렉션으로 DOM 혹은 CSS규칙이 수정되어 화면이 다시 렌더링되어야 할 경우에는 위의 단계들이 다시 실행된다. 따라서 위의 렌더링 프로세스를 최적화하는 것이 곧 성능 최적화하는 방법이라고 할 수 있다.

주요 렌더링 경로를 최적화하는 작업 은 위 단계에서 1단계~5단계를 수행할 때 걸린 총 시간을 최소화하는 프로세스입니다.
이렇게 하면 콘텐츠를 가능한 한 빨리 화면에 렌더링할 수 있으며, 초기 렌더링 후 화면 업데이트 사이의 시간을 줄여 줍니다. 따라서 대화형 콘텐츠의 새로고침 속도를 높일 수 있습니다.

 

2. 성능 최적화

성능최적화가 가능한 시점을 기준으로  페이지를 로드할 때와 페이지를 렌더링할 때로 분류할 수 있는데, 각각의 경우에 적용할 수 있는 성능최적화를 알아본다.

2.1 페이지 로드 최적화

2.1.1 블록 차단 리소스 최적화

HTML을 파싱할 때, css나 js를 만나게 되면, HTML 파싱을 중단하고 해당 파일을 파싱하거나 다운로드 후 실행하게 되는데, 이처럼 HTML 파싱을 차단하는 요소를 블록 차단 리소스라고 한다.

HTML 파서는 스크립트 태그를 만나면 DOM 생성 프로세스를 중지하고 자바스크립트 엔진에 제어 권한을 넘깁니다. 자바스크립트 엔진의 실행이 완료될 후 브라우저가 중지했던 시점부터 DOM 생성을 재개합니다.
다시 말해서, 요소가 아직 처리되지 않았기 때문에 스크립트 블록이 페이지의 뒷부분에서 어떠한 요소도 찾을 수 없습니다. 즉, 인라인 스크립트를 실행하면 DOM 생성이 차단되고, 이로 인해 초기 렌더링도 지연되게 됩니다.

블록 차단 리소스는 곧 렌더링 차단 요소에 속하기 때문에 올바른 실행 위치에서 코드를 작성해야 한다.

CSS의 경우, <head>태그 안에 임포트해야 하며, <script>태그로 실행되는 js는 일반적으로 <body>맨 하단에 위치시킨다.

<!DOCTYPE html>
<html>
  <head>
    <!-- css는 head 안에! -->
    <link href="style.css" rel="stylesheet" />
  </head>
  <body>
    <div>
    </div>
      <!-- js는 body 하단에! -->
    <script>
    //...
    </script>
  </body>
</html>

혹은 css와 js에 특정 속성으로 블로킹을 방지할 수도 있다.

먼저 css의 경우 media attribute로 어떤 단말기의 유형인지에 따라 해당 css를 적용할지를 명시하면 불필요한 블로킹을 방지할 수 있다.

<link href="style.css" rel="stylesheet" />
<link href="print.css" rel="stylesheet" media="print" />
<link href="mobile.css" rel="stylesheet" media="width:780px" />

js의 경우 비동기로 다운로드하도록 명시한다면, DOM 트리나 Style 트리를 변경하지 않겠다는 의미이므로 defer, async attribute를 활용한다.

단, defer의 경우, IE9에서는 치명적인 버그가 있으니, 사용을 주의한다.

자바스크립트는 명시적으로 비동기로 선언되지 않은 경우 DOM 생성을 차단합니다.
<body>
    <div>
    </div>
      <!-- HTML 파싱을 블로킹하지 않고 다운로드 -->
    <script async>
    //...
    </script>
</body>

2020.09.01. 추가

Non-critical CSS 파일의 로드 방식을 변경함으로써 웹 성능을 향상시킬 수 있다.

출처. Improving website performance by eliminating render-blocking CSS and JavaScript

<head>
  <!-- ... -->

    <link crossorigin rel="preload" href="/path/to/styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
    <noscript><link rel="stylesheet" href="/path/to/styles.css"></noscript>

  <!-- ... -->
</head>

 

 

  • link rel="preload" as="style"로 load 이벤트를 막지 않으면서 css 파일을 요청할 수 있다.
  • onload="this.onload=null;this.rel='stylesheet'로 css 파일이 로드 이벤트 이후에 파싱되고, onload 함수가 제거됨을 보장한다.
  • noscript 태그는 JS 사용이 불가능해도 스타일을 로드할 수 있도록 한다.

아래의 preload, prefetch를 사용하여 웹 성능을 향상시킨 사례와 동작 원리를 번역한 글또한 참고바란다.

Preload, Prefetch And Priorities in Chrome


2021.08.12. 추가

폰트 최적화 관련 잘 정리된 글

웹폰트 최적화 하기

Optimize WebFont loading and rendering

 

async 와 defer의 차이

async 는 스크립트 로드만 병렬적으로 실행하므로 실행 순서를 보장할 수 없다.

반면, defer 는 병렬적으로 파일을 로드하면서 모든 DOM이 로드된 후에 스크립트를 실행하므로 실행 순서가 보장된다. 즉, 파일 간 의존성을 해치지 않으면서 모든 DOM 요소에 접근할 수 있다.

 

참고 : 스크립트의 실행 시점을 조절하는 Async와 Defer 속성

 


 

2.1.2 리소스 용량 줄이기

리소스의 용량을 줄임으로써 리소스 다운로드 시간을 최적화할 수 있다.

 

먼저, 리소스에 해당하는 데이터의 유형은 어떤 것들이 있을까?

 

크롬 개발자 도구의 Network 탭 내 필터에서 리소스의 유형을 다음 그림처럼 구분하였다.

XHR은 xml htttp request로 브라우저단에서 서버단으로 HTTP 비동기 통신 할 때 request 전문이 어떻게 구성되어 서버로 전달되는지와 서버로 부터 요청에 따른 Response 결과를 확인하는 용도이므로 제외한다.

 

따라서, JS의 용량 최적화부터 알아보자.

  • 트리 쉐이킹

트리 쉐이킹은 마치 나무를 흔들면 몇몇 가지가 땅으로 떨어지는 것처럼, 외부 모듈에서 필요한 기능만을 임포트하는 것을 의미한다. 거대한 lodash 파일을 한번에 import해오는 것보다  lodash의 curry 기능만을 사용하고자 다음 코드처럼 임포트한다면 파일 용량은 더욱 작아질 것이다.

import _ from 'lodash'; // bad :(
import array from 'lodash/array'; // good :)
  • 불필요한 코드는 제거한다.
  • tab size는 2 spaces를 권장한다.
  • 압축(Minify) 및 난독화(Uglify)로 용량을 최소화한다.

다음으로 CSS를 최적화하는 방안을 알아보자.

  • 간결한 셀렉터를 사용하자.
  • 공통 스타일은 class로 정의하여 사용한다.

마지막으로 이미지나 미디어, 폰트의 경우를 알아보자.

  • 이미지의 경우, 시각적인 품질의 차이가 작다면 png보다 jpg, jpeg의 이미지 크기가 더 작으므로 jpg, jpeg 확장자를 사용한다. 아래 그림은 어떤 이미지 확장자를 선택할 것인가에 대한 기준이다.

출처: Google Devleopers

  • 애니메이션이 적용된 요소의 경우, gif보다 video 태그로 mp4 파일을 사용하여 적은 용량의 리소스를 요청할 수 있다.
  • 글꼴에는 크게 WOFF2, WOFF, EOT 및 TTF 형식이 있다. 각 브라우저마다 호환되는 글꼴 타입과 각 형식별로 지원하는 언어가 다르므로 서비스에서 지원하고자 하는 브라우저와 언어에 따라 파일을 적절하게 임포트한다.

    폰트는 렌더링 트리가 완성된 후 렌더링 트리에서 글꼴 버전이 명시되어 있으면 폰트를 요청한 후, 페인트 단계에서 브라우저가 글꼴을 적용한 텍스트 픽셀을 페인팅하기 시작한다.

    보통 요청 시점과 페인트 시점 간의 차이로 인해 종종 텍스트가 보이지 않는 현상이 나타나기도 해서 preload하거나 font-display 속성을 @font-face에 추가하여 제어할 수 있다. 자세한 설명은 여기에서 확인할 수 있다.

2.1.3 리소스 요청 개수 줄이기

리소스 요청 개수를 줄이는 것 또한 사용자에게 더 빠르게 페이지를 보여줄 수 있는 중요한 성능 최적화이다.

 

먼저 이미지 요청 개수를 줄일 수 있는 방법을 알아본다.

이미지가 다수 사용되는 웹 서비스에서, 각각의 이미지 파일을 서버로 요청하는 것보다 이미지를 하나로 묶어 한 번의 리소스 요청을 통해 가져와 background-position 속성으로 원하는 부분만 표시하는 기법을 말한다.

커머스 웹처럼 이미지가 다수 필요한 서비스에서, 사용자 화면에 보이는 이미지만 요청하고, 사용자가 스크롤을 내려 다른 이미지가 보여야 할 때 이미지를 요청하는 지연 로딩을 통해 리소스 요청을 줄일 수 있다.

반드시 '첫 화면에서' 보여야 하거나 웹페이지가 처음 나타냈을 때의 이미지는 바로 로드됩니다.
'스크롤을 내려야 보이는' 이미지는 아직 사용자가 볼 수 없습니다.
이러한 이미지는 즉시 브라우저에 로드되지 않습니다. 사용자가 스크롤을 내려 표시해야 할 때만 추후에 로드(지연 로드)됩니다.

css와 js의 요청 개수는 어떻게 줄일 수 있을까?

  • 모듈 번들러로 css와 js 번들링하기

webpack과 같은 모듈 번들러로 여러 개의 js 파일을 하나의 파일로 번들링할 수 있다. webpack은 더군다나 공통 기능 단위로 js를 code splitting도 한다.

출처: google developers: web performance optimization with webpack

  • 캐싱할 필요없는 style은 내부 스타일시트 사용하기

<link>로 가져오는 외부 스타일시트가 아닌, <style> 태그로 포함하는 내부 스타일시트를 사용하여 외부 css 요청 횟수를 줄일 수 있다. 다만 내부 스타일시트는 캐싱되지 않으므로 필요한 경우에만 포함한다.


마지막으로 이미지, 폰트 등의 정적 리소스들 뿐만 아니라 css, js 파일들을 캐싱하여 리소스 요청을 줄일 수 있다.

 

웹 캐시란, 어플리케이션을 빠르게 처리하기 위해 클라이언트에서 서버로 정적 컨텐츠(JS, CSS, 이미지 등)를 요청할 때, 이것을 클라이언트(혹은 서버) 캐시에 저장해두고, 해당 컨텐츠를 재호출할 때 서버 요청을 통하지 않고 캐시에서 가져와 활용할 수 있다.

 

웹 캐시의 종류는 브라우저 캐시, 프록시 캐시, 게이트웨이 캐시가 있다. 자세한 설명은 여기를 참고하자.

 

2.1.4 정리

위의 페이지 로드 최적화를 한눈에 보기 쉽게 표로 정리해보았다.

  블록 차단 리소스 최적화 리소스 용량 줄이기 리소스 요청 개수 줄이기
이미지, 폰트 - 올바른 확장자 사용
불필요한 파일 요청 지양
이미지 스프라이트
지연 로딩
css <head> 내 위치 복잡한 셀렉터 지양
공통 스타일은 클래스로 설정
내부 스타일시트 사용
js <body> 내 하단에 위치 트리 쉐이킹
불필요한 코드 제거
tab size는 2 spaces
압축 및 난독화
모듈 번들러로 번들링
공통 - - 캐싱

 

 

2.2 페이지 렌더링 최적화

다음으로 페이지 렌더링을 최적화하는 방법을 살펴본다.

 

레이아웃 과정은 각 요소들의 화면 상 실제 위치를 계산하는 작업이기 때문에 비용이 크다. 사용자가 DOM 요소를 추가·수정하거나 위치에 영향을 미치는 속성(top, width 등)을 수정하면, 위치를 다시 계산해야 하기 때문에 레이아웃이 다시 발생한다.

 

따라서 렌더링 최적화의 목표는 레이아웃을 최대한 빠르게, 최대한 적게 발생하는 것이다.

 

먼저, JS를 최적화하는 방법을 소개한다.

2.2.1 강제 동기식 레이아웃과 레이아웃 스레싱 피하기

강제 동기식 레이아웃이란, 레이아웃 과정이 끝나기 전에, JS 파일에서 아래의 예시처럼 DOM 요소의 위치나 크기값을 변경 후 바로 가져오려 하면 강제로 레이아웃을 발생시키는데, 이것을 강제 동기식 레이아웃이라고 한다.

function logBoxHeight() {

  box.classList.add('super-big');

  // Gets the height of the box in pixels
  // and logs it out.
  console.log(box.offsetHeight);
}

레이아웃 스레싱은 이 강제 동기식 레이아웃을 반복문 내에서 연속적으로 사용하는 것을 의미한다.

 

이러한 강제 동기식 레이아웃과 레이아웃 스레싱은 불필요할수도 있는 레이아웃을 추가적으로 발생시키기 때문에 지양해야 한다.

2.2.2 상위 DOM 요소보다 하위 DOM 요소를 사용하기

상위 DOM 요소를 사용하면 내부 하위 DOM 요소에도 영향을 미치기 쉬우므로, 가급적 하위 DOM 요소를 변경한다.

체크 항목

  • 부모-자식 관계 : 부모 엘리먼트의 높이가 가변적인 상태에서 자식 엘리먼트의 높이를 변경할 경우, 부모 엘리먼트부터 레이아웃이 다시 일어난다. 이때 부모 엘리먼트의 높이를 고정하여 사용하면 하단에 있는 엘리먼트는 영향을 받지 않게 된다. 예를 들어 높이가 모두 다른 여러 개의 탭 콘텐츠가 있을 때, 부모 엘리먼트(탭 컨테이너)의 높이를 고정하여 사용한다.
  • 같은 위치에 있는 엘리먼트 : 여러 개의 엘리먼트가 인라인(inline)으로 놓여 있을 때 첫 번째 엘리먼트의 width 값 변경으로 인해 나머지 엘리먼트의 위치 변경이 일어나므로 유의한다.

2.2.3 display: none;으로 설정된 속성은 레이아웃이 발생하지 않는 점을 활용한다.

위의 렌더링 트리 생성 단계에서 굵은 글씨로 강조한 부분이 있다.

렌더링 트리는 실제 페이지에서 사용되는 노드만을 포함하고 있으며, 루트부터 탐사하면서 해당 노드에 일치하는 스타일을 연결한다.

렌더링 트리에 속한 DOM 요소들은 모두 사용자 화면에 보여지는 요소들로만 이루어져 있다. 따라서 display 속성이 none인 경우에는 렌더링 트리에 포함되지 않는다.

 

※ 주의: visibility: hidden;으로 설정한 요소는 화면에 보이지는 않지만 그 영역은 존재하므로, 렌더링 트리에 포함된다.

 

만약 JS에서 DOM 요소를 조작하고 싶다면 display: none으로 초기 설정한 다음에 요소를 조작한 후, display: "";으로 변경함으로써 성능을 최적화할 수 있다.

2.2.4 domFragment를 활용하기

10개의 동적으로 생성한 요소들을 반복적으로 어떤 parentNode에 추가해야 한다고 가정하자.

 

보통 이렇게 구현할 것이다.

const parentNode = document.getElementById("parent")
const cnt = 10;

for (let i=0;i<cnt;i++) {
  const newNode = document.createElement('li');
  newNode.innerText = `this is ${i}-content`;
  
  parentNode.appendChild(newNode);
}

위의 코드처럼 구현한다면 10번의 레이아웃이 발생해야 하지만, domFragment에 추가된 요소들을 parentNode에 append하면 한 번만 DOM 객체에 접근하면 되므로 효율적이다. documentFragment는 실제 DOM 트리에 포함되는 요소가 아니므로 reflow나 repaint를 발생시키지 않는다.

The key difference is due to the fact that the document fragment isn't part of the active document tree structure. Changes made to the fragment don't affect the document (even on reflow) or incur any performance impact when changes are made.
const parentNode = document.getElementById("parent")
const cnt = 10;

const fragNode = document.createDocumentFragment();

for (let i=0;i<cnt;i++) {
  const newNode = document.createElement('li');
  newNode.innerText = `this is ${i}-content`;
  
  fragNode.appendChild(newNode);
}

parentNode.appendChild(fragNode);

2.2.5 시각적인 변화는 requestAnimationFrame API를 활용한다.

우리가 화면에 어떤 애니메이션을 추가하고자 할 때 setTimeout(setInterval)을 사용한다면 과연 정확한 시점에 의도했던 부드러운 애니메이션이 구현될까?

 

사용자에게 끊김 없는 자연스러운 애니메이션을 제공하기 위해서는 일반적으로 하나의 프레임이 16ms 내로 완료되어야 한다.

자바스크립트 실행 시간은 10ms 이내에 수행되어야 레이아웃, 페인트 등의 과정을 포함했을 때 16ms 이내에 프레임이 완료될 수 있다.

requestAnimationFrame은 자바스크립트의 프레임 시작과 동시에 호출되어 애니메이션이 프레임의 시작과 함께 실행되는 것을 보장해주며, 뿐만 아니라 setTimeout(setInterval)은 화면에 해당 요소가 보이든말든 상관없이 무조건 콜백함수를 실행하지만, requestAnimationFrame은 화면에 요소가 보이지 않을 시  콜백함수가 호출되지 않는다.

2.2.6 CSS에 복잡한 셀렉터 규칙 사용하지 않기

2.2.6부터, CSS와 HTML을 최적화하는 법을 소개한다.

 

먼저, 스타일의 셀렉터 규칙을 복잡하게 만들지 않는다. CSS가 복잡하고 많을수록 스타일 계산과 레이아웃 과정이 오래 걸리기 때문이다.

2.2.7 DOM 트리와 Style 트리를 복잡하게 구성하지 않기

마찬가지로 DOM 트리와 Style 트리를 복잡하게 하지 않아야 계산에 드는 비용을 줄일 수 있다. 불필요한 wrapper 엘리먼트 똘한 피하자.

2.2.8 애니메이션 요소는 position을 고정하기

애니메이션이 걸린 요소는 다른 요소에 영향을 미칠 수 있으므로 position:absolute; 혹은 position:fixed;로 고정한다.

2.2.9 레이아웃보다 리페인트를 발생시키는 속성을 활용하기

스타일 속성은 레이아웃을 발생시키는 속성과 리페인트를 발생시키는 속성으로 나눌 수 있다.

 

레이아웃과 리페인트를 발생시키는 CSS 내 속성을 알고 싶다면 여기이 곳을 참고한다.

 

가장 많이 사용하였던 대체로 top/left/right/bottom 혹은 width/height을 조작하는 대신에 transform 속성을 활용하면 엘리먼트 레이어만 분리하여 합성만 일어나게 된다. 따라서 성능이 더욱 향상될 것이다.

 

아래는 타이머 리스트를 정렬하는 rearrange 함수에서 transform을 사용하여 합성만 발생시켜 요소를 재배치한 코드다.

function () {
    this.array.sort(function (a, b) {
        return b.delay - a.delay;
    });

    for (const [i, timer] of this.array.entries()) {
        let node = document.getElementById(`${timer._id}`);
        node.style.transform = `translateY(${75 * i}px)`;
    }
}

2.2.10 정리

이제 위에서 배운 렌더링 최적화 방법들을 표로 정리해보자.

  JS CSS HTML
AVOID 강제 동기식 레이아웃과 
레이아웃 스레싱 지양
복잡한 셀렉터 규칙 지양 불필요한 wrapper 엘리먼트 제거
복잡한 스타일 트리 지양 복잡한 DOM 트리 지양
RECOMMEND display:none; 활용 애니메이션 요소는 position 고정  
domFragment 활용 리페인트 발생 속성 사용  
requestAnimationFrame 활용    
하위 DOM 요소를 조작    

3. 성능 측정 기준

지금까지 브라우저 동작 원리에 기반하여 각 리소스들을 로드 시점과 렌더링 시점에 어떻게 최적화할 수 있는지 정리하였다.

 

그러나 성능 개선을 하기 위해서는 가장 먼저 현재 프론트엔드의 성능을 측정해야 한다.

 

이러한 프론트엔드 성능을 측정하는 기준으로 과거에 쓰였던 지표와 부족한 점, 새로 등장한 성능 측정 지표를 알아본다.

3.1 브라우저 내부 이벤트 기준

프론트엔드 성능을 측정하는 기준으로 과거에는 브라우저 내부 이벤트가 발생하는 시점을 사용하였는데, 바로 DomContentLoadedload이벤트이다. 

  • DomContentLoaded: HTML과 CSS 파싱이 끝나고, 렌더링 트리를 그릴 준비가 완료된 시점에 발생(1.3이 끝난 시점)
  • load: 서버로부터 모든 리소스가 로드된 시점에 발생

별도의 네비게이션타이밍 API를 사용할 수도 있지만 크롬 개발자 도구를 통해서 확인할 수도 있다. Network 탭의 우측 하단에 푸른 글씨로 DomContentLoaded 이벤트와 붉은 글씨로 Load 이벤트 발생 시점이 표시된다.

그러나, 이러한 과거의 성능 측정 기준만 사용해서는 현재의 상황을 반영하는데 어려워졌다.

 

SPA(Single Page Application)의 등장과 함께 모듈 번들러를 통한 코드 스플리팅과 번들링을 통해 필요한 HTML과 CSS, JS를 로드하여 위의 두 이벤트 발생 시점은 빨라졌지만, 이후 사용자 인터렉션에 따른 다량의 리소스를 로드하게 되면서 여전히 느린 로딩이 존재했다.

 

이러한 이유로 인해 새로운 성능 측정 지표가 등장하였는데, 바로 사용자 관점에서 화면의 성능을 바라보는 것이다.

 

아래의 그림을 보면, DomContentLoaded, load 이벤트 발생 시점은 똑같지만, 화면에 컨텐츠가 더 빨리 나타난 위의 예시가 사용자 관점에서 더 빠르다고 인식될 수 있다.

출처: Google Developers

3.2 사용자 기준 성능 지표

사용자 기준의 성능 지표는 사용자에게 컨텐츠를 보여주는 시점을 기반으로 4가지로 나뉜다.

  • First Paint: 화면에 어떤 요소가 페인트된 시점
  • First Contentful Paint: 화면에 이미지나 텍스트가 나타난 시점
  • First Meaningful Paint: 화면에 사용자에게 의미있는 컨텐츠가 나타난 시점
  • Time To Interactive: 자바스크립트 초기 실행이 완료되고, 사용자가 인터렉션할 수 있는 시점

이 중 가장 중요한 지표는 First Meaningful Paint로, 화면에 의미있는 정보가 페인트되는 시점을 기준으로 성능을 측정했을 때, 로딩이 완료될 때까지 빈 화면이 아니라 어떤 정보를 보여줄 것인지를 미리 표시할 수 있어야 한다.

 

커머스 웹은 이미지와 텍스트 등 컨텐츠가 가장 많은 웹페이지 중 하나라고 생각하여, 대표적으로 카카오 쇼핑하우와 네이버 쇼핑의 메인 페이지를 비교해보았다. 네트워크 환경을 Slow 3G, Disable Cache로 설정하여 화면을 새로고침하였다.

카카오 쇼핑하우

  • DomContentLoaded: 11.36s
  • load: 1.0min

다음은 네이버 쇼핑 메인화면의 로드 과정이다.

네이버 쇼핑

  • DomContentLoaded: 20.27s
  • load: 37.39s

개인적으로 네이버 쇼핑이 FMP 기준에서 바라보았을 때 사용자에게 컨텐츠 프레임을 우선적으로 나타내줌으로써 어떤 정보를 표시할 것인가를 빠르게 제공했다고 생각된다.

 

글쓴이도 지금까지는 Header와 Footer를 먼저 그리고 Contents는 서버 응답을 받고 스토어를 업데이트한 후 렌더링하게끔 구현하였는데 네이버 쇼핑처럼 틀을 먼저 제공할 수 있는 설계를 연구해야 겠다.

(20200818 추가) 알아보니 그저 로딩 ux를 껍데기로 제공하는 거라고 카더라 ... + 프리 렌더링

3.3 사용자 기준 성능 최적화

이전의 성능 최적화는 브라우저 동작 원리에 영향을 미치는 리소스들에 관한 최적화였다면 사용자 기준에서 화면이 빠르게 로드되었다고 인지될 수 있는 방법을 알아본다.

3.3.1 스켈레톤 UX 활용

유튜브 웹을 띄우면, 맨 처음 동영상 리소스가 로드되기 전 동영상이 로드될 위치에 회색의 사각형 영역을 많이 보았을 것이다.

youtube.com

이렇게 실제 데이터가 로드되기 전에 영역을 표현할 스켈레톤 UX를 활용하면 체감 로드 속도를 향상시킬 수 있다.

 

React.js에서는 React.lazy를 통해 코드 스플리팅과 동시에 Suspense의 fallback props에 스켈레톤 Component를 설정하여 컴포넌트가 로드되기 전 스켈레톤 이미지를 띄울 수 있었다.

const BotItem = React.lazy((_) => import("./BotItem"));

const BotList = React.memo((props) => (
  props.data.map((bot) => (
    <Suspense key={bot._id} fallback={thumbnail}>
      <BotItem {...bot} />
    </Suspense>
  ))
)

3.3.2 이미지 preload

이미지 preload를 통해 중요한 컨텐츠를 우선적으로 요청할 수 있다. 이미지가 FMP 관점에서 우선적으로 보여야 할 자산이라면 preload를 활용하여 개선할 수 있을 것이다. 주의할 점은 이미지 요청 우선순위를 바꾸는 것이기 때문에 다른 리소스 요청이 밀리므로 해당 이미지를 반드시 우선적으로 로드해야 하는지를 확인해야 한다.

<link rel="preload" as="image" href="logo.jpg"/>
<link rel=preload>는 선언적 가져오기이며, 브라우저가 문서의 onload 이벤트를 차단하지 않고 리소스에 대한 요청을 생성하도록 강제할 수 있습니다.

4. 정리

지금까지 프론트엔드 성능을 향상시키기 위해 어떻게 최적화해야 하는지 브라우저 동작 원리에 영향을 미치는 리소스별로, 시점 별로 알아보고, 측정 지표에 따른 사용자 경험을 개선하는 방법또한 알아보았다.

 

이 글에서는 자세히 다루고 있진 않지만 웹 캐시를 잘 활용하는 것만으로도 상당히 빠른 페이지 로드를 경험한 적이 있어, 다음 번에는 캐싱에 대해 자세히 공부할 예정이다.

 

이것 외에도 브라우저 DOM 객체의 메소드 별 효율이라던가,로직 구현 시, 배열보다 객체를 사용함으로써 얻는 이득 등 구현하는 데 있어 주의해야 할 점들도 있다. 이 글에서 자세히 다루고 있으니 구현하면서 햇갈릴 때마다 점검할 것이다.

 

아직 실무 경험이 부족한 터라 이것말고도 또다른 기준과 관점에서 성능 최적화를 할 수 있는 방법들이 많을 것이다.

(얼핏 보았지만 ServiceWorker와 관련된 성능 최적화도 있었다.)

 

한정된 서버와 자원에서 프론트엔드 개발자가 할 수 있는 성능 최적화에 대해 공부하고 적용해보면서 이 글이 더욱 풍부해질 수 있도록 노력해야 겠다:)

 

리액트와 리덕스를 최적화하는 방법에 관한 글을 소개하는 것으로 글을 마무리한다.


20200818 추가

브라우저 동작 원리에서 필요한 추가 내용과 VSync 기반 프레임을 생성하는 과정에 대한 내용을 기록하였다. 바로가기