브라우저 렌더링 파이프라인을 공부하며 가장 크게 느낀 점은 '보이지 않는 비용'에 대해 인지하고 있어야겠다는 것입니다. 그저 코드를 작성하면 화면에 나타난다고 당연하게 생각했지만, 바이트가 DOM과 CSSOM으로 파싱되고, 결합하여 렌더 트리를 만드는 작업의 동작 과정을 알게 되었습니다. 자바스크립트의 async/defer 속성 하나가 파싱을 중단시키는지 여부를 결정하고, 무심코 쓴 offsetWidth 조회가 강제 동기식 레이아웃을 유발해 전체 파이프라인을 멈추게 할 수 있다는 사실도 놀랍게 느껴졌습니다. 이제는 코드 한 줄을 작성하더라도 이 코드가 렌더링 엔진을 얼마나 영향을 주는지, 어떻게 하면 레이아웃과 페인트를 건너뛰고 합성 단계만으로 효율적으로 처리할 수 있을지 고민하는 습관을 갖게 되었습니다. 결국 성능 최적화는 화려한 기술이 아니라, 기본적이면서도 핵심적인 파이프라인의 동작 원리를 깊이 이해하는 데서 시작된다고 생각합니다.
우리가 작성한 HTML, CSS, JavaScript 코드는 어떻게 사용자의 화면에 하나의 완성된 웹 페이지로 그려질까요? 이 변환 과정의 중심에는 렌더링 엔진이 있고, 이 엔진은 렌더링 파이프라인이라는 단계를 통해 코드를 시각적 결과물로 만들어냅니다. 따라서 이 파이프라인을 깊이 있게 이해할 수 있도록 공부해보았습니다.
렌더링 엔진이란 무엇일까?
우리가 자주 사용하는 웹 브라우저는 단순히 웹 페이지를 보여주는 도구가 아니라, 다양한 상호작용을 가능하게 하는 중요한 플랫폼입니다. 브라우저는 크게 사용자 인터페이스, 렌더링 엔진, Javascript 엔진, 네트워킹 등으로 구분됩니다.
이 때 렌더링(Rendering)이란 브라우저가 HTML, CSS, Javascript를 해석하여 사용자가 보는 화면에 실제로 나타내는 과정을 의미합니다. 이는 파일을 읽는 것을 넘어서 페이지 구조를 분석하고, 스타일과 스크립트를 계산하며 픽셀 단위의 화면을 생성하면서도 브라우저의 성능을 좌우하는 핵심적인 요소입니다. 브라우저 렌더링 파이프라인(Rendering Pipeline)은 HTML, CSS, Javascript로 작성된 문서를 화면에 픽셀 단위로 그리는 과정을 의미합니다.
주요 단계는 크게 Parse → Style → Layout → Paint → Composite의 5가지로 나눌 수 있습니다.
1. 파싱 (Parse)
브라우저가 서버로부터 HTML 파일을 다운로드하면 가장 먼저 파싱 단계를 거칩니다. 디스크에 저장된 원시 바이트(Bytes) 스트림을 전달받게 되는데, 파싱 단계에서 이 바이트 덩어리를 개발자가 다룰 수 있는 구조적인 객체 모델로 변환하는 과정입니다.
1-1) HTML 파싱 및 DOM(Document Object Model) 트리 생성하기
가장 먼저 브라우저는 HTML 파일을 읽고 DOM 트리를 생성합니다. DOM은 HTML 문서의 계층적 구조를 표현하는 객체 모델이며, HTML로 작성된 요소들을 Javascript가 이해하고 조작할 수 있도록 변환시킨 객체입니다. HTML 문서의 각 태그 요소, 속성, 텍스트는 DOM 트리에서 하나의 노드로 표현되어 부모-자식, 형제 노드 간의 관계를 파악하고 요소를 탐색하거나 수정할 수 있게 합니다.
1-2) CSS 파싱 및 CSSOM(CSS Object Model) 트리 생성
HTML 파싱 중 <link>나 <style> 태그를 만나면 CSS 파서가 동작하여 CSSOM 트리를 생성합니다. 이 과정은 DOM 생성과 유사하지만, CSS의 핵심 특성인 계단식(Cascading) 규칙이 적용됩니다.
CSSOM은 단순히 CSS 규칙을 트리로 만든 것이 아닙니다. 브라우저는 모든 CSS 소스(브라우저 기본 스타일, 개발자 CSS, 사용자 스타일 시트 등)를 분석하고, 선택자 우선순위(Specificity), 상속(Inheritance), 그리고 선언된 순서에 따라 각 DOM 노드에 적용될 최종 스타일 값을 계산합니다. 예를 들어, p 태그에 color: black과 #main p에 color: blue가 모두 적용된다면, 더 구체적인 선택자인 #main p의 color: blue가 최종 값으로 CSSOM에 기록됩니다.
CSS는 렌더링 차단 리소스(Render-Blocking Resource)입니다. 브라우저는 CSSOM 트리가 완전히 구축되기 전까지 렌더링 파이프라인의 다음 단계를 진행하지 않습니다. 스타일이 확정되지 않은 상태에서 렌더링을 시작하면, 나중에 스타일이 적용될 때 화면이 반짝이며 재계산되는 현상(FOUC, Flash of Unstyled Content)이 발생할 수 있기 때문입니다.
2. 렌더 트리(Render Tree) 형성
브라우저는 앞에서 생성한 DOM 트리와 CSSOM 트리를 결합하여 렌더 트리를 생성합니다. 렌더 트리는 실제로 화면에 표시될 요소들만 포함하고 있으며, 각 요소에 적용될 스타일 정보와 위치 정보를 담고 있습니다.
실제 보이는 요소만 포함해 화면에 실제로 그려질 노드들로만 구성됩니다. DOM 트리의 루트에서부터 시작하여 각 노드를 순회하며, 아래와 같은 노드는 렌더 트리에서 제외됩니다.
렌더 트리는 DOM과 1:1 매칭이 아닙니다. visibility: hidden;은 공간은 차지하지만 보이지 않을 뿐이기 때문에 ::before, ::after과 같은 가상 요소는 DOM에는 없지만 렌더 트리에는 포함되어 시각적으로 렌더링됩니다.
3. 레이아웃(Layout)
렌더 트리는 '무엇을' 그릴지는 결정했지만, '어디에', '얼마나 크게' 그릴지는 아직 모릅니다. 따라서 렌더 트리가 완성되면, 브라우저는 이 트리를 기반으로 각 요소의 정확한 위치와 크기를 계산하는 레이아웃 단계에 돌입합니다. 이 과정에서 뷰포트(Viewport) 내에서 요소가 화면에서 어디에 위치할지, 그리고 어떤 크기를 가질지가 결정됩니다.
이 단계에서 모든 상대적인 값(%, rem, vh 등)이 화면상의 절대적인 픽셀 값으로 변환됩니다. 각 요소의 너비, 높이, 여백(margin, padding) 등이 박스 모델에 따라 계산됩니다.
리플로우(reflow)가 여기에 포함되는데, 레이아웃 과정은 비용이 비싼 작업입니다. 너비, 높이, 위치 등 특정 요소의 속성이 변경되거나, 폰트 크기가 바뀌거나, 심지어 브라우저 창 크기가 조절될 때도 리플로우가 발생합니다. 리플로우는 해당 요소뿐만 아니라 영향을 받는 모든 자식 및 부모 요소들의 레이아웃을 재계산해야 하므로 성능 저하의 주범이 됩니다.
<style>
body {
padding: 30px;
}
</style>
예를 들어 위와 같은 코드가 작성되었다면, 하위 요소들은 30px의 여백을 두고 배치됩니다.
4. 페인트 (Paint)
레이아웃 단계에서 계산된 위치와 크기 정보를 참고하여, 브라우저는 요소의 스타일과 내용을 바탕으로 화면에 픽셀을 그립니다. 이 페인트 단계에서는 색상, 텍스트, 그림자, 테두리 등 시각적인 속성들이 표현됩니다. 이 과정에서 브라우저는 각 요소의 시각적 속성을 기반으로 페인트 레이어(Paint Layer)를 생성할 수 있습니다. z-index, position, opacity 등의 속성에 따라서 독립적인 레이어가 될 수 있습니다.
페인트 레이어란?
브라우저는 모든 것을 단일 레이어에 그리지 않습니다. 특정 CSS 속성(transform, opacity, will-change 등)이 적용된 요소는 별도의 레이어로 분리됩니다. 이렇게 하면 해당 레이어의 시각적 속성이 변경될 때 전체 페인트할 필요 없이 해당 레이어만 독립적으로 업데이트 할 수 있어서 성능에 매우 유리합니다.
5. 컴포지팅
페인트 과정에서 생성된 여러 개의 레이어들이 하나의 화면으로 결합되는 컴포지팅 단계입니다. 브라우저는 각 레이어를 올바른 순서로 합성해 최종 화면을 렌더링하며, 특히 요소가 겹치는 경우에 z-index나 position 속성을 고려해 겹침 순서를 정확히 처리합니다. 독립적인 레이어 분리는 페이지의 다른 부분에 영향을 주지 않고 개별적으로 갱신될 수 있게 해 복잡한 화면을 효율적으로 처리하는 데 중요한 역할을 합니다.
transform이나 opacity 속성을 변경하는 애니메이션이 부드러운 이유가 바로 여기에 있습니다. 이러한 변경은 레이아웃과 페인트를 건너뛰고, 컴포지팅 단계만 다시 실행합니다.
JavaScript가 렌더링에 미치는 영향과 성능 최적화하기
브라우저가 복잡한 렌더링 과정을 통해 최종 화면을 완성하지만, JavaScript는 DOM과 CSSOM에 직접적인 영향을 미쳐 화면을 다시 렌더링하도록 유발하는 중요한 변수로 작용합니다.
1) 리플로우(Reflow)와 리페인트(Repaint) 문제 해결하기
JavaScript가 DOM을 조작해 화면 요소를 변경하거나 추가할 때, 브라우저의 레이아웃(Layout)과 페인트(Paint) 단계를 반복적으로 실행하도록 유도하여 성능에 큰 영향을 미칠 수 있습니다. 앞서 설명했듯이, JS로 레이아웃 속성을 변경하면 리플로우(Layout → Paint → Composite)가, 시각적 속성만 변경하면 리페인트(Paint → Composite)가 발생합니다.
- 리플로우(Reflow): 요소의 크기, 위치, 레이아웃 자체가 변경될 때 레이아웃 단계에서 발생합니다. 이 경우 브라우저는 해당 요소뿐만 아니라 관련된 부모 및 자식 요소들까지 전체 레이아웃을 다시 계산해야 하므로, 연산량과 계산 복잡도가 증가합니다.
- 리페인트(Repaint): 레이아웃에는 영향을 주지 않지만, 색상이나 배경색 등 요소의 시각적 스타일이 변경될 때 화면을 다시 그리는 과정입니다. 리플로우보다 덜 복잡하지만, 빈번하게 발생하면 역시 성능 저하를 초래합니다.
성능을 최적화하기 위해서는 DOM 조작 시 리플로우와 리페인트 발생을 최소화해야 합니다. 여러 스타일 속성을 변경해야 할 경우, 스타일을 모아서 한 번에 변경되거나 class를 추가해 조정하는 것이 좋습니다. 또한 offsetWidth와 같이 리플로우를 발생시키는 속성이나 함수를 반복문 내에서 매번 호출하지 않고 변수에 저장해 호출을 제한해야 합니다.
실행 순서는 다음과 같기 때문에, 레이아웃 변경이 발생하면 브라우저는 Reflow → Repaint 순서로 수정합니다.
Reflow (Layout)
↓
Repaint (Paint)
↓
Composite
레이아웃 스래싱(Layout Thrashing): 성능 저하를 유발하는 더 심각한 문제는 레이아웃 스래싱입니다. 예를 들어, 반복문 안에서 요소의 스타일을 변경한 직후 offsetHeight와 같은 계산된 값을 읽는 코드가 있다면, 브라우저는 정확한 값을 반환하기 위해 매 반복마다 강제로 레이아웃 단계를 동기적으로 실행해야 합니다.
// 나쁜 예시: 매 루프마다 Reflow 발생
for (let i = 0; i < elements.length; i++) {
const width = elements[i].offsetWidth; // 읽기 (Reflow 유발)
elements[i].style.width = (width * 2) + 'px'; // 쓰기
}
// 좋은 예시: 읽기와 쓰기를 분리하여 Reflow 최소화
const widths = [];
for (let i = 0; i < elements.length; i++) {
widths.push(elements[i].offsetWidth); // 먼저 모두 읽기
}
for (let i = 0; i < elements.length; i++) {
elements[i].style.width = (widths[i] * 2) + 'px'; // 이후에 모두 쓰기
}
2) 스크립트 실행 차단 문제 해결하기
브라우저는 HTML 파싱 중 <script> 태그를 만나면 기본적으로 해당 스크립트를 다운로드하고 실행할 때까지 HTML 파싱을 중단합니다. 이는 렌더 트리 생성을 지연시키고 렌더링이 늦어지는 주요 원인이 됩니다.
<script> 태그는 기본적으로 파서 차단(Parser-Blocking) 방식으로 동작합니다. HTML 파서가 <script>를 만나면 DOM 생성을 멈추고 스크립트를 다운로드하여 실행합니다. 스크립트가 DOM을 변경할 수 있기 때문입니다. 이는 초기 렌더링 시간을 지연시키는 주된 원인입니다.
따라서 async / defer를 사용해 해당 문제를 해결할 수 있으며, 두 태그의 특징은 다음과 같습니다.
| 속성 | 특징 | 실행 시점 | 실행 순서 보장 | 적합한 상황 |
| async | 스크립트를 비동기적으로 로드 | 로드 완료 즉시 실행 (HTML 파싱 중에도 실행) | 로드된 순서대로 실행되므로 순서 보장이 어려움 | 스크립트 간 의존성이 없고 빠르게 로드되어야 할 때 사용 |
| defer | 스크립트를 비동기적으로 로드 | HTML 파싱이 완료된 후 실행 | 작성된 순서대로 실행 순서 보장 | DOM이 완전히 로드된 후에 실행되어야 하거나 스크립트 간 의존성이 중요할 때 |
- async: HTML 파싱과 병렬로 스크립트를 다운로드하고, 다운로드가 완료되는 즉시 파싱을 멈추고 실행합니다. 실행 순서가 보장되지 않으므로, 다른 스크립트와 의존성이 없는 경우(광고, 분석 스크립트)에 적합합니다.
- defer: HTML 파싱과 병렬로 스크립트를 다운로드하지만, 실행은 HTML 파싱이 모두 끝난 후에 DOMContentLoaded 이벤트 직전에 순서대로 실행됩니다. DOM에 의존적이거나 스크립트 간 실행 순서가 중요한 경우에 가장 이상적인 옵션입니다.
3) 리소스 최적화하기
이미지와 같은 리소스도 렌더링 성능에 큰 영향을 미칩니다. 파일 크기가 큰 이미지를 미리 로드하면 불필요한 네트워크 요청과 자원 소비로 인해 초기 로딩 속도가 느려질 수 있습니다. 이 때 사용자가 해당 콘텐츠를 실제로 볼 때 이미지를 로드하는 Lazy Loading을 활용하면 초기 로딩 속도를 크게 개선할 수 있습니다.
<img src="example.jpg" alt="Example Image" loading="lazy">
위 속성을 추가하면 브라우저는 사용자가 이미지 위치에 도달했을 때만 로드하여 네트워크 요청을 줄이고 초기 렌더링 속도를 높입니다.
References
'📚 CS > JavaScript' 카테고리의 다른 글
| [JavaScript] 프로그래밍 패러다임 (0) | 2025.11.06 |
|---|---|
| [JavaScript] 객체, 속성, 메서드, 클래스, 네임스페이스란? (1) | 2025.11.05 |
| [JavaScript] Promise와 async / await (0) | 2025.09.08 |
| [JavaScript] 이벤트 루프 (Event Loop) (0) | 2025.09.04 |
| [JavaScript] 프로토타입(Prototype)과 클래스(Class) (0) | 2025.09.03 |