프론트엔드/JavaScript

[JavaScript] 로딩 최적화

프론트엔드 최적화는 로딩 최적화와 렌더링 최적화로 대표될 수 있다.

렌더링 최적화는 이전 글에서 알아봤으니 이번엔 로딩 최적화를 알아보고자 한다.

 

로딩 최적화는 서비스의 첫인상을 결정한다.

사용자에게 컨텐츠를 더 빨리, 더 쾌적하게 제공할 수 있기 때문이다.

이 글에선 로딩 성능을 나타내는 지표와 로딩 최적화를 하는 방법에 대해 소개하겠다.

 


 

로딩 성능을 나타내는 지표

로딩 성능 지표의 측정 기준은 크게 브라우저와 사용자 입장으로 나눌 수 있다.

 


 

💻 브라우저 기준의 성능 측정 지표

내비게이션 타이밍 모델 (Navigation Timing Model)

내비게이션 타이밍 모델은 브라우저의 동작 단계별 시간을 측정하는 방식이다.

내비게이션 타이밍 모델 (Navigation Timing Model)

 

그림은 브라우저가 렌더링 되는 일련의 과정이다.

프론트엔드의 역할은 마지막의 Processing과 Load 과정을 최적화하는 것이다.

각 과정의 시작엔 DOMContentLoaded, load 이벤트가 존재한다.

두 이벤트의 발생 시점이 빠를수록, 그리고 발생 폭이 좁을수록 성능이 좋다고 말한다.

DOMContentLoaded (파랑)

  • HTML과 CSS 파싱이 끝나는 시점
  • DOM, CSSOM 구성이 끝난 후, 렌더 트리 구성이 준비된 시점

load (빨강)

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

 


 

이벤트의 발생 시점에 관한 정보는 내비게이션 타이밍 API나 개발자 도구를 통해 확인할 수 있다.

내비게이션 타이밍 API

개발자 도구

  • 브라우저 이벤트 발생 시점에 관한 UI 및 다양한 측정 방식 제공

 


 

그러나 개발 패러다임의 변화로 인해 이벤트 발생 시점 가지고는 성능을 판단하기 어렵다.

 

그 예시로 SPA(Single Page Application)를 들 수 있다.

 

SPA는 초기에 적은 양의 HTML를 로드하기 때문에 이벤트가 일찍 발생한다.

그러나, 이후 사용 과정에서 많은 스크립트를 실행하기 때문에 로딩이 느려질 수 있다.

이러한 이유로 사용자 기준의 성능 측정이 더욱 중요한 지표가 되고 있다.

 


 

👨‍💻 사용자 기준의 성능 측정 지표

사용자 기준의 성능 측정은 사용자가 콘텐츠를 보는 시점들을 기반으로 한다.

 


 

렌더링 최적화 비교 예시

위의 그림에서 두 예시 모두 DOMContentLoaded, load 이벤트가 같은 시점에 발생했다.

하지만 점진적으로 컨텐츠를 보여주고, 아래는 로딩을 마친 후에 일괄적으로 보여준다.

그러면 사용자는 위의 예시를 보면서 로딩이 더욱 빠르다고 느끼게 된다.

 


 

웹 페이지의 로딩 시점

웹 페이지의 로딩 시점은 아래와 같은 단계로 정의할 수 있다.

특히 FMP(First Meaningful Paint)를 앞당겨 의미 있는 컨텐츠를 빠르게 보여주는 것이 핵심이다. 

 

  • FP (First Paint)
    • 흰 화면에 무언가가 처음으로 그려지기 시작하는 시점
  • FCP (First Contentful Paint)
    • 텍스트나 이미지가 출력되기 시작하는 시점
  • FMP (First Meaningful Paint) ⭐⭐
    • 사용자에게 의미 있는 컨텐츠가 그려지기 시작하는 첫 시점
    • 가장 중요한 시점
  • TTI (Time to Interactive)
    • 자바스크립트의 초기 실행이 완료되어 사용자가 행동을 할 수 있는 시점

 


 

🔧 개발자 도구

개발자 도구는 성능과 관련된 지표들의 정보를 다양한 방식으로 보여준다.

이 중 성능과 관련된 패널로는 NetworkPerformanceAudits가 있다.

 


 

1️⃣ Performance 패널

Performance 패널에서는 웹 페이지 로딩 단계를 차트 형태로 살펴볼 수 있다.

또한 웹 페이지가 로드되는 과정을 레코딩하여 단계마다 걸리는 시간을 확인할 수 있다.

Perfomance 패널 구성

 

performance 패널 구성

  1. Controls : 레코딩을 시작하거나 중단함
  2. Capture : 시간 흐름에 따른 Screenshots 확인, Heap Memory 상태 확인, 강제 GC(가비지 컬렉션) 수행
  3. Overview : 전체적인 흐름을 보여줌
  4. Main : Overview 내용을 상세히 볼 수 있음
  5. Details : Main 항목의 상세 내용을 보여줌

 


 

2️⃣ Network 패널

Network 패널에서는 웹 페이지 로딩 중 요청된 리소스의 상태를 차트 형태로 볼 수 있다.

Network 패널 구성

 

Network 패널 구성

  1. Controls : 패널 모양 및 기능을 제어함
  2. Filters : 보여줄 리소스를 선택함
  3. Overview : 전체적인 요청 및 다운로드 상황을 보여줌
  4. Request Table : 검색된 모든 리소스의 요청 및 다운로드 상황을 보여줌
  5. Summary : 총 요청 수, 전송 데이터 량, 이벤트 로드 시간을 보여줌

 


 

3️⃣ Audits 패널

Audits 패널에서는 다양한 성능들을 직접 측정해볼 수 있다.

Audits 패널 구성
Audits 성능 측정 결과

 

Audits 패널 구성

  • (1) Metrics : FCP, FMP, TTI 시점 확인, 페이지가 화면에 그려지는 단계를 스크린샷으로 보여줌.
  • (2) Opportunities : 최적화 가능한 리소스 목록을 보여줌
  • (3) Diagnostics : 리소스 최적화 외에 성능을 개선할 수 있는 부분 진단 및 해결 방안 제시→ Critical Request Chains : 주요 렌더링 최적화를 위해 참조하는 영역
  • (4) Passed audits : 성능 측정 기준과 통과 목록을 보여주는 영역

 


 

웹 페이지 로딩 최적화 방법

HTML 파싱 과정에서 다른 요소(CSS, JavaScript)를 만나면 파싱이 중단되어 블록 상태가 된다.

이렇게 블록 상태의 원인이 되는 리소스를 블록 리소스(Block resource)라고 한다.

 

이 블록 리소스를 최적화 하는 것이 로딩 최적화의 핵심이 된다.

아래 방법들 외에도 최적화와 관련된 수많은 방법이 있다.

관심 있다면 조금 더 깊게 공부하는 것을 추천한다. 

 


 

1️⃣ CSS 최적화

1. HTML 최상단에 CSS 배치하기

DOM 트리는 파싱 중에 태그를 발견할 때마다 순차적으로 구성된다.

하지만 CSSOM 트리는 CSS를 모두 해석해야만 구성이 완료된다.

그래서 CSSOM 트리가 구성되지 않으면 렌더 트리를 만들지 못하여 렌더링이 차단된다.

 

렌더링이 차단되지 않도록 하기 위해선 CSS는 항상 HTML 문서 최상단인 <head>에 배치한다.

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

 


 

2. 특정 조건에서의 스타일 시트는 미디어 쿼리 사용하기

특정 상황 및 조건에서 필요한 스타일 시트는 미디어 쿼리를 사용하면 특정 스타일에서만 조건부로 로드할 수 있다.

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

 


 

3. @import 피하기

외부 스타일시트를 가져올 때 @import를 사용을 피한다.

@import는 스타일시트를 병렬로 다운로드하지 않기 때문이다.

 

https://www.giftofspeed.com/avoid-using-css-import/

 


 

2️⃣ 자바스크립트 최적화

1. HTML 최하단에 스크립트 배치

HTML 파싱 중 스크립트를 만나면 스크립트가 다운되고 실행완료될 때까지 파싱을 중단한다.

스크립트가 DOM 트리와 CSSOM 트리를 동적으로 변경할 수 있기 때문이다.

 

또한 스크립트가 실행되는 동안은 그 이전까지 생성된 DOM에만 접근할 수 있다.

이러한 이유로 스크립트는 일반적으로 HTML 문서 최하단인 </body>바로 위에 배치한다.

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

 


 

✅ async vs defer

HTML5에 추가된 async나 defer 속성을 통해 스크립트를 만나도 HTML 파싱을 멈추지 않을 수 있다.

이들은 스크립트가 DOM 트리와 CSSOM 트리를 변경하지 않겠다는 의미를 가지기 때문이다.

 

우선 스크립트를 선언하는 다양한 방식들을 비교하며 두 속성을 이해해보겠다.

 


 

1) script가 <head> 안에 있을 때

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Document</title>
    <script src="main.js"></script>
  </head>
  <body></body>
</html>

 

script가 <head> 안에 있을 때

 

스크립트를 만나면 HTML는 파싱을 멈추고 스크립트 파일을 서버에서 다운받고 실행시킨다.

실행이 완료될 때까지 HTML 파싱이 멈추기 때문에 그만큼 로딩 시간이 발생한다.

 


 

2) script가 <body> 마지막에 있을 때

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Document</title>
  </head>
  <body>
    <div></div>
    <script src="main.js"></script>
  </body>
</html>

 

script가 <body> 마지막에 있을 때

 

<body> 끝에 위치하기 때문에 기본적인 페이지 콘텐츠를 빨리 보여줄 수 있다.

하지만 자바스크립트에 많이 의존하는 웹사이트의 경우엔 적절하지 않다.

 


 

3) script가 <head> 안에 있을 때 + async

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Document</title>
    <script async src="main.js"></script>
  </head>
  <body></body>
</html>

 

script가 <head> 안에 있을 때 + async

 

async 속성이 쓰인 스크립트를 만나면 병렬적으로 스크립트 파일을 다운받게 된다.

즉 다운로드가 완료되기 전까지 파싱이 진행되고 다운이 완료될 때 파싱을 멈추고 스크립트가 실행된다.

하지만 여전히 스크립트 실행 중에는 HTML 파싱이 멈춘다는 단점이 있다.

 


 

4) 다수의 script가 <head> 안에 있을 때 + async

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Document</title>
    <script async src="aaa.js"></script>
    <script async src="bbb.js"></script>
    <script async src="ccc.js"></script>
  </head>
  <body></body>
</html>

 

다수의 script가 <head> 안에 있을 때 + async

 

async 속성의 스크립트가 여러 개면 먼저 다운된 순서대로 스크립트가 실행된다.

스크립트 순서에 의존적인 웹사이트라면 실행 순서를 제어하기 어려워진다.

 


 

5) script가 <head> 안에 있을 때 + defer 👍

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Document</title>
    <script defer src="main.js"></script>
  </head>
  <body></body>
</html>

 

script가 <head> 안에 있을 때 + defer

 

async와 똑같이 파싱을 멈추지 않고 병렬적으로 스크립트를 다운 받는다.

다만 차이점은 defer는 HTML 파싱을 다 마친 후 스크립트를 실행한다는 것이다.

 


 

6) 다수의 script가 <head> 안에 있을 때 + defer 👍

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Document</title>
    <script defer src="aaa.js"></script>
    <script defer src="bbb.js"></script>
    <script defer src="ccc.js"></script>
  </head>
  <body></body>
</html>

 

다수의 script가 <head> 안에 있을 때 + defer

 

async는 다운된 순서대로 실행하기 때문에 실행 순서를 제어하기 어려웠다.

하지만 defer는 파싱 순서에 맞게 실행시키기 때문에 스크립트의 실행 순서를 제어할 수 있다.

 


 

언제 async를 사용할까?

async는 언제 HTML 파싱을 중단하고 스크립트가 실행될지 모른다. 그러므로 스크립트가 DOM과 종속성이 없어 어느 시점에 실행해도 상관없는 경우 사용하면 좋다.

 


 

언제 defer를 사용할까?

async와 달리 defer는 HTML 파싱이 끝난 뒤 실행된다. 그러므로 DOM 생성이 끝난 뒤 스크립트를 실행해야 할 때 써야 한다. 또한 여러 스크립트를 불러올 때 그 실행 순서를 보장하기 위해 사용한다.

 


 

3️⃣ 리소스 요청 수 줄이기

웹 페이지에 포함된 리소스(CSS, 자바스크립트, 이미지..)는 서버에 요청 후 다운로드된다.

다운로드에 비해 현저히 긴 요청 대기시간

 

이 파일의 실제 다운로드 시간은 1ms 남짓이지만, 그 외 대기 시간은 127ms가량 소요된다.

이를 최적화하기 위해선 되도록 필요한 요청만 해야 한다.

 


 

Image Sprite

이미지 스프라이트는 여러 개 이미지를 하나로 합쳐진 이미지로 만드는 것이다.

이 이미지를 CSS의 background-position 속성을 통해 부분적으로 잘라 사용한다.

이를 통해 각 이미지 리소스마다 요청이 필요한 일을 1번의 요청으로 줄일 수 있다.

Image Sprite (이미지 스프라이트)

 


 

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

Webpack과 같은 번들러를 사용하면 CSS, 자바스크립트 파일 요청을 줄일 수 있다.

번들러는 여러 개의 모듈 파일을 하나로 묶어서 1개의 번들 파일로 만들어주기 때문이다.

<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>

 


 

5️⃣ 내부 스타일시트 사용하기

내부 스타일시트를 사용하면 외부 스타일시트를 가져올 때 발생하는 요청 횟수를 줄일 수 있다.

단, 내부 스타일시트는 리소스 캐시를 사용할 수 없어서 HTML에 CSS가 매번 포함되므로 필요한 경우에만 사용한다.

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

 


 

6️⃣ 작은 이미지는 Base64로 대체

HTML, CSS에서 외부 경로로 이미지를 가져오던 부분을 Base64로 변환된 URI로 대체할 수 있다.

이렇게 하면 외부 이미지를 사용하기 위해 발생하는 요청 횟수를 줄일 수 있다.

이 또한 내부 스타일시트와 같이 캐시 문제가 있으므로 필요한 경우에만 사용한다.

<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAOCAYAAAAbvf3sAAAAAXNSR0IArs4c6QAAAHBJREFUKBVjYBimICwsLAaEsXmPGV0QqnAeUNxfW1v7/tWrVy8hq0HRgKQ4CahoIxDPQ9cE14CseNWqVUtAJoMUo2tiBFkXGRmp9/fv3zNAZhJIMUgMBmAGMTMzmyxfvhzhPJAmmCJ0Gp8cutqhwAcASWgwk+79LiQAAAAASUVORK5CYII=" />

 


 

7️⃣ 리소스 용량 줄이기

용량이 큰 리소스도 웹 페이지 로딩 시간을 느리게 하는 원인이 된다.

용량을 줄이기 위해 불필요한 데이터를 제거하고 압축하여 사용하는 것이 좋다.

 


 

중복 코드 제거하기

자바스크립트 코드 중 자주 사용되는 코드는 utils.js 파일로 정리해 사용한다.

중복 코드로 인해 용량이 늘어나는 문제를 막을 수 있다.

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

filter();
map();

 


 

사용하는 유틸만 import 하기

lodash 같은 만능 유틸 라이브러리를 전부 import 하게 되면 자바스크립트 파일 용량이 불필요하게 커진다.

이때 필요한 함수만 부분적으로 가져옴으로써 파일 용량을 많이 줄일 수 있다.

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

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

 


 

HTML 마크업 최적화

HTML은 태그의 중첩을 최소화하여 단순하게 구성하는 것이 좋다.

또한 불필요한 태그, 공백, 주석 등은 제거하여 사용한다.

 


 

8️⃣ CSS 하위 선택자 줄이기

CSS 선택자 깊이를 줄이는 것은 렌더링뿐 아니라 로딩 최적화에도 도움이 된다.

부모 선택자 탐색 시간이 줄어 CSSOM 트리의 생성 시간이 빨라지면 최초 렌더링 시간도 단축되기 때문이다.

// bad.container .list li .btn {
  background-color: red;
}

// good.list .btn {
  background-color: red;
}

 


 

9️⃣ 압축(Minify)

Webpack 플러그인 등에서 압축 기능을 제공해준다.

이렇게 압축하면 HTML, JavaScript, CSS 코드의 주석이나 공백 등을 제거한 후 난독화 처리가 된다.

 


 

🔟 리소스 우선순위 지정

preload

현재 페이지에서 빠르게 가져와야 하는 리소스에 사용하는 속성이다.

다만 as 속성을 통해 리소스의 유형을 알려줘야 한다.

 

preload는 리소스를 반드시 가져오기 때문에 리소스가 중복되지 않도록 해야 한다.

또한 반드시 사용되는 리소스에만 쓰여야 한다.

<link rel="preload" as="script" href="main.js">
<link rel="preload" as="style" href="style.css">

 


 

prefetch

페이지 로딩을 마친 후 다운로드 가능한 상황이 됐을 때 가장 낮은 우선순위를 부여한다.

브라우저는 이러한 리소스들을 캐시에 저장하게 된다.

다만 명시된 리소스만 가져오고 그 내부에 있는 리소스들을 가져오지 않는다.

<link rel="prefetch" href="subPage.html">

 


후기

이렇게 프론트엔드의 성능 최적화 방법에 대해 정리해보았다.

TOAST UI 기술 블로그 - 성능 최적화 글의 많은 도움을 받았다.

 

프론트엔드의 최적화는 비즈니스와 직결되는 문제이다.

많은 UX 사례가 증명하듯, 이러한 성능 최적화는 유저 이탈률과도 관련되기 때문이다.

 

성능 최적화를 공부하면서 많은 것을 배울 수 있었다.

렌더링 최적화의 핵심은 렌더링 연산을 최적화하고 리렌더링을 줄이는 과정이다.

로딩 최적화의 핵심은 자바스크립트의 블록 리소스 관리이다.

결론적으로 성능 최적화는 자바스크립트, 리액트의 깊은 이해를 요구하고 있었다.

 

얕은 수준의 글임에도 글을 쓰느라 무척 많은 시간이 소요되었다.

글재주도 아직 부족하지만 내용을 한줄 한줄 이해하고 넘어가는 시간이 길었기 때문이다.

 

그럼에도 나는 내가 이해하지 못한 것은 남에게 설명할 수 없다는 메타인지의 원칙은 지키고 싶다.

오늘의 고생 덕분에 다음 글은 더 수월하게, 더 정확한 내용으로 쓸 수 있을 것 같다.