브라우저의 렌더링 과정 (HTTP 캐시와 렌더링 성능 최적화에 관하여)

최윤석·2023년 2월 27일
3

CS

목록 보기
2/3

⭐포스팅 목표

지난 시간에 주소창에 www.naver.com을 입력하고 사용자에게 네이버 페이지가 보이기까지의 과정에 대해 포스팅을 작성하였다.

해당 포스팅의 내용을 바탕으로 F-lab 멘토링을 진행하였고, 전체적인 내용에는 부족함이 없으며 면접 질문에 대한 답변 내용으로 충분하다는 피드백을 받았다.

다만 아쉬운 점이 있다면, DNS 캐시데이터 통신 과정에서의 최적화 방법에 대한 내용에 대해서는 공부했으나 렌더링 과정에서의 최적화 방법에 대해서 내용이 약간 부족했다.

따라서 이번 포스팅에서는 렌더링 과정에 더욱 집중하여 HTTP 캐시, 렌더링 성능 최적화 등 렌더링 과정에 대한 심화 내용을 다룰 예정이다.

기본적인 렌더링 과정은 이전 포스팅을 참고하길 바라며, 이번 포스팅에서는 심화적인 내용에 대해서만 포스팅 하도록 하겠다.




🌏HTTP 캐시

캐시란?

캐시

컴퓨터 과학에서 데이터나 값을 미리 복사해 놓는 임시 장소를 가리킨다. 캐시는 캐시의 접근 시간에 비해 원래 데이터를 접근하는 시간이 오래 걸리는 경우나 값을 다시 계산하는 시간을 절약하고 싶은 경우에 사용한다. 캐시에 데이터를 미리 복사해 놓으면 계산이나 접근 시간없이 더 빠른 속도로 데이터에 접근할 수 있다.
출처 : https://ko.wikipedia.org/wiki/%EC%BA%90%EC%8B%9C

프론트엔드 개발자로서 캐시를 재정의 해보자.

"웹 페이지의 로딩 속도를 높여 사용자 경험을 향상시키고, 네트워크 대역폭을 절약하여 서버 부하를 감소시키는 기술" 정도로 정의할 수 있을 것 같다.

프론트엔드 개발자는 주로 HTTP 캐시를 사용하여 브라우저 캐시를 설정한다.


캐시의 종류

캐시는 크게 private 캐시shared 캐시 두 가지로 나뉜다.

private 캐시

private 캐시는 특정한 클라이언트에 저장되는 캐시를 말한다. (ex. 브라우저 캐시)

다른 클라이언트와 공유되지 않으므로 해당 사용자에 대한 개인화된 응답을 저장할 수 있다.

local 캐시라고도 한다.

shared 캐시

shared 캐시는 클라이언트와 서버 사이에 존재하며, 한 명 이상의 사용자가 공유할 수 있는 응답을 저장하는 캐시를 말한다. (ex. 프록시 캐시)


프론트엔드 개발자가 주로 다루는 캐시는 브라우저 캐시로 해당 포스팅에서도 브라우저 캐시를 위주로 다룬다.

참고자료
MDN Types of caches
https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching#types_of_caches


프론트엔드에서 HTTP 캐시를 사용하는 이유는?

HTTP 캐시를 사용하는 이유는 위에서 내린 정의와 일맥상통하다.

1. 캐시를 사용하여 웹 페이지 로딩 속도를 높이고, 사용자 경험을 향상시킨다.

브라우저 캐시를 사용하면 정적인 파일(HTML, CSS, JS, 이미지 등)들을 브라우저 캐시에 저장하여 사용자가 같은 리소스를 요청할 때, 다시 다운로드 받지 않고 캐시에서 불어올 수 있다.

이는 불필요한 네트워크 요청을 줄이고 웹 페이지 로딩 속도를 향상 시킨다.

2. 캐시를 사용하면 서버의 부하를 줄일 수 있습니다.

같은 리소스에 대해 다시 서버에 요청하지 않고, 캐시된 리소스를 사용하므로 서버와의 네트워크 비용을 줄일 수 있다.

사용자가 많은 경우 효과적으로 서버의 과부하를 줄일 수 있다.


HTTP 캐싱의 대상

일반적으로 GET 요청에 대한 응답만을 캐싱하며, 다른 메서드들은 제외된다.

일반적인 캐싱 엔트리는 다음과 같다.

  • 검색(retrieval) 요청의 성공적인 결과: HTML 문서, 이미지 혹은 파일과 같은 리소스를 포함하는 GET 요청에 대한 200 (OK) 응답.
  • 영구적인 리다이렉트: 301 (Moved Permanently) 응답.
  • 오류 응답: 404 (Not Found) 결과 페이지.
  • 완전하지 않은 결과: 206 (Partial Content) 응답.
  • 캐시 키로 사용하기에 적절한 무언가가 정의된 경우의 GET 이외의 응답.

캐시의 생명 주기

Http Request에 포함된 Cache-Control 헤더에 따라 리소스(HTML, CSS, JS 등)의 생명 주기가 결정된다.

캐시의 유효기간 설정하기

캐시의 유효기간을 설정하는 방법으로는 max-ageExpires가 있다.

max-age=<seconds>는 캐시의 유효 시간을 초 단위로 지정한다.

Expires: <http-date>는 캐시가 유효한 날짜/시간을 지정한다.

max-ageExpires가 둘 다 있는 경우 max-age가 우선된다.

만약 리소스의 유효기간이 지나기 전이라면, 브라우저는 서버에 요청을 보내지 않고 디스크 또는 메모리에서 캐시를 읽어와 사용한다.

max-ageExpires 중 어떤 헤더를 사용해야할까?

max-ageHTTP/1.1 에서 지원하는 헤더로 HTTP/1.0에서는 사용할 수 없다.
Expires는 하위 호환성을 위해 사용되기도 하지만 현재는 HTTP/1.1의 대중화로 거의 사용되지 않는다.

참고자료
MDN Expires or max-age
https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching#expires_or_max-age

캐시의 유효기간이 지난다면 캐시가 삭제되나요?

캐시의 유효기간이 지난 경우에도 캐시는 삭제되지 않는다.

브라우저는 유효기간이 지난 리소스 요청이 발생하면, If-Modified-Since 또는 If-None-Match 요청 헤더를 포함한 HTTP conditional requests를 통해 유효성을 검증한다.

이 과정을 validation 또는 revalidation이라고 한다.

변경사항이 없는 경우, 서버는 304 Not Modified라는 응답을 보낸다.

이 응답은 response body 없이 status code만 포함하므로 매우 작다.

변경사항이 있는 경우, 200 OK 또는 적절한 상태 코드와 함께 최신 리소스를 응답한다.

If-Modified-Since vs If-None-Match

If-Modified-Since

Last-modified 이후 변경사항의 여부에 대해 묻는다.

GET /index.html HTTP/1.1
Host: example.com
Accept: text/html
If-Modified-Since: Tue, 22 Feb 2022 22:00:00 GMT

If-Modified-Since 방식은 시간 형식이 복잡하고 parsing에 어려움이 있으며, 분산 서버에서 파일 업데이트 시간을 동기화하기 어렵다는 단점이 있다.

이러한 문제를 해결하기 위해 ETag 응답 헤더 사용하는 방식을 표준으로 정했다.

If-None-Match

ETag 값의 일치 여부를 확인한다.
ETag는 서버에서 임의로 생성한 값이다.

GET /index.html HTTP/1.1
Host: example.com
Accept: text/html
If-None-Match: "33a64df5"

ETagLast-modified가 모두 존재하는 경우 ETag를 우선 사용한다.

참고자료
MDN validation
https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching#validation

no-cache vs no-store

no-cache

no-cache는 캐시하지만 유효성 검증을 강제한다.

Cache-Control: max-age=0, must-revalidateno-cache와 같은 의미로 사용되지만, HTTP/1.1가 대중화된 지금은 no-cache를 사용해야 한다.

no-store

no-store은 캐시의 저장 자체를 막는다.

브라우저는 어떠한 경우에도 캐시 저장소에 리소스를 저장하지 않는다.

이외에도 다양한 Cache-Control 값이 있으며, 아래 링크를 통해 확인할 수 있다.
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control


캐시의 생명주기가 길면 좋은거 아닌가요?

캐시의 생명주기가 길다면 오랜 기간동안 네트워크 요청 없이 캐시된 리소스를 사용할 수 있다는 장점이 있다.

하지만, 서버의 리소스가 업데이트 되었음에도 불구하고 캐시의 유효기간이 남아있다면 사용자는 업데이트되지 않은 구버전의 리소스를 보게 될 것이다.

이러한 문제를 해결하기 위해 Cache Busting(캐시 무효화) 전략을 사용한다.

Cache Busting (캐시 무효화)

Cache BustingCacheURL을 사용하여 저장된다는 점을 이용한다.

다시 말해, 버전 별로 URL을 변경하여 브라우저가 캐시된 리소스를 사용하지 않고 새로운 리소스를 요청하도록 만든다.

하지만, www.example.com/index.html과 같이 URL이 유지되어야 하는 경우가 있다.

또한, HTML파일의 경우 배포시 내부의 값이 변경될 수 있다.

이처럼 항상 새로운 버전의 배포가 있는지 확인이 필요하거나 해당 리소스가 main 리소스인 경우, max-age=0 또는 no-cache를 적용한다.

Toss 프론트엔드에서는 HTML파일에 max-age=0, s-maxage=31536000를 적용하여 CDN에는 캐시하고 브라우저에는 재검증을 하는 방식을 선택했다.

빌드 될때마다 새롭게 생성되는 JS, CSS 파일 등의 경우에는 최댓값인 max-age=31536000을 설정하여 새롭게 배포가 일어나지 않는다면 캐시의 저장된 리소스를 사용하도록 설정한다.

이때, Cache Busting을 사용하는 방법은 다음과 같다.

  • 파일 이름 버전 관리 (ex. style.v2.css)
  • 파일 경로 버전 관리 (ex. /v2/style.css)
  • 쿼리 문자열 (ex. style.css?ver=2)

참고자료
web.dev HTTP Cache
https://web.dev/i18n/ko/http-cache/#tips
토스 웹캐시 다루기
https://toss.tech/article/smart-web-service-cache
cache-busting
https://www.keycdn.com/support/what-is-cache-busting




🖥️렌더링 성능 최적화

아래 그림은 브라우저의 렌더링 과정(Critical Rendering Path)에 대해 간략하게 표현한 것이다.

브라우저는 위의 그림과 같이 CRA(Critical Rendering Path)에 의해 렌더링이 진행되며 렌더링 성능 최적화란 CRA과정에서 발생하는 웹 페이지 로딩렌더링 최적화를 말한다.

포스팅에서 다루는 방법 외에도 렌더링 성능을 최적화시키는 방법이 있을 수 있지만, 본 포스팅에서는 TOAST UI 성능최적화 아티클을 참조하여 렌더링 성능 최적화에 대해 다룬다.

참고자료
브라우저 동작 원리
https://poiemaweb.com/js-browser


0. 성능 개선 지표

성능의 개선점을 찾기 위해서는 성능 측정에 사용되는 지표를 확인해야 한다.

성능 측정의 기준은 크게 브라우저사용자 기준으로 나뉜다.

해당 내용에 대해서는 TOAST UI 아티클의 링크로 대체한다.

참고자료
성능 개선 지표(브라우저 기준, 사용자 기준)
https://ui.toast.com/fe-guide/ko_PERFORMANCE#%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0-%EC%A7%80%ED%91%9C


1. 로딩 최적화

먼저 로딩 과정에 대한 최적화에 대해 알아보자.

로딩 최적화 방법에는 크게 블록 리소스 최적화, 리소스 용량 줄이기, 리로스 요청 수 줄이기, 캐시 사용 등이 있다.

블록 리소스 최적화

CSSJavaScript는 블록 리소스이다.

따라서 CSSJavaScript 리소스를 최적화 하는 것이 중요하다.

블록 리소스

HTML 파싱이 일어날때 파싱을 블록 시키는 원인이 되는 리소스

CSS 최적화

  1. CSS는 반드시 HTML 문서 최상단에 배치한다.

    렌더 트리를 구성하기 위해서는 DOMCSSOM이 필요하며, CSSOM이 생성되지 않으면 렌더 트리가 생성되지 않아 렌더링이 차단된다.
    따라서 CSS코드는 head태그 내에 위치시켜 렌더링이 차단되지 않도록 한다.

  2. 미디어 쿼리를 적용한다.

    media 속성을 적용하여 해당 스타일을 사용하는 경우에만 로드하도록 한다.
    media 태그를 사용하지 않을 경우 불필요한 블로킹이 발생할 수 있으며, lighthouse에서는 이런 태그를 렌더 블로킹 리소스로 판단한다.

    <link href="style.css" rel="stylesheet" />
    <link href="print.css" rel="stylesheet" media="print" />
    <link href="portrait.css" rel="stylesheet" media="orientation:portrait" />
    <link href="style.css" rel="stylesheet" media="(min-width:320px) and (max-width:768px)">
  3. @import 사용을 지양한다.

    @import는 외부 CSS리소스를 가져오는 것이다.
    웹 브라우저는 @import가 포함된 CSS파일을 다운로드하고 해석한 다음, 가져온 CSS 파일을 다시 다운로드하고 해성해야 한다.
    따라서 @import를 지양하고 <link>를 사용하는 것이 최적화에 유리하다.

JavaScript 최적화

  1. JS는 반드시 HTML 문서 최하단에 배치한다.

    JavaScriptDOM에 접근 및 수정, 삭제가 가능하기 때문에 HTML 파싱 중 <script>태그를 만나면 DOM생성이 중단되고 script코드를 파싱 및 실행한다.
    따라서 <style> 태그는 <body>태그 최하단에 위치시켜 렌더링이 차단되지 않도록 한다.

  2. async, defer 속성을 사용한다.
    async, defer 속성에 대해서는 이전 포스팅을 참고하자.


리소스 용량 줄이기

용량이 큰 리소스는 리소스의 다운로드 시간을 증가시켜 로딩 시간을 증가시킨다.

따라서 불필요한 용량을 줄이는 것이 중요하다.

중복 코드 제거

중복되는 코드의 경우 utils.js 등의 파일에 따로 분리하여 함수로 만들어 사용한다.

만능 유틸 사용 주의

lodash 등 유틸리티 라이브러리 사용시 member style imports 사용을 지양하고 default style imports를 사용한다.

import { merge } from 'lodash' // member style import

import merge from 'lodash/merge' // default style import

member style import를 사용하는 경우 모든 lodash 함수가 포함된 lodash.js파일을 가져와 사용한다.

또한, 사용하지 않는 기능이 많은 라이브러리의 사용을 지양한다.

HTML 마크업 최적화

불필요한 공백, 주석 등을 제거하고 태그의 중첩을 최소화하여 단순하게 구성한다.

간결한 CSS 선택자 사용하기

id선택자 대신 class선택자를 사용하는 등 중복되는 스타일을 최소화할 수 있도록 선택자를 간결하게 사용한다.

압축하여 사용하기

Webpack 플러그인 등을 사용하여 HTML, CSS, JavaScript 파일을 압축하여 사용한다.
불필요한 공백이나 줄바꿈을 제거하여 용량을 줄이고 난독화 시킬 수 있다.


리소스 요청 수 줄이기

요청해야 하는 리소스 파일이 많으면 리소스를 다운받는데 많은 통신 비용과 시간이 필요하다.

따라서, 필요한 요청만 할 수 있도록 리소스 요청 수를 줄이는 것은 로딩 시간 단축에 도움을 준다.

스프라이트 이미지 사용

스프라이트 이미지란 여러 개의 이미지를 하나의 이미지 파일로 합쳐서 관리하는 기술이다.

스프라이트 이미지를 사용하면 네트워크 요청 횟수를 감소 시켜 통신 비용을 줄일 수 있다.

또한, 하나의 이미지만을 캐싱하면 되므로 캐싱 효과가 향상된다.

하지만, 스프라이트 이미지는 유지보수에 어려움이 있으며 이미지를 사용하기 위해 추가적인 작업이 필요하다는 단점이 있다.

HTTP/2.0 프로토콜을 사용하면 Multiplexed Streams을 지원하므로 스프라이트 이미지를 사용하지 않더라도 여러 이미지를 효율적으로 다운로드 할 수 있다.

하지만, HTTP/2.0에서도 스프라이트 이미지를 사용하면 웹 페이지 성능 향상에 도움을 줄 수 있으므로 손익을 따져 사용 여부를 결정해야 한다.

모듈 번들러 사용

webpack과 같은 모듈 번들러를 사용하여 CSS, JavaScript파일을 번들링한다.

이러한 번들러는 여러 개의 모듈 파일을 하나로 묶어 생성하며 이를 번들 파일이라고 한다.

모듈 번들러 사용시 media query 적용

앞에서 css 최적화를 위해 미디어 쿼리를 적용한다고 했다.
하지만, 모듈 번들러를 사용하면 여러 css파일을 하나의 파일로 번들링한다.
따라서 webpack등의 모듈 번들러를 사용할 때에는 media-query-plugin과 같은 플러그인을 사용하여 미디어 쿼리를 적용할 수 있다.

npmjs media-query-plugin
https://www.npmjs.com/package/media-query-plugin

내부 스타일 시트 사용

내부 스타일 시트를 사용하면 리소스 요청을 줄일 수 있다.

다만, 이 경우 CSSHTML 내부에 포함되므로 중복된 스타일에 캐싱의 이점을 활용할 수 없다.

따라서 필요한 경우에만 적절히 사용하도록 해야한다.

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

네트워크 요청을 줄이기 위해 이미지를 Base64로 변환된 URI로 대체하여 사용한다.

단, 이 경우도 동일한 이미지를 다른 곳에서 사용할 경우 캐싱의 이점을 활용할 수 없다.

캐시 사용

HTTP 캐시를 활용하여 리소스를 재사용한다.

본 포스팅의 HTTP 캐시를 참고하자.


2. 렌더링 최적화 (레이아웃 최적화)

웹 페이지를 화면에 렌더링하기 위해서는 DOMCSSOM이 필요하다.

또한, 렌더링 이후 사용자와 상호작용을 통해 다양한 기능을 구현하기 위해서는 JavaScript코드가 필요하다.

따라서 렌더링을 최적화 하기 위해서는 HTML, CSS뿐 아니라 JavaScript를 최적화 해야한다.

레이아웃(Reflow)을 최대한 적게하고 리페인트(Repaint)만 실행하도록 JS, HTML, CSS의 관점에서 알아보자.

자바스크립트 실행 최적화

강제 동기 레이아웃 최적화

레이아웃이 완성되기 이전, 요소의 크기나 위치 등 계산된 값을 속성으로 읽으려고 시도하면 강제 동기 레이아웃이 발생한다.

// 강제 동기 레이아웃이 발생하는 예시
const box = document.querySelectById("box");
box.style.width = 10px;
console.log(box.offsetWidth);
box.style.height = 20px;

한 프레임 내에서 강제 동기 레이아웃이 연속적으로 발생하는 것을 레이아웃 스레싱이라고 하며 레이아웃 스레싱이 발생하지 않도록 코드를 작성해야 한다.

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

DOM트리의 상위 노드를 변경하면 모든 하위 노드에 영향을 미친다.

따라서 DOM트리를 조작할때는 변경 범위를 최소화할 수 있도록 가능한 하위 노드를 조작해야 한다.

영향받는 엘리먼트 제한

  • 부모-자식 관계 : 부모 엘리먼트의 높이가 가변적인 상태에서 자식 엘리먼트의 높이를 변경할 경우, 부모 엘리먼트부터 레이아웃이 다시 일어난다. 이때 부모 엘리먼트의 높이를 고정하여 사용하면 하단에 있는 엘리먼트는 영향을 받지 않게 된다. 예를 들어 높이가 모두 다른 여러 개의 탭 콘텐츠가 있을 때, 부모 엘리먼트(탭 컨테이너)의 높이를 고정하여 사용한다.

  • 같은 위치에 있는 엘리먼트 : 여러 개의 엘리먼트가 인라인(inline)으로 놓여 있을 때 첫 번째 엘리먼트의 width 값 변경으로 인해 나머지 엘리먼트의 위치 변경이 일어나므로 유의한다.

숨겨진 엘리먼트 수정

display : none 속성을 가진 엘리먼트는 DOM조작을 하더라도 리플로우와 리페인트가 발생하지 않는다.


HTML, CSS 최적화

CSS 규칙수 최소화

사용하지 않는 CSS 제거

DOM 깊이 최소화

인라인 스타일 사용 자제


애니메이션 최적화

애니메이션을 구현할 때에는 자바스크립트 API보다 CSS를 사용하여 구현하는 것을 권장한다.

requestAnimationFrame() 사용

requestAnimationFrame을 사용하면 브라우저의 프레임 속도에 맞추어 애니메이션을 실행할 수 있도록 해준다.

아래 예시는 requestAnimationFrame을 사용하여 레이아웃 스레싱을 개선한 예시이다.

개선전

<!DOCTYPE html>
<html>
<head>
  <style>
    .box {
      width: 100px;
      height: 100px;
      background-color: red;
    }
  </style>
</head>
<body>
  <div class="box"></div>
  <script>
    const box = document.querySelector('.box');
    for (let i = 0; i < 100; i++) {
      box.style.left = `${i}px`;
      box.style.top = `${i}px`;
      box.style.width = `${100 - i}px`;
      box.style.height = `${100 - i}px`;
      box.style.backgroundColor = `rgb(${i}, 0, 0)`;
    }
  </script>
</body>
</html>

개선후

<!DOCTYPE html>
<html>
<head>
  <style>
    .box {
      width: 100px;
      height: 100px;
      background-color: red;
    }
  </style>
</head>
<body>
  <div class="box"></div>
  <script>
    const box = document.querySelector('.box');
    const changes = [];
    for (let i = 0; i < 100; i++) {
      changes.push({
        left: `${i}px`,
        top: `${i}px`,
        width: `${100 - i}px`,
        height: `${100 - i}px`,
        backgroundColor: `rgb(${i}, 0, 0)`,
      });
    }
    let current = 0;
    function animate() {
      const change = changes[current];
      box.style.left = change.left;
      box.style.top = change.top;
      box.style.width = change.width;
      box.style.height = change.height;
      box.style.backgroundColor = change.backgroundColor;
      current++;
      if (current < changes.length) {
        requestAnimationFrame(animate);
      }
    }
    animate();
  </script>
</body>
</html>

CSS 애니메이션 사용

CSS 애니메이션을 사용하면 자바스크립트를 실행할 필요 없으며, 브라우저가 애니메이션을 처리하는데 최적화되어 있다.

따라서 CSS 애니메이션을 사용하면 부드러운 애니메이션을 구현할 수 있다.

CSS 애니메이션을 사용할 때는 다음 사항을 지켜야 한다.

1. position : absolute

애니메이션 영역이 주변 영역에 영향을 주지 않도록 absolute 혹은 fixed 값을 설정해야한다.

2. transform 사용

width, height, position 등 기하학적 변화를 유발하는 속성을 변경하면 리플로우가 발생한다.

tranform속성을 사용하면 레이어가 분리되어 레이어 합성만 발생하고 GPU를 사용할 수 있으므로 성능이 빠르다.

따라서 transform : translate()를 사용하여 애니메이션을 구현한다.

참고자료
TOAST UI 성능 최적화
https://ui.toast.com/fe-guide/ko_PERFORMANCE

profile
프론트엔드를 공부하고 있는 주니어 개발자입니다.

0개의 댓글