들어가기에 앞서
해당 글은 프론트엔드 성능최적화에 대한 기본 지식을 눌러 담은 글입니다. 글의 내용이 미비하거나 틀린 부분이 있을 수 있습니다. 해당 레포지토리의 이슈에 수정되어야 할 내용을 등록해주시면 감사히 글에 반영하겠습니다.
들어가며
앞선 글을 읽고 오면 좋습니다. 로딩 성능은 웹 페이지에 필요한 리소스를 다운로드할 때의 성능이라고 말했습니다. 고화질의 이미지가 포함되어있거나 HTML, CSS, JS 파일의 크기가 너무 크면 다운로드에 시간이 걸리기에 로딩 시간이 지연되게 됩니다. 우선, 로딩 성능을 개선하기 위해서는 브라우저가 어떻게 로딩되는지에 대한 기본 지식이 필요합니다. 우선 브라우저의 로딩 과정을 함께 살펴봅니다.
브라우저의 로딩 과정
브라우저의 로딩 과정은 크게 다운로드, 파싱, 스타일, 레이아웃, 페인트, 합성으로 나뉩니다. 각 단계 순서별로 차근차근 살펴보도록 하겠습니다.
- 파싱
- 브라우저에서 웹 페이지를 초기 로드했을 시에 가장 먼저 HTML파일을 다운로드 합니다. 파싱은 해당 HTML파일을 해석하여 DOM트리를 구성하는 단계입니다. 파싱 중 리소스를 다운 받아야 하는 경우에는 요청 후 다운로드 과정을 거치고, 리소스 중 CSS가 포함된 경우에는 CSSOM트리 구성 작업도 거칩니다.
- 스타일
- 파싱 단계에서 생성된 DOM, CSSOM 트리를 가지고 스타일을 매칭시키는 과정을 거쳐 하나의 렌더 트리를 구성합니다.
- 레이아웃
- 구성된 렌더 트리의 노드 하나하나의 정확한 위치와 크기를 계산합니다. 루트부터 순회를 돌면서 계산하고, 노드의 정확한 위치와 크기를 픽셀 값으로 렌더 트리에 반영합니다. %로 크기 값을 지정했다면 해당 단계를 거친 후에는 픽셀로 변환되어 값이 반영됩니다.
- 페인트
- 레이아웃 단계에서 계산된 값을 이용하여 화면상의 실제 픽셀로 변환하며 이 때 위치나 크기와 관계 없는 CSS 속성들도 함께 적용합니다. 픽셀로 변환된 결과들은 개별 레이어로 관리됩니다.
- 합성 & 렌더
- 페인트 단계에서 생성된 레이어들은 모두 합성하여 화면을 업데이트 합니다. 합성과 렌더 과정이 끝나면 드디어 사용자는 웹페이지를 볼 수 있게 됩니다.
로딩 성능 최적화
1. 이미지 사이즈 최적화
페이지에서 사용되는 크기의 이미지에 비해 너무 큰 이미지 리소스를 다운 받는 것은 성능 저하의 원인이 될 수 있습니다. 사용되는 이미지에 비해 2배~3배 정도 되는 사이즈의 이미지 정도로도 충분히 좋은 화질의 이미지를 사용자에게 제공할 수 있습니다. 자체적으로 갖고 있는 정적인 이미지라면 이미지의 사이즈를 직접 조절하여 사용하면 되는 간단한 문제이지만, API를 통해 받아오는 이미지의 경우 사이즈 조절이 까다로울 수 있습니다. 사용할 수 있는 방법 중 하나는 이미지 CDN을 활용하는 방법입니다.
CDN이란?
CDN은 간단히 말해 사용자와 가까운 거리에 컨텐츠 서버를 두는 것을 말합니다. 먼 거리에 있는 서버에서 이미지를 다운로드 하려 한다면 인터넷 속도가 빠르더라도 물리적인 거리가 멀기 때문에 이미지 다운로드 속도가 오래 걸릴 수 있으니, 먼 거리에 있는 서버를 미리 사용자와 가까운 거리에 있는 컨텐츠 서버에 복사해두고 이미지 리소스 다운로드를 요청할 시에 가까운 거리의 컨텐츠 서버에 요청하여 빠르게 다운로드가 가능하도록 합니다.
이미지 CDN이란 이미지에 특화된 CDN 으로 이미지의 포멧, 크기 등 이미지의 형태를 가공하는데 특화되어있으며, Cloudinary, imgix 등이 있습니다. 이미지 CDN에서 제공하는 주소는 주로 다음과 같이 이루어져있습니다.
http://cdn.image.com/?src=[img src]&width=240&height=240
위의 주소와 같이 쿼리 스트링에 원하는 너비와 높이 값을 붙여 요청한다면 알맞은 크기의 이미지 리소스를 다운받아 리소스를 요청하는 시간을 줄일 수 있습니다.
2. 작은 이미지를 HTML, CSS로 대체
웹페이지에 사용되는 이미지 개수가 적은 경우 이미지를 다운로드 하는 대신 HTML이나 CSS에 포함하여 사용할 수 있습니다. Data URI로처리할 수 있으며 Base64로 변환된 URI로 대체하여 사용하면 외부 이미지의 요청 횟수를 줄일 수 있습니다. 다만 캐시 문제가 있을 수 있으므로 필요한 경우에만 사용하도록 합니다.
3. 블록 리소스(CSS, 자바스크립트) 최적화
블록 리소스는 브라우저 로딩 과정에서 HTML 파싱이 일어날 때 CSS 또는 자바스크립트로 인해 파싱이 중단되었을 때 중단시킨 리소스를 블록 리소스라고 일컫습니다. 블록 리소스를 최적화 함으로써 로딩 성능을 최적화 할 수 있습니다.
CSS 최적화
브라우저 로딩 과정에서 스타일 단계에서 진행되는 렌더 트리를 구성하는 과정에는 DOM트리와 CSSOM트리 모두 필요합니다. DOM 트리는 HTML 파싱 중에 태그를 발견할 때 마다 순차적으로 트리를 구성할 수 있으나, CSSOM트리는 CSS를 모두 해석해야 구성할 수 있습니다.즉, CSSOM트리가 구성되지 않으면 렌더 트리를 만들지 못하고 렌더링이 차단되기 때문에 렌더링이 차단되지 않도록 CSS는 항상 HTML 문서 최상단에 배치합니다.
<head>
<link href="“style.css”" rel="“stylesheet”" />
…
</head>또한 특정 조건에서만 필요한 CSS가 있을 때 미디어쿼리를 이용하여 불필요한 블로킹을 방지가 가능합니다.
<head>
<link href="“style.css”" rel="“stylesheet”" />
<link href="“style.css”" rel="“stylesheet”" media="“print”" />
<link href="“style.css”" rel="“stylesheet”" media="“orientation:portrait”" />
</head>더불어 외부 스타일 시트를 가져올 때 사용하는 @import 사용은 지양하는 것이 좋습니다. 스타일시트를 직렬로 로딩하기 때문에 병렬 로딩이 불가하여 로드 시간이 늘어날 수 있습니다. link 방식은 병렬로 로딩하기 때문에 CSS가 많은 경우 link 로딩 방식이 로드 시간을 줄이는데 효과적일 수 있습니다.
자바스크립트 최적화
자바스크립트는 DOM트리와 CSSOM트리를 동적으로 변경할 수 있기 때문에 HTML 파싱을 차단하는 블록 리소스입니다.<script> 태그를 만나면 스크립트가 실행되며 그 이전까지 생성된 DOM 에만 접근할 수 있고, 스크립트 실행이 완료될 때 까지 DOM 트리 생성이 중단되기 때문에 HTML 문서 최하단에 배치합니다.
<body>
…
<script src=“app.js” type=“text/javascript”></script>
</body>만약 <script> 태그를 다른 곳에 위치 시켜야한다면 태그 내부에 defer나 async 속성을 명시하여 파싱 블로킹을 막을 수 있습니다. 해당 속성을 명시하면 HTML 파싱을 멈추지 않습니다.
4. 폰트 최적화
브라우저 진입 후 초기 로딩 시 폰트가 정상 적용 되지 않아 기본 폰트가 적용되었다가 적용 폰트가 적용되어 깜빡이는 모습이나 텍스트 자체가 보이지 않다가 갑자기 나타나는 모습을 본 적 있을 수 있습니다. 이는 사용자의 사용성에 영향을 줄 수 있기 때문에 해당 문제를 해결하는 방법을 살펴봅니다.
FOUT, FOIT란?
폰트 리소스가 다운로드 되기 전에 폰트가 나타나는 방식은 브라우저마다 다른데 해당 방식을 살펴봅니다. FOUT(Flash of Unstyled Text)는 Edge 브라우저에서 폰트를 로드하는 방식으로, 폰트의 다운로드 여부와 상관없이 우선 기본 폰트를 보여준 후 폰트 리소스가 다운로드되면 폰트를 적용하는 방식을 일컫습니다. FOIT(Flash of Invisible Text)는폰트 리소스가 다운로드 되지 않으면 텍스트를 보여주지 않다가 다운로드가 완료된 후에 적용된 폰트를 보여주는 방식을 일컫습니다. FOIT라고 하더라도 몇초 정도 기다린 후에 기본 폰트 노출 -> 다운로드 완료 후 적용 폰트 노출 하는 식으로 변경도 가능합니다.
폰트를 최적화 하는 방법은 폰트 적용 시점을 제어하거나, 폰트 사이즈를 줄이는 방법 두가지가 있습니다.
폰트 적용 시점을 제어하는 방법에는 CSS의 font-display 속성을 이용하면 FOIT, FOUT 방식 및 적용 시점도 제어할 수 있습니다.
- auto: 브라우저 기본 값
- block: FOIT(timeout = 3s)
- swap: FOUT
- fallback: FOIT(timeout = 0.1s) / 3초 후에도 불러오지 못한 경우 기본 폰트로 유지, 이후 캐시
- optional: FOIT(timeout = 0.1s) / 이후 네트워크 상태에 따라 기본 폰트로 유지할지 결정, 이후 캐시
텍스트가 중요한 컨텐츠이기 때문에 기본 폰트라도 선노출이 필요한 경우에는 FOUT 방식을 사용하고 미적인 부분이 중요한 컨텐츠같은 경우는 FOIT 방식을 사용하는 등 컨텐츠의 특성에 따라 방식을 제어하고 적용 시점을 제어할 수 있습니다. 그리고 FOIT 방식에서 너무 뿅 나타나서 어색해 보이지 않도록 페이드인 애니메이션을 주는 등의 방식을 사용해 사용성을 조금 더 높힐 수 있습니다.
폰트 파일 크기를 줄이는 방법으로는 폰트 포멧을 변경하는 방법과 필요한 문자의 폰트만 사용하는 방법(subset)이 있습니다.
폰트 포멧으로는 TTF, OTF, WOFF, WOFF2 등 다양한 포멧이 있는데 TTF 의 큰 파일 크기를 보완하여 나온게 WOFF(Web Open Font Format) 입니다. 단어 그대로 웹에서 사용하기에 적합한 폰트로 TTF 폰트를 압축하여 웹에서 더욱 빠르게 로드 될 수 있도록 나온 폰트입니다. WOFF2는 이보다 더욱 더 나은 압축 방식을 사용한 포멧입니다. 다만 WOFF, WOFF2 모두 브라우저 호환성에 문제가 있을 수 있어 @font-face src 적용 시 우선순위가 높은 것 부터 나열하여 작성하면 호환성 문제를 해결할 수 있습니다.(WOFF2 > WOFF > TTF)
또한, 서브셋 폰트를 사용하는 방법도 있는데 이는 텍스트가 많은 컨텐츠에는 적합하지 않고 일부 문자만 사용하는 홈페이지 같은 경우 적합합니다. 사용 되는 해당 문자의 폰트 정보만 가지고 있는 것을 서브셋(subset)폰트라고 합니다. 일부 문자의 폰트 정보만 가지고 있기 때문에 파일 크기가 매우 작아 다운로드가 매우 빠릅니다.
5. 코드분할과 지연 로딩
코드분할이란 코드를 분할하는 기법으로 하나의 번들 파일을 여러 개의 파일로 쪼개는 방법을 말합니다. A와 B가 모두 하나의 번들 파일에 합쳐져 A가 필요한 시점에도 B 까지 로드되는게 아니라 A가 필요한 시점에는 A.chunk.js만 B가 필요한 시점에는 B.chunk.js만 로드되도록 합니다. 분할된 코드는 사용자가 서비스를 이용하는 중 해당 코드가 필요해 지는 시점에 로드되어 실행되는데 이를 지연 로딩이라고 합니다. 모든 코드가 하나로 합쳐져 있으면, 당장 사용하지 않는 코드들도 함께 다운로드 되기 때문에 페이지 로드 속도가 느려집니다. 코드를 분할하는 방식에는 여러가지 패턴이 있는데, 페이지 별로 코드를 분할하거나 모듈별로 분할하는 방식 등 중복되는 코드 및 불필요한 코드 없이 적절한 타이밍에 적절한 사이즈의 코드가 로드되도록 하는 것이 중요합니다. 코드 분할을 하는 가장 좋은 방법은 동적 import를 사용하는 방법입니다. 기본적인 import를 사용해 특정 모듈을 불러올 시에 해당 모듈은 빌드 시 함께 번들링이 됩니다. 하지만 동적 import문을 사용하게 되면 빌드할 때가 아닌 런타임에 해당 모듈을 로드 합니다. webpack은 동적 import 구문을 만나면 코드를 분할하여 번들링합니다.
import (‘A’).then((module) => {
const { A } = module;
});동적 import문은 Promise형태로 모듈을 반환해 주는데 컴포넌트를 import하기 위해서는 Promise 내부에서 로드된 컴포넌트를 Promise 밖으로 빼내주어야합니다. 이를 해결하기 위해 리액트는 lazy와 Suspense를 제공합니다.
import React, { Suspense } from ‘react’;
const AComponent = React.lazy(() => import(‘./AComponent’));
function APage() {
return (
<div>
<Suspense fallback={<div>Loading</div>}
<AComponent />
</Suspense>
</div>
)
}lazy 컴포넌트는 Suspense 컴포넌트 하위에서 렌더링되어야하며, fallback prop등을 활용하여 컴포넌트가 로드될 때 까지 기다리는 동안 렌더링하려는 React 엘리먼트를 제공할 수 있습니다.
페이지별로 코드를 분할하고자 한다면 Router쪽에 해당 코드를 적용할 수 있습니다.
import React, { Suspense } from ‘react’;
import { Switch, Route } from ‘react-router-dom’;
const Home = React.lazy(() => import (‘./routes/Home’));
const About = React.lazy(() => import (‘./routes/About’));
const App = () => {
return (
<div>
<Suspense fallback={<div>Loading</div>}
<Switch>
<Route path=“/“ component={Home} exact />
<Route path=“/about“ component={About} exact />
</Switch>
</Suspense>
</div>
)
}이렇게 하면 사용자가 Home 페이지에 접근했을 때 전체 코드가 아닌 Home 컴포넌트의 코드만 동적으로 import하여 화면을 노출시킵니다.
6. 이 외
이외에도 중복된 코드 제거, HTML 마크업 최적화 등 로딩 성능 최적화를 할 수 있는 방식은 다양하게 있습니다. 각각 도메인, 콘텐츠 별로 특성에 맞게 성능 측정 도구를 참고하여 로딩 성능을 최적화 하면 되겠습니다.
