FE

브라우저는 어떻게 HTML을 화면에 렌더링할까?

고감귤 2025. 1. 4. 23:01

웹 페이지는 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단계를 다시 실행해야 한다:

  1. Layout – 위치와 크기 계산
  2. Paint – 픽셀로 다시 그리기
  3. 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

https://www.youtube.com/watch?v=Mqh13dNI8jc