이번 글에서는 브라우저 렌더링에 대해 자세히 알아보고, 렌더링 최적화 기법에 대해 정리해보았습니다.
0. 브라우저 렌더링
브라우저 렌더링, 다양한 웹 리소스를 최적화 하는 방법을 이해하여 잘 읽힐 뿐 아니라 빠른 웹을 구현해야 합니다.
마크업 작성 시, 불필요한 태그는 제거하고 필요한 태그만 사용해 간단 명료하게 구조화하는 것이 중요합니다.
구조가 복잡하고 태그의 깊이가 깊어질수록 DOM 트리를 생성하는 비용이 많이 듭니다.
1. 브라우저의 구조
구조 그림과 함께 각 구조에 대해 설명
1) 사용자 인터페이스
웹 페이지 표시 영역을 제외한 주소표시줄, 이전/다음 버튼, 새로고침 버튼 등 나머지 부분에 해당됩니다.
2) 브라우저 엔진
사용자 인터페이스와 렌더링 엔진을 연결하며, 사용자가 주소창에 웹 주소 입력 시 네트워킹을 통해 해당 웹사이트 리소스를 요청하고 렌더링 엔진을 통해 요청한 페이지가 나타나도록 제어합니다.
3) 렌더링 엔진
웹 페이지를 표시하며, HTML, CSS를 파싱하여 화면에 렌더링하는 역할을 합니다.
브라우저마자 렌더링 엔진이 다릅니다. (Blink : 크롬, Edge / Webkit : 사파리 / Gecko : 파이어폭스)
4) 네트워킹
브라우저 주소창에 주소 입력 하는 경우, 웹 페이지에서 링크가 있는 요소(메뉴,버튼 등)를 선택한 경우, 이전/다음/새로고침 버튼을 선택한 경우, 스크립트에서 서버 리소스를 요청한 경우와 같은 사용자 액션이 발생한 경우 웹페이지 요청이 발생합니다.
웹페이지 요청 발생 시, 네트워킹이 요청된 웹사이트 도메인 주소를 IP 주소로 변환해주는 DNS(도메인 네임 시스템)을 통해 실제 웹페이지를 제공하는 웹 서버의 IP주소를 찾고 웹 서버에 요청된 페이지를 보내달라는 신호를 보냅니다.
그럼 요청 받은 웹서버가 페이지에 대한 리소스(HTML, CSS, JS, image 등)을 사용자 브라우저로 전송하고 네트워킹은 클라이언트와 서버 간 데이터 통신을 담당합니다.
전송받은 웹페이지 리소스를 렌더링 엔진, 자바스크립트 인터프리터로 파싱하고 최종적으로 웹페이지를 표시합니다.
5) 자바스크립트 인터프리터
인터프리너란 프로그래밍 언어를 한 줄씩 읽어서 실행하는 프로그램으로, 자바스크립트 인터프리터의 경우 런터임 환경에서 기계어를 한 줄씩 번영해 실행합니다.
모든 브라우저는 자바스크립트 엔진을 내장하고 있고, 크롬의 경우 구글의 V8 엔진을 사용합니다. Node.js의 경우 V8엔진을 사용하며 브라우저가 아닌 곳에서도 자바스크립트를 사용할 수 있도록 만들어졌습니다.
작동 방식
렌더링 엔진에서 웹 서버로부터 받은 HTML 파일을 읽다 <script>를 만나면 HTML 파싱 작업을 일시 중단하고 자바스크립트 인터프리터가 해당 script를 해석, 실행 합니다.
6) UI 백엔드
콤보 박스, 체크 박스, 텍스트 박스 등의 UI 위젯을 그리며, 브라우저별로 내장된 스타일의 UI 위젯을 사용합니다.
7) 데이터 스토리지
브라우저 자체에 데이터를 저장할 수 있는 곳으로, 브라우저 개발자 도구의 애플리케이션 탭을 통해 쿠키, 로컬 스토리지, 세션 스토리지, IndexedDB, Web SQL, 캐시 스토리지 등의 데이터 스토리지를 확인할 수 있습니다.
2. 렌더링 과정
(1) 웹 접속 요청
사용자가 웹 주소 입력 및 웹 사이트 접속을 요청합니다.
(2) 웹 서버의 HTMP 파일 전송
웹 서버는 사용자, 즉 클라이언트가 요청한 웹사이트의 실제 페이지인 HTML 파일을 전송합니다.
(3) DOM 트리 생성
브라우저 렌더링 엔진이 HTML 파일을 파싱하며 DOM 트리를 생성 합니다.
(4) CSSOM 트리 생성
HTML 파싱 중 CSS 인식 시 렌더링 잠시 중단 (HTML 파싱을 계속 진행) 하고 CSS 파싱 후 CSSOM 트리를 생성합니다. 이 때문에 CSS 를 렌더링 차단 리소스 (render block resource) 라고도 합니다.
DOM 트리와 CSSOM 트리의 변환 과정
Bytes -> Characters -> Tokens -> Nodes -> CSSOM
(5) 자바스크립트 컴파일레이션
계속해서 HTML 파싱하다 script 파일 인식 시, HTML 파싱을 잠시 중단 후 자바스크립트 인터프리터가 자바스크립트를 해석하고 실행 (컴파일레이션) 합니다. 자바스크립트를 파서 차단 리소스 (parser blocking resource) 라고도 합니다.
※ 자바스크립트 파일은 렌더링 엔진이 아닌, 자바스크립트 인터프리터에 의해 해석되고 실행됩니다.
컴파일의 3단계
1) 코드를 의미 있는 조각으로 나누는 렉싱/토크나이징 (이 때 스코프가 결정 됩니다.
2) 코드를 트리 구조로 나타내는 추상 구문 트리(abstract syntax tree, AST)로 만드는 파싱
3) AST 트리를 바탕으로 바이트 코드로 변환
AST란?
프로그래밍 언어 문법에 따라 소스 코드 구조를 표시하는 계층적인 프로그램 표현으로, AST Explorer 에서 AST가 어떤 모양으로 생성되는지 확인 가능 합니다.(JSON 형태로도 확인 가능합니다)
AST가 만들어 지는 과정
자바스크립트 코드는 컴파일러의 lexcial analysis (어휘 분석)과 systax analysis (구문 분석)을 통해 AST로 변환 됩니다. 어휘분석기가 정의된 규칙에 따라 공백, 주석을 제거하며 코드를 읽고 토큰 목록으로 분할하고, 분할된 토큰 목록은 구문 분석기(parser)가 코드 구문을 검증하고 트리 구조로 변환하게 됩니다. (구문 오류 시 에러 표시)
(6) 렌더 트리 생성
다시 HTML을 파싱해 DOM트리와 CSSOM 트리를 합쳐 렌더트리를 생성합니다. DOM 트리에는 display:none; 된 HTML 요소가 존재하나, 렌더 트리에는 존재하지 않게 됩니다.
(7) 레이아웃 단계
레이아웃 단계에서 렌더 트리 화면 배치를 위한 절대적인 픽셀 값이 계산 됩니다. CSS에서 크기나 위치를 %로 정의했어도 모두 절대적인 픽셀로 계산되며, 레이아웃 성능은 DOM에 영향을 받으므로, 불필요한 HTML 요소를 제거하는 것이 중요합니다.
(8) 페인팅 단계
페인팅 단계에서 화면에 렌더트리의 각 노드를 화면에 실제 픽셀로 변환하게 됩니다. 픽셀 변환 결과를 바탕으로 여러개의 레이어 ( 렌더링 시 페인트할 대상 영역을 나눈 것)가 생성 되며, 레이어는 개발자 도구 Layers 탭에서 확인 가능합니다. 페인팅 단계에서는 페인팅 레코드(렌더링 순서를 기록한 정보)가 생성됩니다.
(9) 컴포지션 단계
페인트 단계에서 생성된 레이어를 페인트 레코드 순서에 맞게 브라우저에 픽셀로 그리고, 나누었던 레이어들을 합성해 최종화면을 사용자에게 보여줍니다.
(10) 리플로우 & 리페인트
사용자가 웹페이지에 처음 접속하면 렌더링 과정을 거쳐 화면에 모든 요소가 그려지는데, 이후 사용자의 액션에 따라 발생하는 이벤트로 새로운 기존 요소에 변경이 발생될 때 리플로우나 리페인트가 이루어지게 됩니다.
리플로우의 경우 이미 생성된 DOM 요소에 대한 수치가 변경되는 경우 영향을 받는 노드들(자신, 부모, 자식 등)의 너비, 높이, 위치 등과 같은 레이아웃 수치를 재계산해 렌더 트리 생성과 레이아웃 과정을 다시 수행하는 것을 의미합니다.
리플로우가 일어나는 대표 속성
width, height, margin, padding, border, display, position, top, left, right, bottom, float, overflow, font-size, font-family, font-weight, line-height, text-align, vertical-align, white-space, min-height, min-width, max-height, max-width, flex, grid, transform
리페인트는 이러한 리플로우의 결과를 화면에 그리기 위해서 일어나는 과정을 의미합니다. 리플로우 발생 시 반드시 리페인트가 발생됩니다. 이 과정은 개발자도구 Performance 탭 Event Log에서 확인할 수 있습니다. 레이아웃에 영향을 미치지 않는 단순한 색상 변경에 대해선 리페인트만 수행됩니다.
리페인트가 일어나는 대표 속성
color, background, background-color, background-image, border-color, border-style, visibility, outline, opacity, box-shadow, text-shadow, filter
3. 렌더링 최적화
CRP를 최적화하면 최초 렌더링 시간이 단축되고, 초당 60프레임으로 리플로우, 리페인트되도록 보장해 버벅거림을 방지할 수 있습니다. 웹 성능에는 서버 요청 및 응답, 로딩, 스크립팅, 렌더링, 레이아웃, 페인팅과 관련됩니다. 이와 관련해 렌더링 최적화 방법을 w정리해보았습니다.
※ CRP(Critical Rendering Path) : 브라우저가 HTML,CSS, JS를 스크린의 픽셀로 변환하는 순서
1) HTML 마크업 최적화
불필요한 Wrapper 요소를 제거하고 HTML 태그의 중첩을 최소화하여 단순하게 구성해야 합니다.
2) 사용되지 않는 CSS 스타일 선언 제거
크롬 개발자 도구에서 CSS Overview 탭에서 Capture Overview 버튼을 클릭하면 현재 웹페이지에 대한 HTML 요소, 색상, 폰트. 미디어쿼리 등의 정보를 확인할 수 있습니다. 이중 Unused declarations 메뉴를 통해 정의는 했으나 사용되지 않은 CSS 선언을 확인할 수 있습니다.
3) CSS 파싱 시점 분리
CSS는 렌더링 차단 리소스로, CSS를 파싱해 CSSOM을 만드는 동안에 렌더링이 일어나지 않게 합니다. CSS 삽입 시 사용하는 link태그에는 media 라는 속성이 있는데, CSS파일을 어떤 조건에서 적용할지 결정합니다.
속성값이 print일 경우 사용자가 웹 페이지 인쇄를 위한 프린트 버튼을 클릭했을 때만 적용되는 것임을 의미합니다. 이는 곧 최초 렌더링 시 페이지 렌더링을 차단하지 않는 CSS가 됩니다.
이 외에도 CSS 삽입 시, 프린트, 스마트폰 크기, 태블릿 크기, 가로 모드, 세로 모드 등 media 속성을 적절하게 사용해 초기 렌더링 속도를 높일 수 있습니다.
<!-- 모든 환경에서 사용되는 기본 스타일 -->
<link rel="stylesheet" href="base.css">
<!-- 인쇄 시에만 적용되는 스타일 -->
<link rel="stylesheet" href="print.css" media="print">
<!-- 스마트폰 크기에서 적용되는 스타일 -->
<link rel="stylesheet" href="mobile.css" media="screen and (max-width: 767px)">
<!-- 태블릿 크기에서 적용되는 스타일 -->
<link rel="stylesheet" href="tablet.css" media="screen and (min-width: 768px) and (max-width: 1023px)">
<!-- 가로 모드에서만 적용되는 스타일 -->
<link rel="stylesheet" href="landscape.css" media="screen and (orientation: landscape)">
<!-- 세로 모드에서만 적용되는 스타일 -->
<link rel="stylesheet" href="portrait.css" media="screen and (orientation: portrait)">
크롬 개발자 도구 Performance insights 탭에서 현재 페이지의 렌더링 차단 요청이 몇 번 일어나고, 어떤 리소스를 요청할 때 일어나는지, DOM이 로드되는데 걸리는 시간을 확인할 수 있습니다.
4) 파서 차단 자바스크립트 삭제
자바스크립트는 파서 차단 리소스로, 브라우저가 HTML 마크업을 파싱해 DOM을 빌드해야하는데, 이를 처리하는 동안 파서에서는 자바스크립트를 발견할 때마다 파싱을 중지하고 자바스크립트를 실행합니다. 그 후 HTML을 계속 파싱합니다.
HTML 페이지에 삽입된 외부 JS는 파서에 리소스가 다운로드 될 때까지 기다려야 하고, 외부 리소스 수만큼 네트워크 요청에 대한 왕복 시간이 소요되기 때문에 첫 페이지 렌더링 시간이 지연될 수 있습니다. 따라서, 첫 페이지 렌더링 시 필요하지 않은 자바스크립트는 비동기로 가져오거나, 첫 렌더링 완료 시까지 대기시켰다가 가져오도록 처리하는 것이 좋습니다.
첫 렌더링 시 스크립트 크기가 작고 여러 페이지에 반복적으로 사용되지 않을 땐, body 태그 앞쪽에 script 태그를 추가해도 되지만, 자바스크립트로 인해 파서가 차단되지 않도록 하려면 삽입되는 외부 스크립트 파일에 아래와 같이 async 속성을 사용해 비동기처리되도록 하는 것이 좋습니다.
<script async src="script.js"></script>
하지만 비동기 스크립트는 코드 나열 순으로 실행된다는 보장이 없으므로, 실행 순서가 중요한 스크립트의 경우엔 사용해선 안됩니다.
5) HTTP 요청 최소화
웹 페이지 자원 중 CSS, JS, image 파일은 삽입 되는 수 만큼 네트워크 요청이 일어나 응답 시간에 영향을 미칩니다. 이러한 HTTP 요청을 최소화 하는 것만으로도 성능 개선이 가능합니다.
방법 1) 내부 스타일 시트 사용
style 규칙이 적을 경우, <link> 태그로 외부 스타일 시트를 사용하는 대신 <style> 태그 사용하기
방법 2 ) 외부 CSS와 JS 파일 결합하기
웹팩 (webpack)을 사용하면 각각의 CSS와 JS 파일을 압축하고 여러 파일을 결합해서 하나의 파일로 생성할 수 있습니다.
웹팩 기본 설정 예시
// webpack.config.js 예시
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
optimization: {
minimize: true,
minimizer: [new TerserPlugin()],
},
plugins: [
new MiniCssExtractPlugin({
filename: 'styles.css',
}),
],
module: {
rules: [
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader'],
},
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
},
},
],
},
};
방법 3) 이미지 스프라이트 사용
이미지 스프라이트는 웹페이지에 사용하는 다수의 이미지를 한 장의 이미지로 만들고, 스타일 시트에서 background-position 속성을 설정해 필요한 부분의 이미지만 보여주는 기법 입니다.
/* 스프라이트 이미지 예시 */
.icon {
background-image: url('sprites.png');
width: 24px;
height: 24px;
display: inline-block;
}
.icon-home {
background-position: 0 0;
}
.icon-search {
background-position: -24px 0;
}
.icon-settings {
background-position: -48px 0;
}
.icon-user {
background-position: -72px 0;
}
<!-- HTML에서 사용 예시 -->
<a href="#"><span class="icon icon-home"></span> 홈</a>
<a href="#"><span class="icon icon-search"></span> 검색</a>
<a href="#"><span class="icon icon-settings"></span> 설정</a>
<a href="#"><span class="icon icon-user"></span> 사용자</a>
방법 4) gzip 압축을 이용해 파일 크기 최소화
웹 서버에서 파일을 압축하는 대표적인 인코딩 방식으로는 gzip이 있으며, gzip으로 압축 전송 시 파일 크기를 평균 70% 줄일 수 있습니다.
gzip 압축 서버 설정 예시 (Apache)
# Apache .htaccess 파일 예시
<IfModule mod_deflate.c>
# 압축할 파일 유형 지정
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript application/javascript application/json application/xml
# 이미 압축된 콘텐츠는 다시 압축하지 않음
SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png|zip|gz|bz2|rar)$ no-gzip dont-vary
# 프록시 서버가 압축된 콘텐츠를 캐시하도록 설정
Header append Vary User-Agent env=!dont-vary
</IfModule>
Nginx gzip 설정 예시
# Nginx 설정 파일 예시
server {
listen 80;
server_name example.com;
# gzip 활성화
gzip on;
gzip_disable "msie6";
# 압축 레벨 설정 (1-9, 높을수록 더 많이 압축되지만 CPU 사용량 증가)
gzip_comp_level 6;
# 압축할 최소 파일 크기 (1k 이상인 파일만 압축)
gzip_min_length 1000;
# 압축할 파일 유형
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
}
방법 5) CRP 성능 측정
개발자 도구의 성능 탭을 통해 CPR 성능을 측정해봅니다. 아래 관련 용어를 정리해보았으니 참고해서 보시기 바랍니다.
1. Send Request : index.html 요청
2. Parse HTML & Send Request : HTML을 파싱해서 DOM 트리 생성. style.css와 main.js 요청
3. Parse Stylesheet : style.css 에 대한 CSSOM 생성
4. Evaluate Script : main.js 해석
5. Layout : 뷰포트 기준으로 레이아웃 계산
6. Paint : 픽셀로 페인팅
더 알고 가기
가상 DOM과 리액트, 뷰
DOM 변경이 일어날 때마다 브라우저는 렌더링 과정을 거치므로, 복잡한 화면에서 DOM 변경 발생 시 화면을 다시 그리는 과정에서 시간이 소요되거나 버벅거림이 발생할 수 있습니다. 이를 보완하기 위해 나온 것이 가상 DOM (virtual DOM) 입니다.
브라우저 렌더링 과정에서 가장 많은 비용이 드는 단계는 레이아웃 단계와 페인팅 단계이며, 최초 렌더링 이후 자바스크립트로 DOM을 조작하면 리플로우와 리페인트가 수행됩니다. 변경 사항이 많을 수록 많은 리플로우와 리페인트가 수행되고 화면이 느려지게 됩니다.
가상 DOM은 메모리에 실제 DOM과 동일한 DOM 구조를 만들어서 사용하는 방식으로 가상 DOM에서는 실제 렌더링이 일어나지 않아 DOM 조작이 필요한 경우 일단 가상 DOM에 반영하고 변경 사항을 묶어 실제 DOM에 반영함으로써 DOM 조작 연산 비용을 훨씬 적게 줄일 수 있습니다.
가상 DOM 작동 방식
실제 DOM 조작 과정:
1. 변경 필요 → 2. DOM 직접 수정 → 3. 리플로우 발생 → 4. 리페인트 발생
가상 DOM 조작 과정:
1. 변경 필요 → 2. 가상 DOM에 변경 적용 → 3. 실제 DOM과 비교(Diffing) →
4. 변경된 부분만 실제 DOM에 한 번에 적용 → 5. 최소한의 리플로우와 리페인트 발생
리액트와 뷰 가상 DOM 간단 비교
이러한 가상 DOM을 사용하는 대표적인 프론트엔드 개발 도구로 리액트와 뷰가 있습니다.
리액트(React)의 가상 DOM:
- JSX를 사용하여 UI 컴포넌트 정의
- setState나 hooks를 통한 상태 변경 시 가상 DOM 업데이트
- Reconciliation 알고리즘으로 실제 DOM과 비교하여 차이점만 업데이트
- 단방향 데이터 흐름 (부모에서 자식으로만 데이터 전달)
뷰(Vue)의 가상 DOM:
- Template 또는 JSX로 UI 컴포넌트 정의 가능
- 반응형 시스템으로 데이터 변경 감지 및 가상 DOM 업데이트
- 양방향 바인딩 지원 (v-model)
- 더 작은 크기와 더 빠른 초기 렌더링 성능