이번 글에서는 브라우저의 렌더링 과정을 어떻게 최적화하는지 이야기하겠습니다. 앞서 말씀드렸던 주요 렌더링 경로(Critical Rendering Path)
를 위주로 설명하게 될테니, 이전 포스팅
을 참고해주세요.
주요 렌더링 경로(Critical Rendering Path)
라 불리는 위 과정은 브라우저가 초기에 화면을 출력할 때 꼭 거쳐야 하는 순서입니다. 때문에, 주요 렌더링 경로를 최적화
하는 것은 브라우저의 초기 출력을 빠르게
한다는 말과도 같습니다. 이를 위해서는 HTML, CSS 그리고 JavaScript간의 종속성을 이해해야 합니다.
DOM 트리
는 파싱 중에 태그를 발견할 때마다 순차적으로 구성할 수 있지만, CSSOM 트리
는 CSS를 모두 해석해야 구성할 수 있습니다. 즉, CSSOM 트리가 구성되지 않으면 렌더 트리를 만들지 못하고 렌더링이 차단
된다는 얘기죠. 이런 이유로 저번 포스팅에서 CSS는 렌더링을 방해하는 요소
라고 표현했습니다. 정확히 말하자면, 브라우저 로딩 초기 단계에서 HTML 파싱이 일어날 때 CSS를 불러오는 태그를 만나면 HTML 파싱이 중단
됩니다. 그리고 CSS 파일을 불러와 파싱하고나면 다시 HTML 파싱을 재게하죠. 그렇기 때문에 우리는 렌더링이 차단되지 않도록 CSS는 항상 HTML 문서 최상단
(head 태그 아래)에 배치했던 것입니다.
이렇게 HTML 파싱을 방해하느 요소들을 렌더링 차단 리소스(Render Blocking Resource)
라 명합니다. 차단 리소스
는 브라우저 로딩 단계 중 페인트 과정을 지연
시키므로, 차단 리소스가 HTML 파싱을 막는 상황이 발생하지 않도록 해야 합니다. 구글에서는 주요 렌더링 경로(Critical Rendering Path)를 최적화하면 페인팅을 빠르게 하고 로딩 속도를 개선할 수 있다고 설명합니다.
그럼 과연 어떤 방식으로 최적화
가 이루어지는지 요소별로 살펴보겠습니다.
CSS는 렌더링 차단 리소스
이기 때문에, 최초 렌더링에 걸리는 시간을 최적화하려면CSS를 간단하게
만들고, 클라이언트에 최대한빠르게 다운로드
될 수 있도록 해야 합니다.
미디어 쿼리
를 사용하여 일부 CSS 리소스를 렌더링 비차단 리소스
로 표시하면 불필요한 렌더링 방해를 줄일 수 있습니다.
페이지가 인쇄될 때나 대형 모니터에 출력하는 경우 등 몇 가지 특수한 경우에만 사용되는 CSS
가 있다면, 해당 CSS가 렌더링을 차단하지 않는 것이 좋습니다. 이 경우에 미디어 쿼리
를 사용면 CSS 리소스를 렌더링 비차단 리소스로 표시
할 수 있습니다.
<link href="style1.css" rel="stylesheet">
스타일시트를 위와 같이 선언하면 미디어 유형이 따로 제공되지 않았기에 모든 경우에 적용되는 스타일입니다. 즉, 브라우저의 렌더링을 항상 방해
합니다.
<link href="style2.css" rel="stylesheet" media="print">
<link href="style3.css" rel="stylesheet" media="orientation: landscape">
<link href="style4.css" rel="stylesheet" media="min-width: 768px">
만약 위의 선언들처럼 태그 안에 미디어 쿼리
를 표시하는 Props를 추가한다면 브라우저는 해당 스타일시트가 적용되는 미디어 유형
을 인식하고, 그에 해당되는 경우에만 스타일을 적용하기 때문에 렌더링을 방해하지 않습니다.
style2.css
는 콘텐츠가 인쇄될 경우(media="print")에만 적용되기 때문에 처음 로딩 당시에 파싱되지 않고 렌더링을 방해하지 않습니다. 마찬가지로 style3.css
는 전자기기의 화면이 가로 형태(media="orientation: landscape")일 때, style4.css
는 기기 화면 사이즈가 768px 이상일 경우(media="min-width: 768px")에만 렌더링을 차단하기에 비차단 리소스
입니다.
<link href="style1.css" rel="stylesheet" media="all">
그렇다면 위와 같은 코드는 어떨까요?
미디어 쿼리값이 'all'
일 경우, 모든 상황에서 렌더링 되기 때문에 이는 브라우저 로딩 초기에 파싱이 진행됩니다. 그렇기에 위와 같은 경우는 렌더링 차단 리소스
입니다.
/* foo.css */
@import url("bar.css")
@import
를 이용한 외부에서 CSS 정보를 참조
는 가급적 피하는게 렌더링 최적화에 도움 됩니다. 위와 같이 @import를 사용했을 때 브라우저는 스타일시트를 병렬로 다운로드 할 수 없기 때문에 로드 시간이 늘어날 수 있습니다.
이 처럼 CSS가 HTML 파싱을 방해하여 렌더링 지연
을 일으키는 것을 막기 위해서는 사용성이 적은 스타일시트는 가급적 미디어 쿼리 프롭스를 추가하고 외부 스타일시트 사용을 줄이는게 좋습니다.
엘리먼트의 클래스를 변경하는 경우 렌더링이 발생하는데, CSS가 복잡하고 많을수록 스타일 계산과 레이아웃이 오래 걸립니다. 때문에 스타일을 적용할 때는 간결한 CSS 선택자
를 사용해 최적화 해야합니다. 선택자 사용을 최소화하는 방법으로는 id 대신 class 선택자를 사용
하여 중복되는 스타일을 묶어서 처리할 수 있습니다. 이렇게 하면 사용하는 규칙이 적어 계산이 빠르므로 최적화가 이루어집니다.
기본적으로, 자바스크립트도 CSS 처럼 렌더링 차단 리소스
입니다. 프로젝트에서 자바스크립트를 사용하면 콘텐츠, 스타일, 사용자와의 상호작용 등 거의 모든 것을 수정할 수 있죠. 그렇기 때문에 자바스크립트 실행은 DOM 생성을 차단하고 페이지 렌더링을 지연
시킬 수도 있게 됩니다.
따라서 렌더링을 최적화하기 위해서는
자바스크립트를 비동기로 설정
하고,주요 렌더링 경로(Critical Rendering Path)에서 불필요한 자바스크립트를 제거
해야 합니다.
비동기 설정
방법을 이야기하기 전에 비동기 설정을 해야 하는 이유인 자바스크립트의 종속성
에 관해 이야기하도록 하겠습니다.
<head>
<style>
div {
display: none
}
</style>
</head>
<body>
<div>visible</div>
<script>
const div = document.getElementsByClassName('div')[0];
div.style.display = 'inline';
</script>
</body>
자바스크립트를 사용하면 DOM과 CSSOM 노드에 접근하여 조작
할 수 있는 강력한 기능과 유연성을 보여줍니다. 숨겨진 노드(style="display: none")는 렌더 트리에 표시되지 않지만 DOM 트리에는 존재하기 때문에 쉽게 접근할 수 있습니다. 따라서 위와 같이 display 속성을 none에서 inline으로 변경할 수도 있습니다. 뿐만 아니라 자바스크립트는 DOM에 새로운 노드를 추가, 제거, 수정
할 수도 있습니다.
다음과 같은 기능을 구현하기 위해, HTML 파서는 <script> 태그를 만나면 DOM 생성 프로세스를 중지하고 자바스크립트 엔진에 권한을 넘깁니다.
이후 자바스크립트 엔진의 실행이 완료되면 브라우저가 중지했던 시점부터 DOM 생성을 다시 시작합니다. 또한, 인라인 스크립트를 실행하면 DOM 생성이 차단
되고, 이로 인해 초기 렌더링
도 지연됩니다.
이러한 이유로 인하여 자바스크립트
는 화면에 그려지는 태그들이 모두 파싱 된 후인, body 태그를 닫기 직전에 script 태그를 선언하는 것이 좋습니다.
(인라인 스크립트뿐만 태그를 통해 외부에서 불러온 자바스크립트 역시 파싱을 중지시킵니다. 차이점이 있다면, 외부에서 불러올 경우, 서버에서 자바스크립트를 가져올 때까지 기다려야 합니다. 이로 인해 약간의 지연이 추가로 발생할 수 있습니다.)
CSS를 파싱 하는 동안 자바스크립트에서 스타일 정보를 요청하는 경우, CSS가 파싱이 끝나지 않은 상태라면 자바스크립트 오류가 발생할 수 있습니다. CSS 파싱으로 생성되는 CSSOM과 JavaScript에서 스타일 수정 시 발생하는 CSSOM 수정 사이에 경쟁 조건(Race Condition)
이 발생합니다.
브라우저
는 이 문제를 해결하기 위해 CSSOM을 생성하는 작업이 완료할 때까지 자바스크립트 실행 및 DOM 생성을 지연
시킵니다. 바로 이러한 종속성
때문에 브라우저가 화면에 페이지를 렌더링 할 때 상당한 지연
이 발생하는 것이죠.
브라우저는 자바스크립트가 페이지에서 무엇을 수행할지 모르기 때문에 최악의 시나리오를 가정하고 파서를 차단합니다. 만약 브라우저에 자바스크립트를 바로 실행할 필요가 없음을 알려준다면, 브라우저는 계속해서 DOM을 생성할 수 있고 DOM 생성이 끝난 후에 자바스크립트를 실행할 수 있게 될 것입니다.
이때 사용할 수 있는 것이 비동기 자바스크립트
입니다.
<head>
<script src="app.js" async></script>
</head>
<body>
<div>
...
</div>
</body>
위의 코드와 같이 단순히 script 태그에 async 속성
을 추가해 주면 됩니다. async 속성을 script 태그에 추가하여 자바스크립트가 사용 가능해질 때까지 브라우저에게 DOM 생성을 중지하지 말라고 지시
하는 것입니다. 이를 통해 가볍게 자바스크립트와 HTML, CSS의 종속성을 무시
할 수 있습니다.
이는 async 대신 defer 속성
을 써도 가능합니다. 다만 둘의 차이는, async는 스크립트가 다운로드 됐을 때 곧바로 평가 실행하고, defer는 HTML을 완전히 다 읽은 후에 실행한다는 것입니다. 다시 말해, async는 먼저 다운로드 된 순서대로, defer는 정의된 순서대로 실행
한다는 것이죠.
브라우저는 모든 리소스를 똑같은 중요도로 취급하지 않습니다. 브라우저는 중요한 리소스(스크립트나 이미지보다 CSS 우선)를 우선 로드
하기 위해 각 리소스의 중요도를 추측
하여 먼저 로딩합니다. 하지만 이러한 추측이 우리가 생각하는 중요도와 항상 일치하지는 않습니다. 브라우저에게 리소스의 우선순위를 전달
해 주는 방법에 관해 이야기하도록 하겠습니다.
<link rel="preload" as="script" href="script.js">
<link rel="preload" as="style" href="style.css">
preload
는 현재 페이지에서 빠르게 가져와야 하는 리소스에 사용됩니다. 이는 브라우저에게 현재 리소스가 필요하며, 가능한 한 빨리 가져오기를 시도해야 한다고 알립니다.
preload 사용시 유의할 점
은 as 속성을 사용하여 리소스의 유형
을 알려줘야 합니다. 브라우저는 올바른 유형이 설정되어 있지 않으면 미리 가져온 리소스를 사용하지 않습니다. 또한 preload 속성을 부여하면 브라우저가 반드시 리소스를 가져오기 때문에, 리소스를 두 번 가져오게 하거나, 필요하지 않은 것을 가져오지 않도록 주의
해서 사용해야 합니다.
<link rel="prefetch" href="index2.html">
prefatch
는 미래에 필요할 수 있는 리소스를 가져와야 할 때 사용되는 속성입니다. 이는 현재 페이지 로딩이 마치고 다운로드할 여유가 생겼을 때 가장 낮은 우선순위
로 리소스를 가져옵니다.
prefetch는 사용자가 다음에 할 행동을 미리 준비
합니다. 예를 들어, 현재 페이지가 index1 이라면, 위의 코드와 같이 사용하여 index2를 먼저 가져와 준비합니다. 주의할 점
은 위의 코드와 같이 사용하였더라도 index2의 HTML만 가져왔지 index2에 필요한 리소스는 가져오지 않는다
는 것입니다.
DOM 트리가 깊을수록, 하나의 노드에 자식 노드가 많을수록 DOM 트리는 커집니다. 그 만큼 DOM을 변경했을 때 업데이트에 필요한 계산도 많아지겠죠. 따라서, 불필요한 엘리먼트들을 최소화
하면 DOM의 깊이는 얕아지고 계산이 빨라질 것입니다.
브라우저는 CSS 애니메이션
을 처리하기 위한 성능 최적화가 더 잘 되어있습니다. 더불어 transform 속성
을 이용하면 GPU를 이용한 CSS 애니메이션을 하게되어 JS로 진행되는 애니메이션 실행 보다 훨씬 더 좋은 성능을 보일 수 있다.
requestAnimationFrame
을 이용하면 프레임 시작과 맞춰서 JS를 실행하기 때문에 렌더 프레임에 영향을 주지않고 UI를 변경
하게 된다.
DOM변화를 일일이 적용하기보다는, DocumentFragment
를 사용해서 한번에 변화시키는게 계산이 덜 필요하다.
Webpack과 같은 번들러
를 사용하여 CSS와 자바스크립트 파일 요청을 줄일 수 있습니다. 번들러는 여러 개의 모듈 파일을 하나로 묶어서 1개의 파일로 생성해주는데 이것을 번들 파일
이라고 합니다. 이 번들 파일을 사용하여 리소스 요청을 줄일 수 있습니다.
(모듈과 번들에 관해서는 따로 포스팅해서 설명하겠습니다.)
엘리먼트가 display: none
스타일을 가지고 있으면 DOM 조작과 스타일을 변경하더라도 레이아웃과 리페인트가 발생하지 않습니다.
많은 수의 엘리먼트를 변경해야 할 경우 숨겨진 상태에서 엘리먼트를 변경하고 다시 보이도록 하여 레이아웃 발생을 최대한 줄일 수 있습니다. visibility: hidden
은 보이지 않아 리페인트는 발생하지 않지만, 공간을 차지하기 때문에 레이아웃은 발생
하게 됩니다.
이번 포스트에서는 브라우저의 렌더링 과정을 어떻게 하면 최적화시킬 수 있는지
에 대해 알아보았습니다. 사실 오늘 다룬 내용이 렌더칭 최적화의 전부는 아닙니다. 이 방법들 외에도 다양한 라이브러리나 번들러, 압축
등을 이용하면 훨씬 성능을 높일 수 있습니다. 또한 리액트와 같은 라이브러리나 프레임워크를 사용한다면, 그 환경에 맞는 최적화 전략을 구상할 수 있습니다.
프론트엔드 영역은 근 몇년 간 급격한 변화와 발전이 있었다고 하죠. 과거에는 단순히 정보를 보여주기만 하는 웹사이트였다면, 최근에는 그에 그치지 않고 컨텐츠를 수정하고, 게임을 하고, 또 프로젝트 일정을 관리하는 등 많은 상호작용들을 웹에서 처리하고 있습니다. 이에 맞게 개발자분들도 유저들에게 조금이라도 더 좋은 사용자 경험을 줄 수 있도록 프론트엔드 성능 개선하는데 노력을 다하죠.
프로젝트에 알맞게 웹 페이지 로딩과 렌더링을 잘 최적화 한다면, 유저들이 만족할 수 있는 사용자 경험을 보여줄 수 있을 것입니다!