웹 성능 최적화는 사용자 경험뿐만 아니라, 매출과도 거의 직접적으로 연결이 될 정도로 굉장히 중요하다.
웹 성능 최적화를 위해서 필요한 기본 지식과 함께, 최적화 방법에 관해서도 간단히 알아보도록 하자.
브라우저의 로딩 과정
웹 로딩 최적화를 위해서는, 브라우저의 로딩이 어떤 식으로 이루어지는지 알고있는 것이 도움이 많이 된다.
브라우저의 로딩은 크게 파싱, 스타일, 레이아웃, 페인트, 합성의 과정을 거친다.
좀 더 자세한 내용은 TOAST UI의 '성능 최적화' 글을 참조하길 바란다.
■ 파싱
파싱은 다운받은 HTML 파일을 해석하여 DOM 트리를 구성하는 단계이다.
파싱 과정에서 <script />, <link />, <img />를 만나면 해당 리소스를 요청, 다운로드한다.
CSS가 포함된 경우에는 CSSOM 트리도 함께 구성한다.
a. DOM 트리
HTML을 해석해 DOM을 생성하고, 각 DOM 객체를 트리 구조로 연결해 부모-자식 관계를 형성한다.
b. CSSOM 트리
CSS를 해석해 CSSOM 트리를 구성한다. 각 태그 선택자가 노드로 생성되고, 각 노드는 스타일을 참조한다.
■ 스타일
DOM 트리와 CSSOM 트리를 결합하여 렌더 트리를 구성한다.
■ 레이아웃(reflow)
root 노드부터 순회하면서 노드의 정확한 위치와 크기를 계산하여 픽셀값으로 렌더 트리에 반영한다.
■ 페인트(repaint)
레이아웃 단계에서 계산된 값을 이용해 렌더트리의 각 노드를 화면 상의 실제 픽셀로 변환한다.
이 과정에서 위치와 관계없는 CSS 속성(색상, 투명도 등)을 적용한다.
transform같은 속성을 사용하면 해당 엘리먼트가 레이어화 된다.
■ 합성 & 렌더
페인트 단계에서 생성된 레이어를 합성하여 화면을 업데이트한다.
이 단계가 끝나면 온전한 웹 페이지를 볼 수 있다.
⊙ DOM이 추가/삭제될 때, 엘리먼트에 기하적인 영향(높이, 넓이, 위치)을 주는 CSS 속성값을 변경할 때 렌더 트리가 다시 재구성된다. 즉, Style 단계에서 생성된 렌더 트리가 재구성되는 것이므로 Layout부터 과정을 다시 수행한다. 이것을 레이아웃(Layout) 또는 리플로우(Reflow) 이라고 한다.
⊙ 반대로 기하적인 영향을 주지 않는 CSS 속성값(background-color, color, visibility, text-decoration 등)을 변경하면 Paint부터 과정을 다시 수행한다. 이것을 리페인트(Repaint)라고 한다.
⊙ 레이아웃부터 일어나면 전체 픽셀을 다시 계산하기 때문에 부하가 크다. 반면 리페인트는 이미 계산된 픽셀값을 이용해 화면을 그리기 때문에 레이아웃에 비해 부하가 적다.
⊙ What forces Layout/Reflow
브라우저 로딩 초기에 HTML 파싱이 일어날 때, CSS 또는 JS에 의해 파싱이 중단될 수 있다.
이를 두고 '블록되었다' 라고 표현하고, 블록의 원인을 두고 '블록 리소스' 라고 한다.
블록 리소스를 최소화하기 위해선 CSS 혹은 JS 로딩을 최적화해야 한다.
블록 리소스 최소화
CSS 최적화하기
■ 미디어 쿼리를 사용해서 CSS 로딩하기
⊙ 특정 조건에서만 필요한 CSS가 있다면 미디어 쿼리를 사용해 불필요한 블로킹을 방지할 수 있다. 즉, 해당 스타일을 사용하는 경우에만 해당 CSS 파일을 로딩하는 것이다.
<!-- 미디어 쿼리 사용 X -->
<link href="style.css" rel="stylesheet" />
<link href="print.css" rel="stylesheet" />
<link href="portrait.css" rel="stylesheet" />
<!-- 미디어 쿼리 사용 O -->
<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의 사용을 피하자.
브라우저는 스타일시트를 병렬로 다운로드할 수 없으므로 파일을 타고 타고가면서 로딩 시간이 늘어난다.
■ 공통 스타일은 class로 정의해서 사용하기
자바스크립트 최적화하기
자바스크립트를 로딩하는 동안 HTML이 블록된다는 말은,
곧 자바스크립트의 용량을 줄이면 웹페이지를 그리는 시점이 빨라진다는 의미이다.
■ 트리 쉐이킹
외부 모듈을 import할 땐, 전체 파일을 한 번에 import하지 말고
필요한 기능만을 import하여 번들 파일 용량을 줄이자.
import _ from 'lodash'; // X
import array from 'lodash/array'; // O
■ 빌드 설정 점검해보기
빌드 설정을 점검해보자. 가장 보편적으로 사용되는 Webpack의 경우,
'webpack-bundle-analyzer'를 사용하면 번들의 구성을 이미지로 한 눈에 확인할 수 있다.
번들의 구성을 살펴보고 사이즈 비중이 높은 부분의 빌드 설정을 점검하면서
왜 비효율적으로 사이즈가 큰 건지 생각해보면 문제점이 발견될 수도 있다.
■ 코드 스플리팅
보통 리액트를 사용하면 페이지 단위로 리액트 컴포넌트가 구분되어 있을텐데,
특정 페이지에서 다른 페이지에 진입하기 전에 해당 페이지들을 전부 로딩하는 과정은 불필요하다.
⊙ 코드 스플리팅은 번들 파일을 여러 묶음으로 쪼개는 기법이다. 여러 묶음으로 나눈 후에 해당 소스코드가 필요한 시점이 되면 그때서야 불러오게 된다. 리액트에서는 React.lazy를 사용하면 코드 스플리팅을 간단하게 구현할 수 있다.
// React.lazy 사용 X
import OtherComponent from './OtherComponent';
// React.lazy 사용
const OtherComponent = React.lazy(() => import('./OtherComponent'));
⊙ React.lazy는 Suspense 컴포넌트와 함께 사용되어야 한다. Suspense는 lazy 컴포넌트가 로딩되는 동안 '로딩 화면'같은 fallback 컨텐츠를 보여줄 수 있게 해준다.
⊙ 하나의 Suspense 컴포넌트로 여러 lazy 컴포넌트를 감싸는 것도 가능하다.
import React, { Suspense } from 'react';
const OtherComponent = React.lazy(() => import('./OtherComponent'));
const AnotherComponent = React.lazy(() => import('./AnotherComponent'));
function MyComponent() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<section>
<OtherComponent />
<AnotherComponent />
</section>
</Suspense>
</div>
);
}
⊙ React.lazy를 React Router와 함께 사용하면 '라우트 기반 코드 스플리팅'을 설정할 수 있다.
import React, { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
const Home = lazy(() => import('./routes/Home'));
const About = lazy(() => import('./routes/About'));
const App = () => (
<Router>
<Suspense fallback={<div>Loading...</div>}>
<Switch>
<Route exact path="/" component={Home}/>
<Route path="/about" component={About}/>
</Switch>
</Suspense>
</Router>
);
■ 불필요한 라이브러리 제거
■ 동일한 기능의, 용량이 작은 라이브러리로 교체하기
웹 페이지 리소스 최적화
웹 페이지 리소스의 로딩을 최적화하기 위해서는 불필요한 다운로드를 없애고, 리소스 전송을 최적화해야 한다.
텍스트로 이루어진 리소스는 GZIP압축을 통해 최적화할 수 있고, 이미지 리소스는 최적화 방법이 매우 다양하다.
불필요한 다운로드 없애기
비용 대비 실제로 가치가 있는 리소스인지를 잘 판단해야 한다.
예를 들어, 페이지 상단에 슬라이더 이미지를 배치하여 사진이 회전되게 해놨다고 치자.
여기서 중요한 기준점은 해당 리소스를 불러오는 비용에 비해
실제로 얼마나 많은 방문자들이 해당 리소스에 주목하거나 방문해봤냐 하는 것이다.
만약 대부분의 방문자가 보지 않는 리소스라는 결과가 도출된다면
해당 리소스를 계속 사용할 것인지는 고려를 해봐야 할 것이다.
GZIP을 이용한 텍스트 압축
텍스트 기반의 HTML, CSS, JS 파일의 전송에 있어 GZIP은 최적의 성능을 낸다고 알려져 있다.
대부분의 최신 브라우저들은 GZIP 압축을 지원하고, 이를 자동으로 요청한다.
또한 대부분의 서버는 리소스를 제공할 때 자동으로 압축을 해주지만,
CDN을 사용하는 경우에는 GZIP 압축이 활성화 되어있는지 잘 확인해줘야 한다.
이미지 최적화
이미지가 많이 사용되는 웹에서는 이미지 최적화가 로딩에 큰 영향을 끼친다.
현재 Babble의 경우, 이미지 최적화를 적용하지 못한 상태이기 때문에 아래의 사진을 보면
이미지 로딩에 짧게는 0.2초, 길게는 1초 넘게까지 걸리는 모습을 볼 수 있다.
모바일의 경우는 더 오래 걸린다. 네트워크 제약같은 걸 두면 더더욱 오래 걸리고.
■ 적절한 이미지 포맷 선택하기
PNG, JPEG, WebP 등 여러 가지 이미지 포맷들을 상황에 따라 선택하여 적용하면 된다.
⊙ PNG보다는 JPEG이 사이즈가 더 작다. PNG는 비손실 압축, JPEG는 손실 압축을 사용하기 때문. 약간의 손실을 감수하되, 작은 용량으로 사진을 저장하는 데에 특화된 것이 바로 JPEG.
⊙ WebP같은 최신 이미지 포맷은 이미지 사이즈를 많이 줄일 수 있지만, 구형 브라우저(WebP의 경우 특히 사파리) 지원이 안되는 경우가 많으므로 주의할 것.
⊙ 구형 브라우저에서는 fallback 이미지로써 예전 이미지 포맷을 제공하고, 신형 브라우저에서는 WebP 이미지를 제공하게 하는 방법도 있다.
<!-- 기존 -->
<img src="flower.jpg" alt="">
<!-- 개선 -->
<picture>
<source type="image/webp" srcset="flower.webp">
<source type="image/jpeg" srcset="flower.jpg">
<img src="flower.jpg" alt="">
</picture>
<!-- 출처: https://web.dev/serve-images-webp/ -->
■ imagemin으로 이미지 압축하기
imagemin을 사용하면 이미지를 압축하여, 빌드 타임에 이미지 용량을 줄여줄 수 있다.
Webpack의 경우 ImageMinimizerWebpackPlugin 라는 플러그인을 사용하면 imagemin을 적용할 수 있다.
⊙ 이미지 압축은 크게 2가지, lossless와 lossy로 제공된다. lossless는 무손실, 말 그대로 압축 후에도 품질 저하가 없는 압축 방식을 의미하고 lossy는 압축 후에 품질 저하가 생기는 압축을 의미한다. 당연히 lossy 방식 압축률이 더 크다.
⊙ imagemin의 경우 이미지 포맷에 따른 플러그인을 적용하여 압축한다.
⊙ imagemin을 사용하는 방법에는 npm module과 CLI 두 가지 방식이 있는데, 더 상세하게 옵션을 설정해줄 수 있는 npm module 방식을 추천.
■ 이미지 리사이징
이미지가 웹에서 사용되는 크기에 맞게 제공되는 것이 바람직하다.
만약 216 X 260 정도 크기를 보여주기 위해 1280 X 960 크기의 원본 이미지가 사용되고 있다면,
사용되는 크기에 맞게 리사이징을 해줘야 한다.
이미지 리사이징을 위해 사용되는 툴은 sharp npm package와 ImageMagick이 가장 유명하다.
아래의 예시는 ImageMagick.
# macOS/Linux
convert flower.jpg -resize 25% flower_small.jpg // 원본 이미지의 25% 크기
convert flower.jpg -resize 200x100 flower_small.jpg // 200x100 크기
# Windows
magick convert flower.jpg -resize 25% flower_small.jpg
magick convert flower.jpg -resize 200x100 flower_small.jpg
■ 반응형 이미지 제공하기
반응형 이미지는 보통 사이즈에 따라 3-5개까지 제공하는 것이 보편적이다.
다양한 이미지 사이즈를 제공하는 것이 성능에 더 좋지만,
사이즈를 다양하게 제공할수록 당연히 더 많은 서버 공간을 요구하게 되고, 더 많은 HTML 코드가 필요하다.
상황에 맞게 적절한 타협점을 찾는 것이 중요.
img 태그의 srcset와 sizes 속성을 이용하면 다양한 이미지 사이즈를 제공할 수 있다.
// 적용 X
<img src="flower-large.jpg">
// 적용 O
<img src="flower-large.jpg"
srcset="flower-small.jpg 480w,
flower-large.jpg 1080w"
sizes="(max-width: 480px) 50vw
(max-width: 1080px) 60vw">
⊙ src 속성은 srcset와 sizes속성을 지원하지 않는 브라우저에서도 이미지를 정상적으로 출력하게 하기 위한 속성이다. 이 경우는 반응형이 아니므로, 모든 디바이스 사이즈에서 정상적으로 출력될 만한 충분한 크기의 이미지가 제공되어야 한다.
⊙ srcset 속성은 콤마로 구분되는 이미지 파일명 목록이다. width descriptor를 함께 적어준다. width descriptor는 별건 아니고, 그냥 브라우저에게 이미지 너비를 알려주기 위한 것. 단위는 px이 아니라 w를 사용한다는 점에 주목하자. 의미는 동일하고, 그냥 px자리에 w를 적어주면 된다. 위 예시에서 480w는 flower-small.jpg 이미지가 480px 너비를 가진다는 뜻.
⊙ sizes 속성은 해당 이미지가 출력될 때 어느 정도의 너비를 가져야하는지 브라우저에게 알려주는 속성이다. 앞에 나오는 미디어 조건문이 참인 경우 해당 이미지가 어느 정도의 너비를 가질지를 의미한다. 그냥 '50vw' 처럼만 적어주면, 디스플레이 사이즈와는 관계없이 동일한 너비를 가지게 만든다.
참고) % 단위는 사용 불가.
⊙ 만약 다양한 디스플레이 해상도를 지원할 때 동일한 크기의 이미지를 보여준다는 것을 좀 더 간단하게 적고싶다면, 다음과 같은 코드도 가능하다.
<img srcset="flower-320w.jpg,
flower-480w.jpg 1.5x,
flower-640w.jpg 2x"
src="flower-640w.jpg">
■ 이미지 스프라이트 사용하기
웹에 사용되는 아이콘의 경우, 대부분은 이미지 사이즈가 작고 개수가 많다.
이 경우, 아이콘을 전부 따로 로딩하게 되면 아이콘 1개 당 1번의 네트워크 요청을 하기 때문에 매우 비효율적이다.
이미지 스프라이트는 여러 개의 아이콘을 하나의 이미지로 합치는 기법이며,
이를 사용하면 1번의 네트워크 요청으로 여러 아이콘을 불러올 수 있다.
한 개의 이미지 내부에서 위치를 달리 하여 아이콘을 다르게 사용할 수 있다.
.up,
.down,
.right,
.left {
display: inline-block;
background: url("icon.png") no-repeat;
}
.up { width: 59px; height: 62px; background-position: 0 0; }
.down { width: 56px; height: 62px; background-position: -60px 0; }
.right { width: 62px; height: 60px; background-position: -117px 0; }
.left { width: 62px; height: 60px; background-position: -178px 0; }
■ Lazy Loading
절대적인 수치를 개선하는 방법은 아니지만, 방문자의 체감 속도를 향상시키는 방법이다.
방문자에게 처음부터 보여질 필요가 없는 이미지들은 페이지 로딩 시점에 불러오는 게 아니라 별도의 이벤트,
예를 들면 스크롤 혹은 좌우 스와이프 등으로 이미지를 탐색하는 순간에 불러오도록 만들면 된다.
Web API 중에 Intersection Observer API를 사용하면 Lazy Loading을 수월하게 구현할 수 있다.
import React, { useEffect, useRef } from "react";
import ReactDOM from "react-dom";
import "./styles.css";
// Custom hook
function useLazyImageObserver(target) {
useEffect(() => {
if (!target.current) {
return;
}
let observer = new window.IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const lazyImage = entry.target;
lazyImage.src = lazyImage.dataset.src;
observer.unobserve(lazyImage);
}
});
});
observer.observe(target.current);
return () => {
if (observer) {
observer.disconnect();
observer = null;
}
};
}, [target]);
}
// Component
function App() {
const target = useRef(null);
useLazyImageObserver(target);
return (
<section>
<div style={{ height: "2000px" }} />
<img ref={target} data-src="https://placeimg.com/320/100/any" alt="" />
</section>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
// 출처: 마이리얼트립 프로덕트 블로그의 'useLazyImageObserver Hook 예제 코드'
// (https://medium.com/myrealtrip-product/fe-website-perf-part1-6ae5b10e3433)
출처 및 참고
'성능 최적화' 카테고리의 다른 글
도커 스웜을 이용한 스케일 아웃 (0) | 2021.10.27 |
---|---|
브라우저 렌더링 과정을 알아보자! (0) | 2021.08.23 |
댓글