최근 웹 성능 최적화 기법 책을 보며 성능 최적화에 대해 공부하고 있다. 아직은 대략적으로 훑어보는 1회독을 마쳤고 이제 목차별로 찬찬히 다시 볼 예정.. (한번 레이어를 깔고 세부 항목을 보는 건 차이가 있는 것 같다)
자투리 시간에 성능 최적화에 대해 검색해보다가 좋은 자료들을 찾았다. 최적화는 파고 들어가면 한도 끝도 없는 것 같은데 대표적인 방법으로 몇 가지로 추려지는 것 같아서 여러 블로그 자료와 읽고 있는 책의 내용을 종합해서 단권화를 해볼까 한다.
참고: 웹 성능 최적화 5분 정리
우선 대표적인 최적화 방법에는 다음과 같은 것들이 있다. 개인적으로 부연할 부분만 가져왔다.
style은 상단, js는 하단에서 불러오기
이 항목은 웹 성능 최적화에서 거의 항상 나오는 얘기인데 리액트와 같은 프레임워크를 주로 쓰는 경우에는 어떻게 해야 하는지 늘 궁금했다. javascript는 index.html에서 body 직전에 불러올 수 있다고 하지만 style 태그는..? 보통 프레임워크에서는 CSS-in-JS나 Sass(SCSS)를 사용한다. 프레임워크에서도 태그의 위치로 최적화하는 방법이 있을까?
웹팩(Webpack)을 활용한 최적화
CRA로 리액트 앱을 만들 때는 웹팩에 거의 신경쓰지 않았다. (사실 잘 알면 웹팩 커스텀으로 최적화 가능하다고 알고 있지만..) 근데 최근에 CRA 대신 를 쓰게 되면서 'CRA가 편하지만 구동이 느렸던 이유가 모든 웹팩 셋팅이 되어 있어서인데 Vite는 이 부분을 어떻게 처리했을까?' 하는 궁금증이 들면서 다음을 정리했다.
js의 공백 줄이기
이건 성능화 책에서도 봤는데 주석이나 불필요한 공백을 줄이면 js 파일 크기 자체가 줄어들어서 최적화에 영향을 미치는 것 같다. 하지만 요즘은 에디터의 힘을 빌리면 불필요한 공백은 쉽게 정리할 수 있고(emmit, prettier 등) 주석은 꼭 필요한 경우에는 유지 보수를 위해 달아둬야 한다고 배워서 이 부분은 넘어갔다.
html 작성 시 불필요한 div 제거
예전에 비슷하게 면접을 보러 갔다가 새로 리모델링한 사이트 이전에 외주로 맡겼던 사이트를 보여주셨는데 내가 보기에도 div 천지여서(?) 브라우저가 문서를 잘 읽어갈 것 같지 않았다.
리액트 공식 문서에서는 불필요한 태그 사용을 줄이는 방안으로 React.Fragment 태그를 제시했다.
📌 React.Fragment 태그
여러 자식 태그를 그룹화하는 것 외에 어떤 역할도 하지 않는 태그
<></>
로 간략하게 사용 가능하다
파일 개수 줄이기
JS 파일의 갯수를 줄이면 그만큼 빠른 번들링과 렌더링이 가능하다.
라이브러리 의존도 낮추기
찔림 직접 구현이 가능한 부분은 라이브러리 의존도를 최대한 낮추자.
간단한 성능 측정 도구를 먼저 소개한다.
Lighthouse는 몇 번 써봤는데 PageSpeed Insights는 처음이었다. 바로 토이 프로젝트의 URL을 넣어 돌려보니 Lighthouse와 비슷한 UI인데 좀 더 시각적으로 보기 편하고, 실제 배포되어 링크를 가진 사이트만 확인이 가능한 것 같다.
성능 최적화는 크게 렌더링 최적화와 로딩 최적화 두 가지로 나뉜다.
성능 최적화 = 렌더링 최적화 + 로딩 최적화
즉, 렌더링과 로딩 과정 이 두 가지를 최적화하면 웹 성능을 유의미하게 개선할 수 있다.
먼저 렌더링 최적화에 대해 알아보자.
렌더링 최적화의 목표:
브라우저의 리플로우를 최대한 적게 발생시키면서 빠르게 화면을 그리는 것
리플로우는 렌더링 차단 리소스에 의해 발생한다.
렌더링 차단 리소스란 브라우저의 렌더링을 막는 소스로 일반적으로 css와 js 파일을 말한다.
레이아웃의 넓이, 높이, 위치 등에 영향을 주는 css 속성을 변경하면 Layout 단계부터 다시 그려진다. 이를 리플로우(Reflow)라고 한다.
반면 레이아웃에 영향을 주지 않는 속성을 변경하면 레이아웃을 건너뛰고 Paint 단계부터 리렌더링하게 되는데 이를 리페인트(Repaint)라고 한다.
결론
레이아웃 단계부터 변경하는 Reflow가 일어나면 브라우저가 전체 픽셀을 다시 계산하기 때문에, 되도록이면 Repaint인 속성을 사용해서 css를 작성하는 것이 좋다.
🍑 리플로우(Reflow) 속성
너비, 높이, 배치 등에 영향을 주는 속성
width / height / margin / padding / display /
position / float / top / left / right / bottom /
box-sizing / border / border-color / border-width /
font-size / font-weight / font-family / text-align /
line-height / vertical-align /
white-space / word-wrap / text-overflow / text-shadow ...
🍎 리페인트(Repaint) 속성
너비, 높이, 배치 등에 영향을 주지 않는 속성
color / text-decoration / visibility /
background / background-color / background-image / background-position / background-repeat / background-size / box-shadow /
border-radius / border-style / text-decoration /
outline / outline-style / outline-color / outline-width ...
✨ 리플로우와 리페인트를 둘 다 발생시키지 않는 속성
opacity / transform / cursor / z-index /
display: none ...
앞서 말했듯 css는 렌더링 차단 리소스이기 때문에 사용하지 않는 css는 제거하는 것이 좋다. Unused css는 크롬 Lighthouse를 통해 확인할 수 있다.
나의 경우는 프로젝트에서 사용되지 않는 global 폰트가 있어서 제거했더니 Performance에서 5점이 올랐다.
셀렉터(선택자)가 복잡할수록 부모 선택자에 자식 선택자 포함 여부를 확인하려고 DOM을 거슬러 올라가는 데 시간이 소요되어 성능에 좋지 않다.
.mypage .mypage_item{...} /* 🔺 */
.mypage_item{...} /* ✅ */
인라인 스타일은 웹 페이지가 그려질 때 레이아웃에 영향을 미치고 유지보수 측면에도 좋지 않으므로 지양한다.
<div style="margin-top:20px;"></div> <!-- ❌ -->
DOM 트리가 커지고 깊어질수록 DOM 변경 시 계산해야 하는 것들이 많아진다. 불필요한 div wrapper 등을 제거하자.
면접 때 애니메이션 최적화의 중요성에 대해 설명을 들은 적 있다.
애니메이션은 자바스크립트나 라이브러리보다 css를 통해 구현하는 것이 성능적으로 이득이다.
transform은 리플로우와 리페인트를 모두 발생시키지 않기 때문에 애니메이션에서 사용하면 렌더링 속도를 향상시킬 수 있다.
position을 absolute나 fixed로 설정해도 주변 요소에 영향을 주지 않는다.
원래 레이아웃은 비동기이나 특정 속성을 읽을 때 최신 값을 계산하기 위해 레이아웃이 동기적으로 발생하는 경우이다.
예를 들어 style 변경 이후에 offsetHeight, offsetTop과 같은 계산된 값을 속성으로 읽을 때에 강제 동기 레이아웃이 발생하게 된다.
const tabBtn = document.getElementById('tab_btn');
tabBtn.style.fontSize = '24px';
console.log(testBlock.offsetTop); // offsetTop 호출 직전 브라우저 내부에서는 동기 레이아웃이 발생한다.
tabBtn.style.margin = '10px';
// 레이아웃 변경
계산된 값을 반환하기 전에 변경된 style이 계산 결과에 적용되어 있지 않으면 style 변경 이전의 계산 값을 반환한다. 최신 브라우저에서도 발생하는 부분이므로 강제 동기 레이아웃을 발생할 수 있는 코드는 최대한 사용하지 않도록 주의해야 한다.
한 프레임 내에서 강제 동기 레이아웃이 연속적으로 발생하는 경우이다. 다음 코드에서 반복문 안에서 style.width를 설정하고 box.offsetWidth를 읽어와서 for문이 실행될 때마다 레이아웃 변경이 반복 발생한다. 반복문 밖에서 box 엘리먼트의 너비를 읽어오면 레이아웃 스레싱을 막을 수 있다.
DOM의 상위 노드의 스타일을 변경하면 하위 노드에 모두 영향을 미치기 때문에 변경 범위를 최소화하기 위해서 가능한 하위 노드의 DOM을 조작한다.
display: none
은 DOM이 조작되어도 레이아웃과 리페인트가 발생하지 않는다. 따라서 많은 수의 요소를 변경해야 할 때 display: none으로 숨겨놓고 요소를 변경한 뒤 다시 보이게 하면 레이아웃 발생을 최대한 줄일 수 있다.
참고로 visibility: hidden의 경우, 리페인트는 발생하지 않지만 공간을 차지하기 때문에 리플로우가 발생하게 된다.
html과 css는 바로 눈에 보여지는 부분이다.
빠르게 그려질 수 있도록 css는 head 내에서 임포트한다.
파싱 중 script 태그를 만나면 브라우저는 html 파싱을 멈춘다. 따라서 js 외에 나머지 소스들이 먼저 로딩이 끝나고 js가 실행되도록 body 태그를 닫기 직전에 script 태그를 임포트 한다.
media 속성을 사용하면 브라우저에 css를 반응형이라고 인식시킬 수 있다.
크롬 Lighthouse는 media 속성이 없는 <link rel=”stylesheet” />
태그를 렌더 블로킹 리소스로 판단한다.
media 속성이 없으면 브라우저가 stylesheet를 해석할 때 화면에 style을 불러오지 못한다.
<link href="style.css" rel="stylesheet"> <!-- ❌ -->
<!-- 화면이 특정 너비일 때 브라우저가 stylesheet 해석 -->
<link href="style.css" rel="stylesheet" media="(min-width: 320px) and (max-width: 768px)">
두 속성은 html 파싱을 멈추지 않고 스크립트 파일을 병렬적으로 다운로드하며 실행에서 시간차가 있다.
async는 다운로드 후 즉시 실행해서 잠깐 파싱이 끊기지만, (즉시 실행)
<script async src="test.js"></script>
defer는 다운로드 및 파싱이 모두 끝난 후에 script를 실행한다. (지연 실행)
<script defer src="test.js"></script>
그림으로 확인해보자.
초록색: HTML 파싱
회색: HTML 파싱 멈춤
파랑색: script 다운로드
빨강색: script 실행
파싱이 도중에 끊기고 script를 다운로드해서 실행까지 한 뒤에 다시 파싱을 시작한다.
파싱을 끊지 않고 script를 병렬 다운로드하지만, 실행을 위해서 잠시 파싱을 끊었다가 실행이 끝나면 다시 파싱을 진행한다.
파싱을 끊지 않고 script를 병렬 다운로드하고, 이후에 파싱이 모두 끝나면 그때서야 script를 실행한다. (도중에 파싱이 끊기지 않음)
picture
태그는 다음 2가지가 가능하다.
source
태그의 type
으로 다양한 형식의 이미지 제공 가능img
태그)를 초기 값으로 넣어둔다.avif - 저용량 & 고품질, 가장 좋음
webp - jpg와 png 대비해서 30%-70% 용량
<picture>
<source srcset="aaa.avif" type="image/avif" /> /* avif */
<source srcset="aaa.webp" type="image/webp" /> /* webp */
<img src="aaa.jpg" alt> /* jpg */
</picture>
source
의 media
로 반응형 이미지 제공 가능img
태그로 반응형이 없을 때의 초기값 이미지를 지정할 수 있다.<!-- 보통은 pc.webp 이미지, 브라우저의 넓이가 760px 이하일 때는 mob.webp 이미지 출력-->
<picture>
<source srcset="mob.webp" media="(max-width: 760px)">
<img src="pc.webp" alt>
</picture>
여러 개의 이미지를 하나로 만든 이미지이다.
다음은 인스타그램 스프라이트 이미지 예시이다.
css의 background-position 속성으로 부분적으로 이미지를 가져와 사용한다. 리소스 요청 자체가 줄기 때문에 의미 있는 최적화 방법이다.
background-position으로 가져온 모습
.icon_rotate { width:15px; height:15px; background-position:-23px -50px; }
.icon_del { width:10px; height:13px; background-position:-65px -15px; }
...
img
태그의 loading 속성을 사용해서 브라우저 화면에 이미지를 지연/병렬 로딩할 수 있다.
// 사용된 코드
<img src="item.jpg" loading="lazy" alt>
웹팩으로 css 파일과 js 파일들을 각각 하나의 파일로 번들링(묶기)한다.
⬇️ css/js 번들링 이전 ⬇️
<html>
<head>
/* css 파일 3개 */
<link href="main.css" rel="stylesheet">
<link href="sub.css" rel="stylesheet">
<link href="sub2.css" rel="stylesheet">
</head>
<body>
<div id="content">
...
</div>
/* js 파일 2개 */
<script async src="sample1.js" type="text/javascript"></script>
<script async src="sample2.js" type="text/javascript"></script>
</body>
</html>
⬇️ 웹팩을 통한 css/js 번들링 이후 ⬇️
<html>
<head>
/* css 파일이 bundle.css 1개로 묶임 */
<link href="bundle.css" rel="stylesheet">
</head>
<body>
<div id="content">
...
</div>
/* js 파일이 bundle.js 1개로 묶임 */
<script async src="bundle.js" type="text/javascript"></script>
</body>
</html>
모든 브라우저에서 지원하며, 가장 유명한 데이터 압축 및 압축 해제 알고리즘이다.
Accept-Encoding: gzip
으로 서버에 요청을 보낸다.Content-Encoding: gzip
을 통해 데이터가 압축된 방식을 확인하고, 해당 방식으로 데이터 압축을 해제한다.UglifyJS
등의 라이브러리를 사용해서 js 파일을 압축한다. 불필요한 공백이나 줄바꿈을 제거해서 파일의 용량이 감소하며, 난독화를 하면 민감한 코드를 알아보기 어려워진다.
Content Delivery Network로, 유저에게 많은 콘텐츠를 손실 없이 빠르게 전달하는 서비스이다.
대용량 콘텐츠 다운 또는 스트리밍 등에 사용하며 사용량 만큼 비용을 지불한다.
html, css, js, 이미지 등의 리소스를 첫 요청 시 다운받고 특정한 위치에 복사본을 저장해둔 뒤 이후에 동일한 URl의 리소스 요청이 오면 이전에 저장해둔 리소스를 사용하는 방법이다.
프로그램 전반에 해당하는 공통의 개념인데, 프론트엔드에서의 캐싱은 구체적으로 웹 캐시를 말한다.
보통 사이트를 새로 들어갈 때 첫 접속 때는 로딩 속도가 느리지만 이후에는 속도가 현저히 빨라지는 것이 예이다.
캐싱은 브라우저가 새로 다운로드할 파일의 개수 자체가 줄어서 리소스 요청 수를 줄인다.
브라우저는 서버에게 받은 응답 헤더의 내용에 따라 캐쉬 정책을 수행한다.
응답 헤더에 Last-Modified, Etag, Expires, Cache-Control:max-age 항목이 존재하면 복사본을 생성하고 값을 저장하게 된다.
브라우저는 최초 응답 시 받은 Last-Modified
값을If-Modified-Since
라 는 헤더에 포함시켜 페이지 요청
서버는 요청 파일의 수정 시간을 If-Modified-Since
값과 비교하여 동일하 면 '수정되지 않음(304 Not Modified
)'로 응답, 다르면 '수정됨(200 OK
)'과 함께 새로운 Last-Modified
값을 응답 헤더에 넣어 전송
브라우저는 응답 코드가 304
(수정 없음)이면 이미 캐쉬된 리소스로 페이지를 로드하고, 200
(수정됨)이면 새로운 리소스를 다운 받은 후 서버에서 받은 새로운 Last-Modified
값으로 갱신
브라우저는 최초 응답 시 받은 Etag
값을 If-None-Match
헤더에 포함시켜 페이지 요청
서버는 요청된 파일의 Etag
값을 If-None-Match
값과 비교하여 동일하 면 '수정되지 않음(304 Not Modified
)'로 응답, 다르면 '수정됨(200 OK
)'과 함께 새로운 ETag
값을 응답 헤더에 넣어 전송
304
(수정 없음)이면 이미 캐쉬된 리소스로 페이지를 로드하고, 200
(수정됨)이면 새로운 리소스를 다운 받은 후 새로운 ETag
값으로 갱신Last-Modified(1.0) 와 ETag(1.1)는 validation을 체크하기 위해 서버와 통신이 한번 발생한다. 이때 통신의 요청과 응답에서 header와 cookie 등에 의한 1KB의 데이터 전송이 발생하게 된다.
Etag는 서버마다 생성하는 값이 다르고, 파일마다 고유한 값을 가진다.
Cache-Control
의 max-age
값을 GMT
와 비교하여 기간이 지나지 않았다면 바로 캐쉬에서 페이지를 로드하고, 기간이 만료되었으면 validation 체크를 한 뒤 새로운 리소스를 다운 받는다.Expires(1.0)와 Cache-Control의 max-age(1.1)는 freshness를 기준으로 캐시 사용 여부를 결정한다. (기간이 지나기 전에는 캐시 사용, 지나면 서버에서 새로 다운)
Expires
와 max-age
에 사용되는 시간은 HTTP date
형태이며 로컬 타임이 아닌 GMT
를 사용한다.
서버는 Last Modified Time
(마지막 수정 시간) 또는 Last Access Time
(마지막 접속 시간)을 기준으로 하여 일정 시간 이후에 Expires
나 max-age
를 설정한다.
Expires
를 비교하여 기간이 지나지 않으면 서버에 통신 요청 없이 바로 캐쉬에서 페이지를 로드하고, 만약 기간이 만료되었다면 validation 작업 이후 새로운 리소스를 다운 받는다.캐쉬 설정법
캐쉬는 서버에서 설정된다. Expires나 Etag는 서버 설정에 의하여 사용하지 않을 수도 있다. Expires의 경우, 설정되면 max-age와 함께 설정된다.