브라우저 렌더링 원리 — DOM 파싱부터 화면 출력까지
주소창에 URL을 입력하고 엔터를 누르면 화면에 페이지가 그려집니다. 그런데 브라우저는 HTML 텍스트를 어떻게 픽셀로 바꾸는 걸까요?
이 과정을 Critical Rendering Path(중요 렌더링 경로)라고 부릅니다. HTML 파싱부터 화면에 픽셀이 찍히기까지, 브라우저 내부에서 일어나는 단계를 하나씩 따라가 보겠습니다.
브라우저의 주요 구성 요소
브라우저를 하나의 덩어리로 생각하기 쉽지만, 내부에는 역할이 뚜렷하게 나뉜 여러 엔진이 들어 있습니다.
┌────────────────────────────────────────────────┐
│ 브라우저 (Chrome) │
│ ┌──────────────────┐ ┌────────────────────┐ │
│ │ 렌더링 엔진 │ │ 자바스크립트 엔진 │ │
│ │ (Blink) │ │ (V8) │ │
│ │ │ │ │ │
│ │ HTML/CSS 파싱 │ │ JS 코드 실행 │ │
│ │ 레이아웃 계산 │ │ DOM API 호출 │ │
│ │ 화면 그리기 │ │ │ │
│ └──────────────────┘ └────────────────────┘ │
│ ┌──────────────────────────────────────────┐ │
│ │ 네트워크 모듈 / GPU 프로세스 / UI 백엔드 │ │
│ └──────────────────────────────────────────┘ │
└────────────────────────────────────────────────┘
- ** 렌더링 엔진 **: HTML과 CSS를 파싱하고 화면을 그리는 핵심 엔진입니다. Chrome은 Blink, Safari는 WebKit 을 사용합니다.
- **자바스크립트 엔진 **: JS 코드를 실행합니다. Chrome의 V8 이 대표적이고, Safari는 JavaScriptCore를 사용합니다.
- **네트워크 모듈 **: HTTP 요청을 처리합니다.
- **GPU 프로세스 **: 레이어 합성과 하드웨어 가속을 담당합니다.
렌더링 엔진과 JS 엔진은 ** 같은 메인 스레드 **에서 돌아갑니다. 그래서 JS가 오래 실행되면 렌더링이 막히고 화면이 버벅이는 겁니다.
Critical Rendering Path 전체 흐름
브라우저가 HTML을 받아서 화면에 그리기까지의 과정을 한눈에 보면 이렇습니다.
HTML 문서
│
▼
┌──────────┐ ┌──────────┐
│ HTML 파싱 │ │ CSS 파싱 │
│ → DOM 트리 │ │ → CSSOM │
└────┬─────┘ └────┬─────┘
│ │
└───────┬────────┘
▼
┌─────────────┐
│ Render Tree │
│ (DOM + CSSOM)│
└──────┬──────┘
▼
┌─────────────┐
│ Layout │
│ (위치·크기) │
└──────┬──────┘
▼
┌─────────────┐
│ Paint │
│ (픽셀 그리기) │
└──────┬──────┘
▼
┌─────────────┐
│ Composite │
│ (레이어 합성) │
└─────────────┘
▼
화면 출력
각 단계를 하나씩 살펴보겠습니다.
1단계: HTML 파싱 → DOM 트리
브라우저가 서버에서 HTML 문서를 받으면 가장 먼저 ** 파싱 **을 시작합니다. HTML 텍스트를 토큰으로 분해하고, 이 토큰들을 노드로 변환해서 트리 구조로 조립합니다.
<!DOCTYPE html>
<html>
<head>
<title>예제</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>안녕하세요</h1>
<p>브라우저 렌더링 원리</p>
</div>
</body>
</html>
이 HTML이 파싱되면 아래 같은 DOM 트리가 만들어집니다.
document
└── html
├── head
│ ├── title ("예제")
│ └── link (style.css)
└── body
└── div.container
├── h1 ("안녕하세요")
└── p ("브라우저 렌더링 원리")
HTML 파싱은 ** 점진적(incremental)**으로 이루어집니다. 문서 전체를 다 받을 때까지 기다리지 않고, 바이트가 도착하는 대로 파싱을 시작합니다.
2단계: CSS 파싱 → CSSOM 트리
HTML에서 <link> 태그나 <style> 태그를 만나면 CSS를 파싱해서 CSSOM(CSS Object Model) 트리를 만듭니다.
/* style.css */
body { font-size: 16px; }
.container { width: 80%; margin: 0 auto; }
h1 { color: #333; font-size: 2em; }
p { color: #666; line-height: 1.6; }
CSSOM 트리
└── body (font-size: 16px)
└── .container (width: 80%, margin: 0 auto)
├── h1 (color: #333, font-size: 2em, 상속: font-size: 16px)
└── p (color: #666, line-height: 1.6, 상속: font-size: 16px)
공부하다 보니 여기서 중요한 포인트가 있었습니다.
CSS는 렌더 차단 리소스(render-blocking resource) 입니다. CSSOM이 완성되기 전에는 Render Tree를 만들 수 없으므로, CSS 파일이 크거나 로딩이 느리면 첫 화면 출력이 지연됩니다.
그래서 중요한 CSS는 인라인으로 넣거나(<style>), CSS 파일을 최적화하는 게 성능에 직접적인 영향을 줍니다.
3단계: Render Tree 생성
DOM 트리와 CSSOM 트리가 준비되면 이 둘을 결합해서 Render Tree 를 만듭니다.
Render Tree는 실제로 화면에 보이는 노드들만 포함합니다.
DOM 트리 Render Tree
document ┌───────────────────────┐
└── html │ body │
├── head (제외) │ └── div.container │
└── body │ ├── h1 │
└── div.container │ └── p │
├── h1 └───────────────────────┘
├── p
└── span (display:none) ← 제외됨!
Render Tree에서 제외되는 것들:
<head>,<meta>,<script>등 비시각적 요소display: none이 적용된 요소 — ** 공간도 차지하지 않고 완전히 제외**visibility: hidden은? → Render Tree에 ** 포함됩니다 **. 보이지 않지만 공간은 차지하니까요.
// display: none vs visibility: hidden
const box1 = document.querySelector('.box1');
box1.style.display = 'none';
// → Render Tree에서 완전히 제거, 주변 요소 Layout 다시 계산 (Reflow 발생)
const box2 = document.querySelector('.box2');
box2.style.visibility = 'hidden';
// → Render Tree에 남아있음, 공간 유지 (Repaint만 발생)
4단계: Layout (Reflow)
Render Tree가 완성되면 브라우저는 각 노드의 ** 정확한 위치와 크기 **를 계산합니다. 이 과정을 Layout 또는 Reflow 라고 부릅니다.
뷰포트: 1024px × 768px
body
└── div.container (width: 80% → 819.2px, margin: 0 auto → left: 102.4px)
├── h1 (width: 819.2px, height: 44px, top: 0)
└── p (width: 819.2px, height: 25.6px, top: 44px)
Layout은 뷰포트 기준 으로 계산됩니다. %, em, vh 같은 상대 단위는 이 단계에서 모두 픽셀 값으로 변환됩니다.
한 요소의 크기가 바뀌면 그 요소의 자식뿐 아니라 형제, 부모까지 영향을 줄 수 있어서 Layout은 비용이 큰 연산입니다.
5단계: Paint
Layout이 끝나면 각 노드를 실제 픽셀로 그리는 단계입니다. 배경색, 텍스트, 테두리, 그림자 등을 레이어 위에 칠합니다.
Paint는 여러 레이어에 나뉘어 수행될 수 있습니다. 브라우저는 성능을 위해 특정 조건에서 별도 레이어를 만듭니다.
** 별도 레이어가 생성되는 경우:**
transform,opacity에 애니메이션이 있는 요소will-change속성이 지정된 요소position: fixed요소<video>,<canvas>요소
6단계: Composite (합성)
마지막으로 Paint된 여러 레이어를 GPU가 합성 해서 최종 화면을 만듭니다.
레이어 1 (배경) ─┐
레이어 2 (콘텐츠) ├─→ GPU 합성 → 최종 화면
레이어 3 (애니메이션) ─┘
이 단계가 중요한 이유는, transform이나 opacity 같은 속성은 Composite 단계에서만 처리 되기 때문입니다. Layout이나 Paint를 건너뛸 수 있어서 애니메이션 성능이 크게 좋아집니다.
Reflow vs Repaint
DOM이나 스타일이 변경되면 항상 전체 파이프라인을 다시 실행하는 건 아닙니다. 변경된 속성에 따라 어느 단계부터 다시 시작하는지가 달라집니다.
Reflow가 발생하는 경우 (Layout부터 다시)
Layout → Paint → Composite
- 요소의 크기 변경:
width,height,padding,margin,border - 위치 변경:
top,left,right,bottom - DOM 노드 추가/삭제
- 텍스트 내용 변경
window.resize- 폰트 변경
Repaint만 발생하는 경우 (Paint부터 다시)
Paint → Composite
color,background-colorvisibilitybox-shadowoutline
Composite만 발생하는 경우 (GPU에서 처리)
Composite
transformopacity
// 나쁜 예: Reflow 발생 — 매 프레임 Layout 재계산
element.style.left = `${x}px`;
element.style.top = `${y}px`;
// 좋은 예: Composite만 — GPU가 처리
element.style.transform = `translate(${x}px, ${y}px)`;
실무에서 애니메이션 성능이 안 나온다면, 가장 먼저
top/left대신transform을 쓰고 있는지 확인해 보면 좋습니다.
Reflow를 줄이는 방법
Reflow는 비용이 크기 때문에 최소화하는 게 중요합니다. 실제로 적용할 수 있는 방법들을 정리해 봤습니다.
1. DOM 조작을 모아서 한 번에 — DocumentFragment
// 나쁜 예: 반복문마다 Reflow 발생 가능
const list = document.querySelector('.list');
for (let i = 0; i < 100; i++) {
const li = document.createElement('li');
li.textContent = `아이템 ${i}`;
list.appendChild(li); // 매번 DOM 업데이트
}
// 좋은 예: DocumentFragment에 모은 뒤 한 번에 추가
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
const li = document.createElement('li');
li.textContent = `아이템 ${i}`;
fragment.appendChild(li); // 메모리에서만 조작
}
list.appendChild(fragment); // Reflow 1회만 발생
2. 스타일은 클래스 단위로 변경
// 나쁜 예: 스타일을 하나씩 변경 — 여러 번 Reflow 가능
element.style.width = '100px';
element.style.height = '200px';
element.style.marginTop = '10px';
// 좋은 예: 클래스 하나로 한 번에 변경 — Reflow 1회
element.classList.add('active');
.active {
width: 100px;
height: 200px;
margin-top: 10px;
}
3. Layout 정보 읽기를 모아서
offsetHeight, getBoundingClientRect() 같은 속성을 읽으면 브라우저는 최신 Layout을 보장하기 위해 강제 Reflow 를 실행합니다.
// 나쁜 예: 읽기-쓰기가 번갈아 발생 — "Layout Thrashing"
for (const box of boxes) {
const height = box.offsetHeight; // 읽기 → 강제 Reflow
box.style.height = height * 2 + 'px'; // 쓰기 → 다음 읽기에서 또 Reflow
}
// 좋은 예: 읽기를 먼저 다 하고, 쓰기를 나중에
const heights = boxes.map(box => box.offsetHeight); // 읽기 모두 완료
boxes.forEach((box, i) => {
box.style.height = heights[i] * 2 + 'px'; // 쓰기 일괄 처리
});
4. 애니메이션 요소는 레이어 분리
/* will-change로 브라우저에 미리 힌트 */
.animated-element {
will-change: transform;
}
/* 또는 "null transform hack" */
.animated-element {
transform: translateZ(0);
}
will-change를 사용하면 브라우저가 해당 요소를 별도 레이어로 만들어 Composite에서 처리합니다. 단, 남용하면 오히려 메모리를 많이 쓰게 되니 실제로 애니메이션이 적용되는 요소에만 사용해야 합니다.
script 태그의 위치와 defer/async
HTML 파싱 도중 <script> 태그를 만나면 브라우저의 동작이 달라집니다. 이 부분이 Critical Rendering Path에 직접적인 영향을 줍니다.
기본 script 태그
<script src="app.js"></script>
HTML 파싱 ─────█████████████────────────────────────█████████
│ │
▼ │
JS 다운로드 ████ │
│ │
▼ │
JS 실행 ████ │
│ │
└── 파싱 재개 ────────────┘
HTML 파싱이 중단(blocking) 됩니다. 스크립트 다운로드와 실행이 끝나야 파싱이 재개됩니다.
defer
<script src="app.js" defer></script>
HTML 파싱 ████████████████████████████████████████████████████
│ │
▼ ▼
JS 다운로드 ████ JS 실행 ███
(파싱과 병렬) (파싱 완료 후)
│
DOMContentLoaded
- HTML 파싱과 병렬로 다운로드 합니다.
- HTML 파싱이 끝난 뒤, DOMContentLoaded 이벤트 전에 실행됩니다.
- 여러 defer 스크립트가 있으면 ** 문서에 작성된 순서대로** 실행됩니다.
async
<script src="analytics.js" async></script>
HTML 파싱 █████████████████████████████████████████████████████
│ │
▼ ▼
JS 다운로드 ████ JS 실행 ██
(파싱과 병렬) (다운로드 완료 즉시, 파싱 잠시 중단)
- HTML 파싱과 ** 병렬로 다운로드 **합니다.
- 다운로드가 끝나면 ** 즉시 실행 **됩니다 (파싱이 잠시 중단될 수 있음).
- 여러 async 스크립트의 ** 실행 순서는 보장되지 않습니다 **.
어떤 걸 써야 할까?
| 상황 | 권장 방식 |
|---|---|
| DOM을 조작하는 메인 스크립트 | defer |
| 독립적인 서드파티 (GA, 광고) | async |
| DOM에 의존하지 않는 작은 인라인 스크립트 | 기본 <script> |
| 현대 번들러(webpack, vite) 사용 시 | 보통 defer가 기본 적용 |
정리
브라우저 렌더링의 전체 흐름을 다시 요약하면 이렇습니다.
- HTML 파싱 → DOM 트리 생성 (점진적)
- CSS 파싱 → CSSOM 트리 생성 (렌더 차단)
- Render Tree 생성 → DOM + CSSOM 결합 (
display: none제외) - Layout → 각 노드의 위치·크기를 픽셀 단위로 계산
- Paint → 레이어에 픽셀을 그리기
- Composite → GPU가 레이어를 합성해서 화면 출력
성능 최적화의 핵심은 Reflow를 최소화 하는 것입니다. DOM 조작을 모으고, 스타일은 클래스 단위로 바꾸고, 애니메이션에는 transform을 사용하는 습관을 들이면 체감 성능이 달라집니다.
<script> 태그는 렌더링을 차단할 수 있으니 defer/async를 적절히 활용하고, CSS는 빠르게 로드될 수 있도록 관리하는 것도 잊지 마세요.