성능 최적화


애플리케이션 성능 최적화는 앱과 웹에서 모두 중요하다. 최근 웹 애플리케이션은 Ajax 통신, 복잡한 UI 등 많은 기능을 담으면서 크고 무거워졌다. 무거워진 웹은 긴 로딩 시간 함께 사용자 경험에 안 좋은 영향을 끼친다. Pinterest는 긴 로딩 시간으로 인해 사용자가 페이지를 나가는 비율이 높았는데, 최적화를 통해 사용자 이탈률을 줄이고 매출은 40%로 증가시켰다. 이처럼 매출 실적과 연결될 정도로 웹 애플리케이션의 성능 최적화는 매우 중요하다. 이 가이드는 성능 최적화에 필요한 사전 지식을 설명하고, 웹페이지의 로딩과 렌더링 과정으로 나누어 각 과정에서 진행할 수 있는 다양한 최적화 방법을 소개한다.

목차

성능 최적화에 필요한 이론과 측정 도구

성능 최적화를 위해서는 성능과 관련된 용어와 개념을 이해하고 있어야 한다. 이번 장에서는 브라우저의 로딩 과정을 다루고, 각 과정에서 사용되는 용어와 성능을 측정하는 데에 사용되는 지표를 설명한다. 또한 성능을 측정하는 도구로 크롬 개발자 도구를 살펴볼 것이다.

브라우저의 로딩 과정

브라우저는 웹 페이지에 필요한 리소스를 내려받고 해석한 다음 여러 계산 과정을 거쳐 콘텐츠를 화면에 보여준다. 이를 브라우저의 로딩 과정이라고 하며 다운로드, 파싱, 스타일, 레이아웃, 페인트, 합성으로 나뉜다. 단계마다 어떤 일이 발생하는지 알아보겠다.

1. 파싱

브라우저에서 웹 페이지를 로드하면 가장 먼저 HTML 파일을 다운로드한다. 파싱은 다운로드한 HTML을 해석하여 DOM 트리를 구성하는 단계이다. 파싱 중  <script /><link /><img />를 발견하면 각 리소스를 요청하고 다운로드한다. HTML 또는 리소스에 CSS가 포함된 경우에는 CSSOM 트리 구성 작업도 함께 진행한다. DOM 트리 및 CSSOM 트리가 구성되는 방법은 다음과 같다.

DOM 트리 구성

다음과 같은 HTML 파일이 있다고 가정한다. 파싱이 일어나면 HTML을 해석해 DOM을 생성한 후, 각 DOM 객체를 트리 데이터 구조로 연결해 부모-자식 관계를 갖도록 만든다. <body>, <p>, <div> 등 각 태그가 DOM 트리의 노드로 생성되고 자식 노드를 참조한다.

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
    <title>Critical Path</title>
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg"></div>
  </body>
</html>

01-dom-tree (이미지 참조 : 객체 모델 생성)

CSSOM 트리 구성

위 예제에서 style.css처럼 외부 스타일시트 파일이나 내부 스타일시트가 포함되어 있을 경우, CSS를 해석해 CSSOM 트리를 구성한다. body, p, span 등 선택자가 노드로 생성되고 각 노드는 스타일을 참조한다.

/* style.css */
body { font-size: 16px }
p { font-weight: bold }
span { color: red }
p span { display: none }
img { float: right }

02-cssom-tree (이미지 참조 : 객체 모델 생성)

2. 스타일

스타일 단계에서는 파싱 단계에서 생성된 DOM, CSSOM 트리를 가지고 스타일을 매칭시켜주는 과정을 거쳐 렌더 트리를 구성한다. 아래 이미지는 파싱 단계에서 설명한 DOM 트리와 CSSOM 트리를 조합해 렌더 트리가 구성되는 과정을 보여준다.

02-render-tree (이미지 참조 : 렌더링 트리 생성, 레이아웃 및 페인트)

3. 레이아웃

레이아웃 단계에서는 노드의 정확한 위치와 크기를 계산한다. 노드의 정확한 크기와 위치를 파악하기 위해 루트부터 노드를 순회하면서 계산하고, 레이아웃 결과로 각 노드의 정확한 위치와 크기를 픽셀값으로 렌더트리에 반영한다. 아래는 레이아웃 전/후 과정을 보여준다. 만약 CSS에서 크기 값을 %로 지정하였다면, 레이아웃 단계를 거친 후 % 값은 계산되고 측정 가능한 픽셀 단위로 변환된다.

레이아웃 전

04-layout-before (이미지 참조 : 렌더링 트리 생성, 레이아웃 및 페인트)

레이아웃 후

05-layout-after

4. 페인트

이전 레이아웃 단계에서 계산된 값을 이용해 렌더트리의 각 노드를 화면상의 실제 픽셀로 변환한다. 이때 위치와 관계없는 CSS 속성(색상, 투명도 등)을 적용한다. 그리고 픽셀로 변환된 결과는 포토샵의 레이어처럼 생성되어 개별 레이어로 관리된다. 단, 각각의 엘리먼트가 모두 레이어가 되는 것은 아니다. transform 속성 등을 사용하면 엘리먼트가 레이어화 되는데, 이 과정을 페인트라고 한다.

5. 합성 & 렌더

페인트 단계에서 생성된 레이어를 합성하여 스크린을 업데이트한다. 합성과 렌더 단계가 끝나면 화면에서 웹 페이지를 볼 수 있다.

다음 그림은 브라우저 로딩 과정을 나타낸다. 웹 애플리케이션에서 성능 개선점을 찾기 위해서는 브라우저 로딩 과정을 이해해야 한다.

06-loading (이미지 참조 : Quantum 엔진 설명)

레이아웃과 리페인트

브라우저 로딩 과정 중 스타일 이후의 과정(스타일 -> 레이아웃 -> 페인트 -> 합성)을 렌더링이라고 하는데, 이 렌더링 과정은 상황에 따라 반복하여 발생할 수 있다. 스타일 단계에서 구성되는 렌더 트리는 자바스크립트에 의해 DOM 트리, CSSOM 트리가 변경될 때 다시 재구성된다. DOM이 추가/삭제되거나 요소에 기하적인 영향(높이, 넓이, 위치)을 주는 CSS 속성값을 변경하는 경우, 렌더 트리가 다시 재구성된다. 즉, 레이아웃부터 이후 과정을 다시 수행하며 이것을 레이아웃이라고 한다(또는 리플로우라고도 한다).

07-layout (이미지 참조 : 렌더링 성능)

레이아웃은 요소에 기하적인 영향을 주는 CSS 속성값을 변경할 때 발생한다고 하였는데, 반대로 영향을 주지 않는 CSS 속성값을 변경하면 레이아웃 과정을 건너뛴다. 페인트부터 수행하며 이를 리페인트라고 한다.

08-repaint (이미지 참조 : 렌더링 성능)

레이아웃이 일어나면 전체 픽셀을 다시 계산해야 하므로 부하가 크다. 반면 리페인트는 이미 계산된 픽셀값을 이용해 화면을 그리기 때문에 레이아웃에 비해 부하가 적다. 다음은 어떤 엘리먼트에 레이아웃과 리페인트를 발생시키는 CSS 속성과 해당 속성값을 변경했을 때 차이를 보여준다.

<div id="sample" style="background:red;width:150px;height:50px;">
  Sample
</div>

요소에 기하적인 영향을 주는 CSS 속성값 변경

  • CSS 속성값 : heightwidthlefttopfont-sizeline-height 등
const example = document.getElementById('example');

example.style.width = '400px';
  • 레이아웃 발생

09-layout-chrome

요소에 기하적인 영향을 주지 않는 CSS 속성값 변경

  • CSS 속성값 : background-colorcolorvisibilitytext-decoration 등
const sample = document.getElementById('example');

example.style.backgroundColor = 'blue';
  • 리페인트 발생

10-repaint-chrome

참고 : 레이아웃과 리페인트를 발생시키는 CSS 속성 목록

위에서 확인했듯이 레이아웃이 발생하면 실행 시간만큼 렌더링 시간도 늘어나게 된다. 따라서 불필요한 레이아웃이 발생하지 않도록 신경 써야 한다.

블록 리소스와 주요 렌더링 경로

브라우저 로딩 초기 단계에서 HTML 파싱이 일어날 때 CSS 또는 자바스크립트로 인해 파싱이 중단될 수 있다. 이렇게 파싱이 중단되는 상황을 HTML 파싱이 블록되었다라고 표현하며, 블록 상태의 원인이 되는 리소스를 블록 리소스(Block resource) 라고 부른다. 블록 리소스는 브라우저 로딩 단계 중 페인트 과정을 지연시키므로, 블록 리소스가 HTML 파싱을 막는 상황이 발생하지 않도록 해야 한다. 구글에서는 주요 렌더링 경로(Critical Rendering Path)를 최적화하면 페인팅을 빠르게 하고 로딩 속도를 개선할 수 있다고 설명한다. 주요 렌더링 경로를 최적화하는 방법은 다음 장에서 자세히 살펴본다.

성능 개선 지표

지금까지 로딩 과정과 성능에 영향에 줄 수 있는 요소를 설명하였다. 이번에는 로딩 성능을 개선하는 방법을 알아본다. 개선점을 찾기 위해서는 어떤 기준으로 로딩 속도가 느린지를 판별할 수 있어야 한다. 성능 지표의 측정 기준은 크게 브라우저사용자 입장으로 나눌 수 있다. 기준별로 어떤 차이가 있는지 알아보고, 성능 측정에 사용되는 지표를 알아본다.

브라우저 기준의 성능 측정

전통적인 성능 측정 방식은 브라우저에서 발생하는 이벤트를 사용하는 것이었다. 웹 페이지가 로딩될 때 DOMContentLoaded, load 이벤트가 발생하며, 각 이벤트가 발생하는 시점으로 성능을 측정하게 된다. 아래 이미지는 내비게이션 타이밍(Navigation Timing) 모델로, 이 모델에서 DOMContentlLoaded 이벤트(파란색 표시), load 이벤트(빨간색 표시) 발생 시점이 빠를수록, 그리고 두 이벤트 발생 구간의 폭이 좁을수록 성능이 좋다고 말한다.

11-navigation-timing-model

로딩 과정에서 DOMContentLoaded, load 이벤트가 발생하는 구체적인 시점은 다음과 같다.

DOMContentLoaded 이벤트

  • HTML과 CSS 파싱이 끝나는 시점
  • 렌더 트리를 구성할 준비가 된(DOM 및 CSSOM 구성이 끝난) 상황

load 이벤트

  • HTML 상에 필요한 모든 리소스가 로드된 시점

그리고 이 두 이벤트가 발생하는 시점은 내비게이션 타이밍 API를 사용하거나 크롬 개발자 도구를 통해 확인할 수 있다.

내비게이션 타이밍 API 사용

브라우저에서는 웹 페이지 성능을 측정할 수 있도록 내비게이션 타이밍 API를 제공한다. 내비게이션 타이밍 API에서 PerformanceNavigationTimingdomContentLoadedEventStart 속성을 사용하면 DOMContentLoaded 이벤트, loadEventStart 속성을 사용하면 load 이벤트의 발생 시점을 알 수 있다.

크롬 개발자 도구 사용

크롬 개발자 도구는 브라우저에서 이벤트 발생 시점을 확인할 수 있도록 UI를 제공하고 다양한 측정 방법을 제공한다. 다음과 같이 개발자 도구의 하단에 DOMContentLoaded 이벤트(파란색 화살표), load 이벤트(빨간색 화살표) 발생 시점이 출력된다. 크롬 개발자 도구를 사용하면 내비게이션 타이밍 API를 사용할 때보다 두 이벤트 간 발생 시점을 쉽게 확인할 수 있다.

12-browser-events

그러나 개발 패러다임이 변화하면서 DOMContentLoaded, load 이벤트 발생 시점만 가지고 성능을 판단하기 어려워졌다. 최근에 많이 사용되는 SPA(Single Page Application)에서는 웹 페이지에 포함된 적은 양의 HTML로 DOMContentLoaded, load 이벤트가 일찍 발생할 수 있으나, 이벤트가 발생한 이후에도 수많은 스크립트 실행으로 인해 여전히 "느린 로딩"이 존재한다. 이러한 이유로 사용자 기준의 새로운 성능 측정 방식이 필요하게 되었다.

사용자 기준의 성능 측정

사용자 기준의 성능 측정은 사용자에게 콘텐츠를 보여주는 여러 시점을 기반으로 한다. 의미 있는 콘텐츠가 처음 보이는 시점이 빠를수록 성능이 좋다고 판단하며, 이 시점을 앞당길 수 있도록 최적화해야 한다. 아래 이미지는 DOMContentLoaded, load 이벤트가 같은 시점에 발생했으나 사용자 기준에서 성능 최적화가 잘 된 페이지와 그렇지 않은 페이지를 비교한 예다. 최적화가 잘 된 경우(위) 0.3초에 콘텐츠 일부분이 보이고 이후 점진적으로 모든 콘텐츠가 화면에 출력된다. 반면 최적화가 안 된 경우(아래) 흰 화면만 보이다가 1.5초 후에 모든 콘텐츠가 보인다. 사용자는 최적화가 잘 된 페이지를 볼 때 로딩이 더 "빠르다"고 느낀다. 13-progressive-rendering

(이미지 참조 : 주요 렌더링 경로)

구글에서는 이렇게 사용자가 웹 페이지 로딩이 빠르다, 느리다를 느낄 수 있는 여러 순간을 정의하고 성능을 측정하는 지표로 사용한다. 각 시점에 대한 설명은 다음과 같다.

FP(First Paint)

흰 화면에서 화면에 무언가가 처음으로 그려지기 시작하는 순간이다.

FCP(First Contentful Paint)

텍스트나 이미지가 출력되기 시작하는 순간이다.

FMP(First Meaningful Paint)

사용자에게 의미 있는 콘텐츠가 그려지기 시작하는 첫 순간이다. 콘텐츠를 노출하는데 필요한 CSS, 자바스크립트 로드가 시작되고 스타일이 적용되어 주요 콘텐츠를 읽을 수 있다.

TTI(Time to Interactive)

자바스크립트의 초기 실행이 완료되어서 사용자가 직접 행동을 취할 수 있는 순간이다.

14-fmp

이 중 가장 중요한 시점은 FMP인데, 로딩이 끝날 때까지 흰 화면 대신 의미 있는 먼저 보여주어 사용자에게 긍정적인 인상을 줄 수 있다. 사용자 기준에서 성능을 좋게 하기 위해서 FMP를 앞당겨야 하고, 위에서 설명한 주요 렌더링 경로를 최적화하여 해결할 수 있다.

성능 측정 도구

크롬 브라우저에서는 개발자 도구를 제공하며, 이 개발자 도구를 사용해 지금까지 설명한 내용을 눈으로 확인해볼 수 있다. 크롬 개발자 도구에서 성능과 관련된 패널로 Network, Performance, Audits가 있다. 이 장에서는 각 패널에서 하는 역할과 사용 방법을 소개하고, 실제 사용 방법은 최적화 단계에서 다룬다. (크롬 70 버전을 기준으로 설명한다)

Performance 패널

Performance 패널에서는 앞서 살펴본 웹 페이지 로딩 단계를 차트 형태로 살펴볼 수 있다. 웹 페이지가 로드되는 과정을 레코딩하고 단계마다 걸리는 시간을 확인할 수 있다. 로딩 과정에서 최적화가 필요한 부분을 찾을 때 사용한다.

영역 정보

  • (1) Controls : 레코딩을 시작하고 중단하는 영역

    • 레코딩 시작, 중단 버튼(첫 번째 버튼) : 수동으로 레코딩을 제어한다.
    • 페이지 로드 레코딩(두 번째 버튼) : 레코드를 시작하면서 페이지를 리로드하고 완료되면 자동 중단한다.
  • (2) Capture

    • Screenshots : 시간의 흐름에 따른 렌더링 상태를 확인할 수 있다.
    • Memory : Heap Memory 상태를 확인할 수 있다.
    • GC(가비지 컬렉션) 버튼 : 강제로 GC를 수행할 수 있다.
  • (3) Overview : 전체적인 흐름을 보여주는 영역
  • (4) Main : Overview에서 선택한 구간의 상세 내용을 보여주는 영역
  • (5) Details : Main에서 선택한 특정 항목의 상세 내용를 보여주는 영역

15-devtool-performance

웹 페이지 로딩 과정 살펴보기

웹 페이지가 로드될 때 Controls를 사용해 레코딩한 다음 Main 영역에서 다음과 같이 로딩 과정을 확인할 수 있다.

16-devtool-network-graph

Network 패널

Network 패널에서는 웹 페이지가 로딩되는 동안 요청된 리소스의 상태를 차트 형태로 확인할 수 있으며, 리소스 최적화 상태를 비교할 때 사용한다. Performance 패널과 같이 레코딩하고, 레코딩이 끝나면 Overview와 Request Table에 리소스 요청 정보가 나타난다. 이때 리소스 목록은 시간순으로 정렬된다. 그리고 차트 부분을 선택하면 각 리소스의 서버 요청 대기 시간을 자세히 볼 수 있다. 또한 패널 하단에 DOMContentLoaded, load 이벤트 발생 시간이 출력된다.

영역 정보

  • (1) Controls : 네트워크 패널의 모양과 기능을 제어하는 영역
  • (2) Filters : 보여줄 리소스를 선택하는 영역
  • (3) Overview : 전체적인 요청과 다운로드 흐름을 보여주는 영역
  • (4) Request Table : 검색된 모든 리소스의 요청과 다운로드 상황을 보여주는 영역
  • (5) Summary : 총 요청 수, 전송된 데이터 양, 이벤트 로드 시간을 보여주는 영역

17-devtool-network

리소스의 서버 요청 대기 시간 보기

  • (1) Queuing : 대기열에 쌓아두는 시간

    • 자바스크립트, CSS보다 우선순위가 낮아서 대기한다.
    • TCP 소켓 대기
    • TCP 연결 초과 대기
    • 디스크 캐시 항목 작성 소요 시간
  • (2) Stalled : 요청을 보내기 전의 대기 시간

    • Queuing에서 쌓인 대기 시간 소모
    • 프락시 협상에 드는 시간
  • (3) DNS Lookup : DNS 조회에 소비된 시간
  • (4) Initial connection : TCP 핸드셰이크/재시도 및 SSL을 포함한 연결을 설정하는 데 걸린 시간
  • (5) Waiting(TTFB) : 초기 응답(Time To First Byte)을 기다리는 데 소비한 시간 (서버 왕복 시간)
  • (6) Content Download : 리소스 실제 다운로드 시간

18-devtool-network-request

Audits 패널

Audits 패널에서는 사용자 기준의 성능 측정 지표를 확인할 수 있다. 화면은 크게 성능 측정 전/후로 나뉘는데, 측정 전 화면에서는 어떤 환경에서 성능을 측정할지 선택할 수 있다. 느린 네트워크 환경의 시뮬레이션이 필요하다면 Throttling 영역을 설정한다. Run audits 버튼을 클릭하면 성능 측정이 시작되며 측정 후 결과 화면을 보여준다.

성능 측정 전 화면

19-audits

성능 측정 후 화면

  • (1) Metrics : 다양한 성능 측정 지표를 보여주는 영역

    • FCP, FMP, TTI 시점을 확인할 수 있다.
    • 웹 페이지가 화면에 그려지는 단계를 스크린샷으로 보여준다.
  • (2) Opportunities : 최적화 가능한 리소스 목록을 보여주는 영역
  • (3) Diagnostics : 리소스 최적화 외에 성능을 개선할 수 있는 부분을 진단하고 해결 방안을 목록으로 보여주는 영역

    • 주요 렌더링 경로를 최적화할 때 "Critical Request Chains" 영역을 참조한다.
  • (4) Passed audits : 성능 측정 기준과 통과 목록을 보여주는 영역

20-audits-result

웹 페이지 로딩 최적화

지금부터는 앞서 살펴본 내용을 바탕으로 로딩 과정을 최적화해본다. 주요 렌더링 경로를 기반으로 최적화를 진행해볼 것이다.

블록 리소스(CSS, 자바스크립트) 최적화

브라우저 로딩 과정에서 파싱 중 블록 리소스가 발생할 수 있으며, CSS와 자바스크립트가 블록 리소스에 해당한다고 했다. 최적화의 첫 번째 단계는 이 블록 리소스를 최적화하는 것이다.

CSS 최적화

렌더 트리를 구성하기 위해서는 DOM 트리와 CSSOM 트리가 필요하다. DOM 트리는 파싱 중에 태그를 발견할 때마다 순차적으로 구성할 수 있지만, CSSOM 트리는 CSS를 모두 해석해야 구성할 수 있다. 즉, CSSOM 트리가 구성되지 않으면 렌더 트리를 만들지 못하고 렌더링이 차단된다. 이러한 이유로 CSS는 렌더링 차단 리소스라고 하며, 렌더링이 차단되지 않도록 CSS는 항상 HTML 문서 최상단(<head> 아래)에 배치한다.

<head>
  <link href="style.css" rel="stylesheet" />
</head>

그리고 특정 조건에서만 필요한 CSS가 있을 때 미디어 쿼리를 사용하면 불필요한 블로킹을 방지할 수 있다. 예를 들어 다음과 같이 페이지를 인쇄하거나(print.css) 화면이 세로 모드일 때(portrait.css) 사용하는 CSS가 있을 때, 해당 스타일을 사용하는 경우에만 로드할 수 있도록 <script> 태그에 media 속성을 명시하여 사용한다.

미디어 쿼리를 사용하지 않는 경우 (최적화 전)

<link href="style.css" rel="stylesheet" />
<link href="print.css" rel="stylesheet" />
<link href="portrait.css" rel="stylesheet" />

미디어 쿼리를 사용하는 경우 (최적화 후)

<link href="style.css" rel="stylesheet" />
<link href="print.css" rel="stylesheet" media="print" />
<link href="portrait.css" rel="stylesheet" media="orientation:portrait" />

또한 외부 스타일시트를 가져올 때 사용하는 @import 사용은 피한다. 아래와 같이 @import를 사용했을 때 브라우저는 스타일시트를 병렬로 다운로드 할 수 없기 때문에 로드 시간이 늘어날 수 있다.

/* foo.css */
@import url("bar.css")

그리고 때에 따라 내부 스타일시트를 사용한다.

<head>
  <style type="text/css">
    .wrapper {
      background-color: red;   
    }
  </style>
</head>

자바스크립트 최적화

자바스크립트는 DOM 트리와 CSSOM 트리를 동적으로 변경할 수 있기 때문에 HTML 파싱을 차단하는 블록 리소스이다. <script> 태그를 만나면 스크립트가 실행되며 그 이전까지 생성된 DOM에만 접근할 수 있다. 그리고 스크립트 실행이 완료될 때까지 DOM 트리 생성이 중단된다. 외부에서 가져오는 자바스크립트의 경우에는 모든 스크립트가 다운로드되고 실행될 때까지 DOM 트리 생성이 중단된다. 이러한 이유로 자바스크립트도 렌더링 차단 리소스라고 하며, HTML 문서 최하단(</body> 직전)에 배치한다.

<body>
  <div>...</div>
  <div>...</div>
  <script src="app.js" type="text/javascript"></script>
</body>

만약 <head> 아래에 포함되어 있거나 HTML 내부에 <script> 태그가 포함되어 있을 때도 HTML 파싱을 멈추지 않게 할 수 있다. <script> 태그에 deferasync 속성을 명시하면 스크립트가 DOM 트리와 CSSOM 트리를 변경하지 않겠다는 의미이기 때문에 브라우저가 파싱을 멈추지 않는다. 단, 이 속성들은 브라우저 지원 범위가 한정적이므로 사용에 유의한다.

<html>
  <head>
    <script async src="https://google.com/analatics.js" type="text/javascript"></script>
  </head>
  <body>
    <div>...</div>
    <div>...</div>
  </body>
</html>

크롬 개발자 도구로 블록 리소스 최적화 전/후 비교

크롬 개발자 도구의 Performance 패널에서 블록 리소스를 최적화하기 전과 후를 비교해본다. 먼저 블록 리소스를 최적화하기 전 페이지의 로딩 과정을 살펴보면, HTML을 파싱하던 중에 블록 리소스가 실행되고 실행이 끝나면 다시 HTML을 파싱한다. 아래 이미지에서 최적화 전에는 파싱이 중단되어 'Parse HTML'이 2번 발생했지만, 최적화 후에는 CSS나 자바스크립트에 의해 'Parse HTML'이 중단되지 않았다.

최적화 전

  • <head />가 아닌 곳에서 CSS 파일 로드
  • <head />에서 자바스크립트 파일 로드

21-block-resource

최적화 후

  • <head />에서 CSS 파일 로드
  • </body> 직전에 자바스크립트 파일 로드
  • 내부 스타일시트 사용

22-block-resource-optimization

리소스 요청 수 줄이기

CSS, 자바스크립트, 이미지 등 웹 페이지에 포함된 리소스는 서버 요청 후 다운로드되어야 사용할 수 있다. 다음 이미지는 개발자 도구 네트워크 패널에서 1개 리소스 파일을 요청했을 때 걸리는 시간을 확인한 것이다. 이 파일의 실제 다운로드 시간은 1.03ms, 그 외 대기 시간(전체 소요 시간 - 실제 다운로드 시간)은 127.45ms가 소요된다. 이렇게 리소스 파일 하나를 요청하는 데 많은 시간이 소요되므로, 필요한 요청만 할 수 있도록 최적화해야 한다. 리소스 종류별로 다른 요청 수를 줄이는 방법을 설명한다.

23-request-time

이미지 스프라이트

다음과 같은 웹 페이지에서 아이콘마다 다른 이미지 파일을 사용할 경우 리소스 요청이 7번 이상 발생한다. 이런 경우 이미지 스프라이트 기법을 사용하여 요청을 1번으로 줄일 수 있다.

24-icons-example

이미지 스프라이트는 여러 개 이미지를 하나로 만들고, CSS의 background-position 속성을 사용해 부분 이미지를 사용하는 방법이다. 아래 CSS에서 사용된 icons-sprite.png가 스프라이트 이미지다. 이 이미지 스프라이트 기법을 사용하면 웹 페이지를 보다 빨리 보여줄 수 있다.

<button class="btn">확인</button>
.btn {
  background-image: url(../images/icon-sprite.png);
  background-position: 10px 10px;
  width: 20px;
  height: 20px;
}

다음은 크롬 개발자 도구의 Network 패널에서 여러 개의 이미지 파일과 이미지 스프라이트를 사용하였을 때를 비교한 것이다. 이미지 스프라이트를 사용했을 때 리소스 요청 수가 줄어들어 로딩 시간이 빨라졌다.

여러 개의 이미지 파일을 사용한 경우 (최적화 전)

25-image-files

이미지 스프라이트를 사용한 경우 (최적화 후)

26-image-sprites

CSS, 자바스크립트 번들하기

모듈 기반의 개발 방식이 등장하기 이전까지 분리된 여러 개의 리소스 파일을 가져와 사용했었다. 아래 최적화 하기 전 예제를 살펴보면, 5번 이상의 리소스 요청(CSS 파일 2번, 자바스크립트 파일 3번)이 발생한다. 이 경우에는 webpack과 같은 번들러를 사용하여 CSS, 자바스크립트 파일 요청을 줄일 수 있다. 번들러는 여러 개의 모듈 파일을 하나로 묶어서 1개 파일로 생성해주는데 이것을 번들 파일이라고 한다. 이 번들 파일을 사용하여 리소스 요청을 줄일 수 있다. (모듈 설명과 번들 파일 생성 방법은 [FE 가이드] 번들러를 참조한다)

분리된 리소스를 사용하는 경우 (최적화 전)

<html>
  <head>
    <link href="foo.css" rel="stylesheet" />
    <link href="bar_baz.css" rel="stylesheet" />
  </head>
  <body>
    <div id="foo">...</div>
    <script async src="foo.js" type="text/javascript"></script>
    <script async src="bar.js" type="text/javascript"></script>
    <script async src="baz.js" type="text/javascript"></script>
  </body>
</html>

번들된 리소스를 사용하는 경우 (최적화 후)

<html>
  <head>
    <link href="bundle.css" rel="stylesheet" />
  </head>
  <body>
    <div class="foo">...</div>
    <script async src="bundle.js" type="text/javascript"></script>
  </body>
</html>

내부 스타일시트 사용하기

<link> 태그로 외부 스타일시트를 가져오는 대신, 문서 안에서 <style> 태그를 사용할 수 있다. 이러한 사용 방법을 내부 스타일시트라고 하며, 외부 스타일시트를 가져올 때 발생하는 요청 횟수를 줄일 수 있다. 단, 내부 스타일시트를 사용하면 리소스 캐시를 사용할 수 없어서 HTML에 CSS가 매번 포함되므로 필요한 경우에만 사용한다.

외부 스타일시트를 사용하는 경우 (최적화 전)

<html>
  <head>
    <link href="bundle.css" rel="stylesheet" />
  </head>
  <body>
    <div class="foo">...</div>
  </body>
</html>

내부 스타일시트를 사용하는 경우 (최적화 후)

<html>
  <head>
    <style type="text/css">
      .foo {
        background-color: red;
      }
    </style>
  </head>
  <body>
    <div class="foo">...</div>
  </body>
</html>

작은 이미지를 HTML, CSS로 대체

웹 페이지에서 사용하는 아이콘 이미지 개수가 적은 경우, 다운로드한 이미지를 사용하는 대신 이미지를 HTML, CSS에 포함해 사용할 수 있다. Data URI로 처리할 수 있으며, 다음과 같이 HTML, CSS에서 외부 경로로 이미지를 가져오던 부분을 Base64로 변환된 URI로 대체한다. 이렇게 하면 외부 이미지를 사용하기 위해 발생하는 요청 횟수를 줄일 수 있다. 이 경우도 내부 스타일시트를 사용했을 때와 같이 캐시 문제가 있으므로 필요한 경우에만 사용한다.

외부 이미지 사용 (최적화 전)

.btn{background: url('../img/arrow_top.png') no-repeat 0 0;}
<img src="../img/arrow_top.png" />

이미지를 Base64로 변환하여 사용 (최적화 후)

.btn{background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAOCAYAAAAbvf3sAAAAAXNSR0IArs4c6QAAAHBJREFUKBVjYBimICwsLAaEsXmPGV0QqnAeUNxfW1v7/tWrVy8hq0HRgKQ4CahoIxDPQ9cE14CseNWqVUtAJoMUo2tiBFkXGRmp9/fv3zNAZhJIMUgMBmAGMTMzmyxfvhzhPJAmmCJ0Gp8cutqhwAcASWgwk+79LiQAAAAASUVORK5CYII=') no-repeat 0 0;}
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAOCAYAAAAbvf3sAAAAAXNSR0IArs4c6QAAAHBJREFUKBVjYBimICwsLAaEsXmPGV0QqnAeUNxfW1v7/tWrVy8hq0HRgKQ4CahoIxDPQ9cE14CseNWqVUtAJoMUo2tiBFkXGRmp9/fv3zNAZhJIMUgMBmAGMTMzmyxfvhzhPJAmmCJ0Gp8cutqhwAcASWgwk+79LiQAAAAASUVORK5CYII=" />

리소스 용량 줄이기

용량이 큰 리소스도 웹 페이지 로딩 시간을 느리게 하는 원인이 된다. 각 리소스에 맞게 불필요한 데이터를 제거하고 압축하여 사용하는 것이 좋다. 용량을 줄이기 위한 최적화 방법을 알아보겠다.

중복 코드 제거하기

자바스크립트 코드 중 자주 사용되는 코드는 utils.js 파일로 정리해 사용한다. 중복 코드로 인해 용량이 늘어나는 문제를 막을 수 있다.

중복 코드 사용 (최적화 전)

// foo.js
function filter() { ... }
function map() { ... }

filter();
map();
// bar.js
function filter() { ... }
function find() { ... }

filter();
find();

중복 코드 제거 (최적화 후)

// utils.js
export function find() { ... }
export function filter() { ... }
export function map() { ... }
// foo.js
import {filter, map} from 'utils.js'

filter();
map();
// bar.js
import {filter, find} from 'utils.js'

filter();
find();

만능 유틸 사용 주의하기

lodash와 같은 만능 유틸 라이브러리를 사용할 때 주의한다. 일반적인 방식으로 가져와 사용하면 유틸 함수 전체가 포함되므로 자바스크립트 파일 용량이 커진다. 이 경우에 필요한 함수만 부분적으로 가져올 수 있으며 용량이 늘어나는 문제를 해결해준다. 그리고 되도록 사용하지 않는 기능이 많이 포함된 라이브러리 사용은 지양한다.

모든 유틸 함수 가져오기 (최적화 전)

import _ from 'lodash';

_.array(...);
_.object(...);

필요한 함수만 가져오기 (최적화 후)

import array from 'lodash/array';
import object from 'lodash/fp/object';

array(...);
object(...);

HTML 마크업 최적화

HTML은 태그의 중첩을 최소화하여 단순하게 구성한다. 또한 공백, 주석 등을 제거하여 사용한다. 권장하는 DOM 트리의 노드 수는 전체 1500개 미만, 최대 깊이는 32개, 자식 노드를 가지는 부모 노드는 60개 미만이다. (참조: Excessive DOM) 불필요한 마크업 사용으로 인해 DOM 트리가 커지는 것을 막고, HTML 파일 용량이 늘어나지 않도록 해야 한다.

불필요한 태그, 공백, 주석 사용 (최적화 전)

<html>
  <head>
    ...
  </head>
  
  <body>
  
    <div>
    
            <div>
                <!-- blar blar blar -->
                <p>hello</p>
            </div>
        
    </div>
  </body>
</html>

간결한 마크업 사용 (최적화 후)

<html>
  <head>
    ...
  </head>
  <body>
    <p>hello</p>
  </body>
</html>

간결한 CSS 선택자 사용

스타일을 적용할 때 간결한 CSS 선택자를 사용해 최적화한다. ID 대신 클래스 선택자를 사용하면 중복되는 스타일을 묶어서 처리할 수 있다. 선택자는 최소화여 사용한다.

불필요한 셀렉터 사용 (최적화 전)

<html>
  <head>
    <style type="text/css">
      #wrapper {
        border: 1px solid blue; 
      }
      
      #wrapper #foo {
        color: red;
        font-size: 15px;
      }
      
      #wrapper #bar {
        color: red;
        font-size: 15px;
        font-weight: bold;
      }
      
      #wrapper #bar > span {
         color: blue;
         font-weight: normal;
      }
    </style>
  </head>
  <body>
    <div id="wrapper">
      <span id="foo">hello</span>
      <span id="bar">
        javascript <span>world</span>
      </span>
    </div>
  </body>
</html>

간결한 셀렉터 사용 (최적화 후)

<html>
  <head>
    <style type="text/css">
      .wrapper {
        border: 1px solid blue; 
      }
      
      .text {
        color: red;
        font-size: 15px;
      }
      
      .strong {
        font-weight: bold;
      }
      
      .wrapper .text {
        color: blue;
        font-weight: normal;
      }
    </style>
  </head>
  <body>
    <div class="wrapper">
      <span class="text">hello</span>
      <span class="text strong">
        javascript <span class="text">world</span>
      </span>
    </div>
  </body>
</html>

압축(Minify)하여 사용하기

HTML, 자바스크립트, CSS 모두 압축해서 사용할 수 있으며, 불필요한 주석이나 공백 등을 제거한 다음 난독화하여 사용한다. webpack 플러그인과 같은 도구로 처리할 수 있다.

웹 페이지 렌더링 최적화

웹 페이지를 렌더링하기 위해서는 DOM과 CSS가 필요하다. 그러나 다양한 기능과 효과를 구현하기 위해서 자바스크립트를 많이 사용하기 때문에, 자바스크립트가 렌더링 성능에 어떤 영향을 주는지 잘 알아야 한다. 또한 자바스크립트는 브라우저에서 단일 스레드로 동작하기 때문에 자바스크립트의 실행 시간은 곧 렌더링 성능과 직결된다. 렌더링은 자바스크립트의 실행 시간과 자바스크립트로 인한 DOM, CSS 변경을 다시 화면에 그리는 시간을 모두 포함한다. 렌더링 성능 최적화는 이러한 소요 시간을 단축하고 화면에 끊김 없이 그리는 것이다. 이번에는 브라우저 렌더링 과정에서 어떤 부분이 성능에 영향을 주고, 특히 자바스크립트에서 실행되는 일련의 코드가 렌더링 성능에 어떠한 영향과 최적화할 방법을 알아본다.

레이아웃 최적화

렌더링 과정에서 레이아웃은 DOM 요소들이 화면에 어느 위치에 어떤 크기로 배치될지를 결정하게 되는 계산 과정이다. 자바스크립트 코드를 통해 DOM을 변경하거나 스타일을 변경할 경우, 아래 그림같이 변경된 스타일을 반영하고 다시 레이아웃을 해야만 화면에 렌더링할 수 있다. 특히 레이아웃은 글자의 크기를 일일이 계산하고 요소 간 관계를 모두 파악해야 하는 과정이므로 시간이 오래 걸린다.

07-layout (1) (이미지 참조 : 렌더링 성능)

레이아웃 최적화의 목표는 자바스크립트 실행 과정과 렌더링이 다시 일어나는 과정에서 레이아웃에 걸리는 시간을 단축하고 레이아웃이 최대한 발생하지 않도록 하는 것이다. 레이아웃을 최대한 적게하고 리페인트만 할 수 있도록, 자바스크립트와 HTML, CSS 측면에서 최적화 방법을 하나씩 알아본다.

자바스크립트 실행 최적화

자바스크립트 실행 시간이 긴 경우, 한 프레임 처리가 오래 걸려 렌더링 성능이 떨어진다. 많은 작업을 수행할 때 자바스크립트 실행 시간은 당연히 오래 걸린다. 그러나 코드가 단순하더라도 불필요한 레이아웃으로 인해 실행 시간이 오래 걸릴 수 있으므로 성능 저하의 원인을 잘 파악해야 한다. 또한 레이아웃을 줄일 수 있도록 DOM 및 스타일 변경을 최소화해야 한다.

강제 동기 레이아웃 최적화

DOM의 속성을 변경하면 화면 업데이트를 위해 레이아웃이 일어날 수 있다. 원래 레이아웃은 비동기이나 특정 상황에서 동기적으로 레이아웃이 발생할 수 있다. 특정 속성을 읽을 때 최신 값을 계산하기 위해 레이아웃이 동기적으로 발생하며 이를 강제 동기 레이아웃이라고 한다. 강제 동기 레이아웃은 자바스크립트 실행 시간을 늘어나게 하므로 신경 써야 한다. 강제 동기 레이아웃이 일어나는 경우와 개선 방법은 다음과 같다.

강제 동기 레이아웃 피하기

스타일을 변경한 다음 offsetHeightoffsetTop과 같은 계산된 값을 속성으로 읽을 때 강제로 동기 레이아웃을 수행해야 한다.

const tabBtn = document.getElementById('tab_btn');

tabBtn.style.fontSize = '24px';
console.log(testBlock.offsetTop); // offsetTop 호출 직전 브라우저 내부에서는 동기 레이아웃이 발생한다.
tabBtn.style.margin = '10px';
// 레이아웃

계산된 값을 반환하기 전에 변경된 스타일이 계산 결과에 적용되어 있지 않으면 변경 이전 값을 반환하기 때문에 브라우저는 동기로 레이아웃을 해야만 한다. 최신 브라우저에도 동일하게 발생하는 부분이므로 강제 동기 레이아웃을 발생할 수 있는 코드를 최대한 사용하지 않도록 주의해야 한다.

레이아웃 스래싱(thrashing) 피하기

한 프레임 내에서 강제 동기 레이아웃이 연속적으로 발생하면 성능이 더욱 저하된다. 다음 코드에서는 paragraphs[i] 요소를 순회하면서 각 요소의 너비를 box 요소의 너비와 일치하도록 설정한다. 반복문 안에서 style.width를 설정하고 box.offsetWidth를 읽어오면 for문이 반복 실행될 때마다 레이아웃이 발생한다. 이것을 레이아웃 스래싱이라고 한다. 반복문 밖에서 box 엘리먼트의 너비를 읽어오면 레이아웃 스래싱을 막을 수 있다.

function resizeAllParagraphs() {
  const box = document.getElementById('box');
  const paragraphs = document.querySelectorAll('.paragraph');

  for (let i = 0; i < paragraphs.length; i += 1) {
    paragraphs[i].style.width = box.offsetWidth + 'px';
  }
}
// 레이아웃 스래싱을 개선한 코드
function resizeAllParagraphs() {
  const box = document.getElementById('box');
  const paragraphs = document.querySelectorAll('.paragraph');
  const width = box.offsetWidth;

  for (let i = 0; i < paragraphs.length; i += 1) {
    paragraphs[i].style.width = width + 'px';
  }
}

참고 : 레이아웃에 영향을 미치는 요소

가능한 한 하위 노드의 DOM을 조작하고 스타일을 변경

DOM을 변경하면 스타일 계산, 레이아웃, 페인트 과정이 모두 필요하며, 조작이나 스타일 변경을 하는 DOM이 상위에 있을수록 한 프레임에 드는 시간이 길어진다.

체크 항목

  • DOM 트리 상위 노드의 스타일을 변경하면 하위 노드에 모두 영향을 미친다.
  • 변경 범위를 최소화할수록 레이아웃 범위가 줄어든다.
영향받는 엘리먼트 제한

DOM과 스타일을 변경하면 레이아웃 과정에서 주변의 엘리먼트도 영향을 받아 다시 레이아웃을 해야 하는 경우가 있다.

체크 항목

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

엘리먼트가 display: none 스타일을 가지고 있으면 DOM 조작과 스타일을 변경하더라도 레이아웃과 리페인트가 발생하지 않는다. 많은 수의 엘리먼트를 변경해야 할 경우 숨겨진 상태에서 엘리먼트를 변경하고 다시 보이도록 하여 레이아웃 발생을 최대한 줄인다. visibility: hidden은 보이지 않아 리페인트는 발생하지 않지만, 공간을 차지하기 때문에 레이아웃은 발생하게 된다.

체크 항목

  • display: none으로 숨겨진 엘리먼트를 변경할 경우에는 레이아웃, 리페인트가 발생하지 않아 성능에 유리하다.

HTML, CSS 최적화

화면을 렌더링하기 위해서 필요한 데이터는 HTML과 CSS로, 각각 DOM트리와 CSSOM 트리를 만들고 렌더링할 때 사용된다. DOM트리와 CSSOM 트리를 변경하면 렌더링을 유발하고 트리가 클수록 더 많은 계산이 필요하다. 그러므로 HTML과 CSS를 최적화하여 렌더링 성능을 향상할수 있다.

CSS 규칙수 최소화

엘리먼트의 클래스를 변경하면 렌더링이 발생하는데, CSS가 복잡하고 많을수록 스타일 계산과 레이아웃이 오래 걸린다.

체크 항목

  • 사용하는 규칙이 적을수록 계산이 빠르므로 최소화한다.
  • 복잡한 선택자는 스타일 계산에 많은 시간이 걸리므로 사용을 피한다.
DOM 깊이 최소화

DOM 트리가 깊을수록, 하나의 노드에 자식 노드가 많을수록 DOM 트리는 커진다. 그만큼 DOM을 변경했을 때 업데이트에 필요한 계산은 많아진다.

체크 항목

  • DOM이 작고 깊이가 얕을수록 계산이 빠르다.
  • 불필요한 래퍼 엘리먼트는 제거한다.

애니메이션 최적화

한 프레임 처리가 16ms(60fps) 내로 완료되어야 렌더링 시 끊기는 현상 없이 자연스러운 렌더링을 만들어낼 수 있다. 자바스크립트 실행 시간은 10ms 이내에 수행되어야 레이아웃, 페인트 등의 과정을 포함했을 때 16ms 이내에 프레임이 완료될 수 있다. 애니메이션을 구현할 때 네이티브 자바스크립트 API를 사용하는 것보다 CSS 사용을 권장한다.

requestAnimationFrame() 사용

requestAnimationFrame API를 사용하면 브라우저의 프레임 속도(보통 60fps)에 맞추어 애니메이션을 실행할 수 있도록 해준다. 특히 setInterval, setTimeout과 달리 프레임을 시작할 때 호출되기 때문에 일정한 간격으로 애니메이션을 수행할 수 있는 장점이 있다. 또한 현재 페이지가 보이지 않을 때는 콜백함수가 호출되지 않기 때문에 불필요한 동작을 하지 않는다.

function animate() {
  // 애니메이션 처리 프레임 코드
  requestAnimationFrame(animate);
}

requestAnimationFrame(animate);
CSS 애니메이션 사용

자바스크립트를 사용한 애니메이션은 성능이 나쁠 수 있다. CSS3 애니메이션을 사용하면 자바스크립트를 실행할 필요가 없고, 브라우저가 애니메이션을 처리하는데 최적화되어 있어서 부드러운 애니메이션을 구현할 수 있다. CSS 애니메이션을 구현할 때는 다음 사항을 지켜서 사용한다.

position: absolute 처리

애니메이션 영역이 주변 영역에 영향을 주지 않도록 주의해야 한다. position을 absolutefixed로 설정하면 주변 레이아웃에 영향을 주지 않는다.

transform 사용

스타일 속성 중 positionwidthheight 등과 같이 기하적 변화를 유발하는 속성을 변경하면 레이아웃이 발생한다. transform을 사용한 엘리먼트는 레이어로 분리되기 때문에 영향받는 엘리먼트가 제한되어 레이아웃과 페인트를 줄일 수 있다. 그리고 합성만 발생시키기 때문에 애니메이션에서 사용 시 렌더링 속도가 향상할 수 있다. 때에 따라 하드웨어가 지원될 경우 GPU를 사용할 수 있으므로 성능이 빠르다. 예를 들어 left, top을 사용하면 모든 프레임마다 엘리먼트와 배경이 합성되어 많은 시간이 걸리므로, transform: translate()를 사용해야 한다.

body {
  background-color: lime;
}

.animation-item {
  position: absolute; /* good */
  top: 0;
  left: 0;
  width: 50px;
  height: 50px;
  background-color: red;

  animation: move 3s ease infinite;
}

/* bad */
@keyframes move {
  50% {
    top: 100px;
    left: 100px;
  }
}

/* good */
@keyframes move {
  50% {
    transform: translate(100px, 100px);
  }
}

맺음말

지금까지 웹 애플리케이션의 성능을 최적화할 방법을 알아보았다. 최적화 전략은 브라우저 로딩, 렌더링 과정을 정확하게 이해하고 각 과정에서 일어나는 불필요한 작업을 제거하는 것이다. 이 가이드를 참고하여 다양한 최적화 기법을 실무에 적용해보고 더 나은 서비스를 제공하는 데 도움이 되길 바란다.


이 문서는 NHN Cloud의 FE개발랩에서 작성하고 관리하는 공식 웹 프론트 개발 가이드이다. 가이드 적용 관련 문의나 문서의 오류, 개선 제안은 공식 문의 채널(dl_javascript@nhn.com)을 통해 할 수 있다.


Last Modified
2019. 04. 08
FE Development LabBack to list