브라우저는 어떻게 HTML을 화면에 렌더링할까?
웹 페이지는 HTML, CSS, JavaScript로 구성된다.
그런데 이걸 브라우저는 어떻게 받아서 사용자 눈앞에 ‘화면’으로 보여줄 수 있을까?
1. HTML 수신 (Byte Stream)
브라우저는 서버로부터 HTML 파일을 바이트 스트림(byte stream) 형태로 전달받는다.
즉, 0과 1로 이루어진 데이터 덩어리를 먼저 받는다.
예: 0100111001101010...
2. 문자 인코딩 (Decode)
받은 바이트 스트림을 사람이 읽을 수 있는 **문자(Character)**로 변환한다.
대부분의 웹사이트는 UTF-8 인코딩을 사용한다.
3. 토큰화 (Tokenize)
이제 브라우저는 문자 하나하나를 읽으면서 문법 구조를 파악하기 시작한다.
HTML 문서는 <, >, /, = 같은 구조적 기호로 이루어져 있다.
<div class="box">Hello</div>
→ 다음과 같은 토큰들(token) 로 분해됨:
- StartTag: <div>
- Attribute: class="box"
- Text: Hello
- EndTag: </div>
📌 이 과정을 HTML Tokenizer가 수행하며, 동시에 문법 오류나 중첩 검사도 한다.
4. 오브젝트화 (Objectify) — 토큰에서 노드로
토큰이 만들어지면, 이제 그걸 바탕으로 실제 객체(Node) 를 생성한다.
각 태그는 브라우저 내부적으로 하나의 객체(노드)로 변환되고, 부모-자식 관계를 가지게 된다.
아까 그 예시로 보면:
<div class="box">Hello</div>
→ 다음과 같은 구조의 노드가 만들어짐:
Element(div)
├── Attribute(class="box")
└── Text("Hello")
이런 식으로 토큰이 계층적 객체 구조로 바뀌는 게 바로 오브젝트화 단계다.
5. DOM 트리 생성
노드들을 부모-자식 관계로 연결하여 DOM(Document Object Model) 트리를 만든다.
이 DOM은 JavaScript로 조작 가능하다.
document.querySelector('.box').textContent = 'Hi';
→ 이런 코드는 DOM 트리를 직접 수정하는 작업이다.
⚠️ 자주 발생하는 실수
<head>
<script>
document.querySelector('#target').textContent = '변경'; // ❌ 오류
</script>
</head>
<body>
<div id="target">원래 내용</div>
</body>
스크립트가 DOM 파싱보다 먼저 실행되면, 요소가 존재하지 않아 null이 반환되고 오류가 발생한다.
📌 왜 이런 일이 생길까?
<script> 태그는 브라우저의 HTML 파싱을 중단시킨다.
왜냐하면 자바스크립트가 DOM 구조를 직접 조작할 수 있기 때문이다.
→ 만약 브라우저가 DOM을 끝까지 다 만들기 전에 자바스크립트를 실행하지 않으면, document.write() 등으로 기존 파싱 내용이 무효화될 수 있음.
→ 그래서 브라우저는 HTML을 파싱하다가 <script>를 만나면, 그 즉시 실행을 멈추고 JS부터 실행하는 것이다.
✅ 해결 방법
- <script>를 <body> 하단에 배치
- defer 속성 사용: <script src="main.js" defer></script>
- DOMContentLoaded 이벤트 사용
6. CSSOM 트리 생성
HTML 파싱과 함께 브라우저는 <link>나 <style> 태그를 만나면 CSS 파싱도 시작한다.
그 결과 만들어지는 구조가 CSSOM(CSS Object Model) 트리다.
CSSOM은 렌더링에 필수다
브라우저는 DOM + CSSOM을 결합해서 렌더 트리(Render Tree) 를 만들어야 화면에 내용을 그릴 수 있다.
→ CSSOM이 아직 준비되지 않았다면 렌더링은 일시 중단된다.
그래서 외부 CSS는 종종 렌더링 차단 리소스로 간주된다.
💡 CSSOM이 DOM보다 먼저 만들어질 수도 있다?
그럴 수 있다. 이유는 다음과 같다:
- 브라우저는 HTML 파싱과 CSS 파싱을 병렬로 처리
- HTML 구조가 복잡하거나 길면 DOM이 늦게 끝날 수 있음
- <link>가 <head> 상단에 있으면 CSS 다운로드가 더 일찍 시작됨
하지만 이건 어디까지나 가능성일 뿐이다.
렌더링은 언제나 DOM과 CSSOM이 모두 준비되어야 시작된다.
7. 렌더 트리 생성 & Layout
✅ 렌더 트리란?
- DOM: 구조 정보
- CSSOM: 스타일 정보
→ 두 트리를 결합해서 브라우저는 렌더 트리(Render Tree) 를 생성한다.
이 트리는 실제로 화면에 그릴 요소만 포함한다.
📎 예시:
<div>
<span style="display: none;">숨김</span>
<p>보이는 문단</p>
</div>
→ 렌더 트리에는 <div>와 <p>만 포함되고, display: none인 <span>은 제외된다.
📐 Layout 단계 (Reflow)
렌더 트리가 준비되면 브라우저는 각 요소의 크기, 위치, 좌표를 계산한다.
- width, height
- top, left
- padding, margin, border…
이 과정이 Layout, 또는 Reflow다.
💥 성능 이슈: Layout Thrashing
🔁 문제 상황
JavaScript가 다음처럼 DOM의 레이아웃을 읽고 → 바로 수정하는 작업을 반복하면 문제가 발생한다.
const items = document.querySelectorAll('.box');
for (let item of items) {
// DOM 읽기
const height = item.offsetHeight;
// DOM 쓰기
item.style.height = (height + 10) + 'px';
}
→ 이 코드는 읽기 → 쓰기 → 읽기 → 쓰기 순으로 반복되며
브라우저는 계속해서 Layout을 강제로 재계산한다.
→ 이것이 Layout Thrashing, 즉 성능 병목의 원인이다.
✅ 해결 방법
읽기와 쓰기를 분리하자:
const items = document.querySelectorAll('.box');
const heights = [];
// 1. 모든 읽기 먼저
for (let item of items) {
heights.push(item.offsetHeight);
}
// 2. 그 다음 쓰기
for (let i = 0; i < items.length; i++) {
items[i].style.height = (heights[i] + 10) + 'px';
}
→ 이렇게 하면 reflow는 단 한 번만 일어나고 성능도 훨씬 좋다.
8. Paint (Repaint)
Layout이 끝나면, 이제 실제 픽셀을 채우는 Paint 단계로 넘어간다.
- 텍스트 색상
- 배경
- 테두리
- 그림자
- 이미지 등
브라우저는 이들을 픽셀 단위로 칠해 시각적으로 표현한다.
📌 RePaint를 유발하는 CSS 속성
element.style.backgroundColor = 'red';
element.style.boxShadow = '5px 5px 10px rgba(0,0,0,0.2)';
→ background, box-shadow, border-radius, filter 등으로 스타일을 변경하면 Paint를 유발한다.
9. Composite (합성)
렌더링의 마지막 단계는 **Composite(합성)**이다.
브라우저는 Paint 단계에서 칠해진 결과들을 여러 레이어(layer)로 관리하고,
Composite 단계에서 이 레이어들을 결합해 최종 화면을 만든다.
💡 Composite만으로 끝낼 수 있다면?
보통 CSS가 변경되면 브라우저는 다음 3단계를 다시 실행해야 한다:
- Layout – 위치와 크기 계산
- Paint – 픽셀로 다시 그리기
- Composite – 레이어를 합쳐 화면 표시
하지만 일부 속성은
Layout과 Paint 없이, Composite만으로 화면을 바꿀 수 있다.
대표적인 속성: transform, opacity
🔁 예시: left vs transform
element.style.left = '200px'; // Layout 부터 트리거
element.style.transform = 'translateX(100px)';
이렇게 하면 실제 DOM 구조나 레이아웃은 그대로 두고,
GPU 레벨에서 위치만 바뀐 것처럼 보여준다.
→ 결과적으로 Layout과 Paint는 건드리지 않고, Composite만 발생한다.
🔁 예시 2: visibility vs opacity
visibility: hidden (Paint 발생)
element.style.visibility = 'hidden';
- 요소는 안 보이지만, 여전히 공간을 차지함
- Paint는 다시 일어남
element.style.opacity = '0';
- 요소는 투명하지만 공간은 유지됨
- Layout 변화 없음, Paint도 없음 → Composite만 발생
→ 애니메이션, 페이드 효과에 적합
✅ 중요한 전제: “동적으로 바뀌는 경우”만 해당된다
/* 초기 CSS 선언 */
.card {
transform: translateX(100px);
}
→ 이건 최초 렌더링 시에는 다른 스타일들과 마찬가지로
Layout → Paint → Composite 전부 발생함.
🧩 추가로 알아두면 좋은: 렌더링 병목을 만드는 주요 원인들
렌더링 파이프라인이 정상적으로 흘러가더라도,
다음과 같은 요소들로 인해 예상치 못한 병목이나 화면 멈춤 현상이 발생할 수 있다.
1. JavaScript의 장시간 실행
브라우저는 JavaScript, 렌더링, 이벤트 처리 모두 메인 스레드에서 실행한다.
→ JS 코드가 오래 걸리면 다른 작업(특히 렌더링)이 대기 상태가 되어 UI가 멈춘다.
for (let i = 0; i < 1e9; i++) {} // ❌ 무한 루프 → UI 먹통
해결: Web Worker 분리, 연산 단위 쪼개기, requestIdleCallback 활용
2. HTML 파싱 중단 (Blocking Script)
HTML 문서의 <script> 태그는 파싱을 멈추게 만든다.
<script src="heavy.js"></script> <!-- ❌ HTML 파싱 중단 -->
→ 스크립트 다운로드 + 실행이 끝날 때까지 DOM 파싱 중단
해결: defer, async 속성 사용으로 병목 완화
3. Layout Thrashing
JS 코드가 DOM 읽기 → 쓰기 → 읽기 식으로 반복되면
브라우저가 Layout을 계속 강제로 다시 계산하게 된다.
4. Paint 과다
box-shadow, filter, border-radius, opacity 등
시각 효과가 자주 바뀌면 Paint가 빈번하게 발생하여 GPU 부담 증가
해결: 해당 속성 최소화 또는 will-change로 제한적 GPU 레이어 활용
5. 고빈도 이벤트 + 무거운 핸들러
scroll, resize, mousemove 같은 이벤트에 무거운 작업이 연결되면
매 프레임마다 메인 스레드를 잠식하게 된다.
해결: requestAnimationFrame, debounce, throttle 필수
[ 잘못된 내용이나, 알려주실 지점이 있다면 댓글 환영입니다 ㅎㅎ ]
참고
https://www.youtube.com/watch?v=z1Jj7Xg-TkU&t=60s
https://www.youtube.com/watch?v=R23JmhbPnVo
https://developer.mozilla.org/ko/docs/Web/API/Web_Workers_API