눈에 보이지 않는 힘: 브라우저 구조와 렌더링 최적화 전략
웹 애플리케이션을 개발하면서 한 번쯤은 “브라우저는 대체 내부에서 어떻게 돌아가는 걸까?”라는 궁금증이 생겼지만, 항상 의문만 갖을 뿐 직접 알아본 적이 없음을 반성하며, 지금이라도 해당 주제로 블로그 글을 작성하고자 이번 포스팅을 시작합니다.
특히 성능 최적화나 렌더링 병목 현상을 마주할 때, 브라우저의 내부 동작을 이해하면 문제를 빠르게 파악하고 해결할 실마리를 얻을 수 있습니다.
1. 브라우저 아키텍처 전체 개요
브라우저는 사용자가 입력한 URL을 기반으로 네트워크 요청을 보내고, 서버로부터 전달받은 HTML/CSS/JS/이미지 등의 리소스를 파싱하여 화면에 표현하는 역할을 합니다. 이 과정은 크게 다음과 같은 구성 요소들이 협업하여 이루어집니다.
-
사용자 인터페이스(User Interface)
주소 표시줄, 탭, 북마크바, 뒤로/앞으로 버튼 등, 우리가 눈으로 보고 직접 클릭하는 영역입니다.
-
브라우저 엔진 (Brower Engine)
UI에서 발생한 클릭이나 URL 입력, 네트워크 응답 등을 받아 렌더링 엔진, JS 엔진, 네트워킹 모듈과 연결해주는 조율자 역할을 합니다.
-
렌더링 엔진(Rendering Engine)
HTML과 CSS를 파싱해서 DOM/CSSOM을 만들고, 실제로 화면에 보일 픽셀을 계산·그려주는 엔진입니다.-
크롬: Blink
-
사파리: WebKit
-
파이어폭스: Gecko
-
-
자바스크립트 엔진(JS Engine)
JavaScript 코드를 파싱·컴파일·실행해서 DOM을 조작하거나 이벤트를 처리하며, Event Loop를 관리하는 엔진입니다.-
크롬: V8
-
파이어폭스: SpiderMonkey
-
-
네트워킹(Networking)
HTTP/HTTPS 요청을 서버에 보내고, 응답으로 HTML, CSS, JS, 이미지 등을 받아오는 역할을 합니다.
-
UI 백엔드(UI Backend)
OS 차원에서 윈도우, 버튼, 콤보박스 같은 네이티브 컴포넌트를 실제 화면에 그릴 때 사용되는 로직입니다.
-
스토리지(Storage)
로컬스토리지, 세션스토리지, IndexedDB, 쿠키 등 브라우저 내부에 데이터를 저장하는 다양한 공간입니다.
아래 그림은 브라우저 내부 구성 요소가
어떻게 나뉘어 있는지를 개략적으로 보여줍니다.
2. 렌더링 파이프라인
브라우저가 HTML/CSS/JS 파일을 받아 사용자 화면에 보여주기까지,
대략적으로 다음과 같은 흐름으로 동작합니다.
-
HTML
파싱 →DOM
트리 생성
-
CSS
파싱 →CSSOM
트리 생성
-
렌더 트리(Render Tree)
생성
-
레이아웃(Layout)
단계
-
페인트(Paint)
단계
-
컴포지팅(Compositing)
단계
순서대로 살펴보겠습니다.
2.1 HTML 파싱과 DOM 트리 생성
이러한 코드가 있다고 가정해 보고 실제로 HTML 파싱과 DOM 트리 생성이 어떻게 이루어지는지 간략하게 요약해보겠습니다!
<!DOCTYPE html>
<html lang="ko">
<body>
<div>
<p>매튜</p>
</div>
</body>
</html>
html
-
HTML 파싱
-
파서는 위에서부터 순서대로 태그를 읽고, 각 시작 태그마다 “요소 노드(Element Node)”를 생성하여 계층 구조대로 연결합니다.
-
닫는 태그를 만나면 해당 요소를 닫고 상위 노드로 제어를 되돌립니다.
-
-
텍스트 파싱
<p>
같은 요소 내부의 “매튜”와 같은 텍스트를 만나면, “텍스트 노드(Text Node)”를 생성해 현재 열려 있는 요소의 자식으로 추가합니다.
-
결과 DOM 트리
- 최종적으로는 아래와 같은 트리 형태로 메모리에 저장되고, 이후 렌더링 엔진이 이를 바탕으로 화면을 그리게 됩니다.
Document (암시적 루트) └─ html (lang="ko") └─ body └─ div └─ p └─ “매튜”
- 최종적으로는 아래와 같은 트리 형태로 메모리에 저장되고, 이후 렌더링 엔진이 이를 바탕으로 화면을 그리게 됩니다.
이 과정이 파싱 단계
이며, 이 후 CSSOM
생성, 렌더 트리
생성, 레이아웃·페인트
단계를 거쳐 최종적으로 화면에 “매튜”라는 텍스트가 들어 있는 <p>
요소가 표시됩니다.
2.2 CSS 파싱과 CSSOM 트리 생성
-
CSS 파서(Parser)
HTML 파서가<link rel="stylesheet" href="styles.css">
를 만나면, 네트워킹 모듈이 비동기로styles.css
를 가져오도록 요청합니다.
-
CSSOM(CSS Object Model) 트리
CSS 바이트가 전부 내려오면, 이를 토큰화하여 “선택자(selector) + 속성(property)” 정보로 쪼갠 뒤, 최종 계산된 스타일(Computed Style)을 담은 트리를 만듭니다.
이 과정에서 상속(Inheritance), 우선순위(Specificity), 중복 규칙 등을 모두 반영해 “엘리먼트 A에 적용될 최종 스타일”을 계산합니다.
-
CSSOM
이 준비되기 전에는 브라우저가 안전하게 화면을 그릴 수 없습니다.
-
폰트까지 포함된 스타일이 늦게 적용되면 페이지가 깜빡이거나
FOIT/Flash Of Invisible Text
, 백지 상태로 보일 수 있습니다.
-
따라서 중요한
크리티컬 CSS
만<head>
에 인라인으로 넣고, 나머지는 비동기로 로드해 초기 렌더링을 최적화합니다.
2.3 렌더 트리(Render Tree) 생성
-
렌더 트리(Render Tree)
는 “실제로 화면에 그릴 노드만 골라낸 트리”입니다.<head>
,<meta>
,<script>
등 시각적으로 보이지 않는 태그는 렌더 트리에 포함되지 않습니다.display: none
이 걸려 있는 요소도 렌더 트리에서 제외됩니다.
-
생성 과정
DOM 트리
의 각 노드를 순회하며,CSSOM
에서 계산된 스타일 정보(Computed Style)를 확인해 “이 노드가 화면에 표시되어야 하는가?”를 판단- 표시 대상이라면, 렌더 트리에 해당 노드 복사본을 만들고, 박스 모델 속성(크기, 마진, 패딩,
display
,position
등)을 붙입니다.
이렇게 하면 실제로 보이는 요소(A, B, C)에 대한 정보
만 남아서 효율적으로 화면을 그릴 수 있습니다.
- 복잡한 레이아웃(중첩된 그리드, 플렉스박스, 애니메이션)이 많으면 렌더 트리가 커지고, 레이아웃 단계에서 CPU 사용량이 급증합니다.
- 가능한 단순한 DOM 구조를 유지하고, 불필요한 중첩 레이어(Layer)를 줄이는 것이 성능 최적화에 도움이 됩니다.
2.4 레이아웃(Layout) 단계
- 레이아웃 단계는 렌더 트리의 각 노드가 “화면에서 차지할 크기(
width
,height
)와 정확한 위치(x
,y
좌표)”를 결정하는 과정입니다.- 부모 노드의 크기가 결정되어야 자식 노드의
%
단위 크기를 계산할 수 있고,position: relative/absolute
속성도 고려해야 합니다. - 복잡한 레이아웃 계산(반응형 디자인,
%
단위, 깊은 박스 모델 등)이 많으면 비용이 커집니다.
- 부모 노드의 크기가 결정되어야 자식 노드의
- *리플로우(Reflow)**라고도 부르며, 성능 면에서 가장 무거운 단계 중 하나입니다.
- 전체 리플로우(Full Reflow): 레이아웃이 크게 변경되어 모든 노드를 다시 계산해야 하는 경우
- 부분 리플로우(Partial Reflow): 특정 서브트리(subtree)만 레이아웃을 재계산해도 되는 경우
- 리플로우를 발생시키는 사례
- 윈도우 창 크기 변경(리사이즈)
- DOM에 새로운 엘리먼트 삽입/삭제
- CSS 속성 변경(ex.
width
,height
,position
,display
등)
리플로우 최적화
-
Batch DOM 업데이트: 여러 개의 DOM 조작을 한 번에 묶어서 처리하기
<!-- 나쁜 예: 루프 안에서 바로바로 DOM 추가 → 리플로우를 여러 번 발생 --> <script> items.forEach((item) => { const li = document.createElement('li'); li.textContent = item; document.querySelector('ul').appendChild(li); }); </script>
html<!-- 좋은 예: DocumentFragment에 먼저 모아두었다가 한 번만 append --> <script> const fragment = document.createDocumentFragment(); items.forEach((item) => { const li = document.createElement('li'); li.textContent = item; fragment.appendChild(li); }); document.querySelector('ul').appendChild(fragment); </script>
html
-
CSS 클래스 토글 활용: 스타일 변경이 필요할 때
element.classList.toggle('active')
처럼 한 번만 바꾸면리플로우
가 적게 일어납니다.반면,
element.style.width = '100px'
같은 직접 속성 변경을 반복하면리플로우
가 자주 발생합니다.
2.5 페인트(Paint) 단계
-
페인트(Paint)
는 레이아웃 계산이 끝난 후, 각 노드를 실제 픽셀 단위로 “어떻게 색칠할지” 결정하는 과정입니다.- 배경색, 텍스트 색, 그림자, 테두리, 이미지 등 모든 시각적 요소가 이 단계에서 처리됩니다.
- GPU나 CPU를 이용해 렌더링 버퍼에 드로잉을 수행합니다.
-
레이어 분할(Layer Splitting)
브라우저는 성능 최적화를 위해, 자주 변경될 것으로 예상되는 요소(애니메이션, 트랜스폼 등)를 별도 레이어로 분리합니다.
예를 들어, 스크롤할 때 고정 헤더가 있고, 그 아래에 움직이는 콘텐츠가 있다면, 이 둘을 각각 별도 레이어로 처리해 “헤더만 다시 그리고 콘텐츠는 따로 다시 그리는 식”으로 최적화합니다.
-
페인트 비용이 큰 속성
box-shadow
,filter: blur()
,border-radius
등 복잡한 그래픽 효과- 대용량 이미지, 고해상도 텍스처
Tip!
will-change
속성 남용 주의
will-change: transform
같은 CSS를 붙이면 브라우저가 미리 레이어를 확보하고,
GPU 가속을 준비하겠다는 신호를 보냅니다.
그러나 너무 많은 레이어를 생성하면
GPU 메모리를 과도하게 사용하게 되어 오히려 성능이 떨어질 수 있습니다.
꼭 필요한 곳에만 최소 범위로 사용하는걸 권장합니다.
단순 속성으로 대체
복잡한 애니메이션이나 그래픽 효과 대신,transform: translate()
나opacity
같은 GPU 친화적 속성을 활용하면리페인트·리플로우
비용을 크게 낮출 수 있습니다.
2.6 컴포지팅(Compositing) 단계
-
컴포지팅(Compositing)
은 최종적으로레이어(Layer)끼리 합쳐서(Compose) 화면에 표시
하는 단계입니다.
예를 들어, 배경 레이어, 텍스트 레이어, 애니메이션 레이어, 고정 헤더 레이어 등이 각각 독립적으로 준비되어 있다가, 한 번에 합쳐져서 최종 화면이 만들어집니다.
-
GPU 가속
각 레이어가 별도 텍스처(texture)로 GPU 메모리에 올라가 있다가, 셰이더(shader)가 조합해 한 프레임을 렌더링합니다.
-
컴포지팅 비용
- 해상도가 높거나 레이어가 너무 많으면, 합성 과정에서 GPU에 큰 부하가 걸립니다.
- 불필요하게 레이어를 분리하면 메모리 점유와 드로우콜(draw call)이 증가하여 오히려 FPS가 떨어질 수 있습니다.
Tip!
- 레이어 플래트닝(Layer Flattening)
브라우저가 퍼포먼스를 위해 자동으로
레이어를 합치는 과정을 거치기도 하지만, 개발자가 의도치 않게 너무 많은 레이어를
만들면 합치는 과정에서 오버헤드가 발생합니다. 예를 들어,position: fixed
나will-change
를 남용하지 말고, 꼭 필요한 곳에만 사용하세요.
- 고해상도 기기 대응
넷플릭스 같은 서비스는 4K, Retina 디스플레이에서도 동영상 자막이나 초당 프레임(FPS) 저하 없이 부드럽게 보여야 합니다. 이를 위해 컴포지팅 단계를 최대한 최적화하고, 불필요한 레이어 분리를 최소화해야 합니다.
번외. 자바스크립트 로딩과 렌더링 차단
브라우저는 렌더링 파이프라인을 따라가던 중 <script>
태그를 만나면,
HTML 파싱과 CSS 처리(CSSOM 생성)를 일시 중단하고 자바스크립트 엔진이 스크립트를 다운로드·파싱·실행하도록 전환합니다.
이 과정에서 발생하는 렌더링 차단(Blocking)이 성능 병목
으로 이어질 수 있습니다.
- 렌더링 차단 발생 시점
- 브라우저가
<script src="ohtani.js" />
를 만나면,- 현재까지 파싱된 DOM 트리까지만 완성된 상태에서,
- 네트워킹 모듈을 통해
ohtani.js
를 다운로드 - 자바스크립트 엔진이 해당 코드를 파싱·컴파일·실행
- 이 모든 과정이 끝나야야 HTML 파싱이 재개되고, 이어서 CSSOM이나 렌더 트리 생성 단계로 넘어갑니다.
- 브라우저가
- 렌더링 성능에 미치는 영향
<script>
자체가 렌더링 차단 요소이기 때문에, 사용자가 화면을 볼 수 있는 시점(First Paint, First Contentful Paint)이 지연됩니다.- 특히 외부 CDN이나 네트워크 레이턴시가 큰 환경에서
스크립트를 불러오는 데 시간이 오래 걸리면
, DOM/CSSOM 생성이 중단되어페이지가 한동안 빈 화면
으로 보일 수 있습니다.
- 해결 방법:
defer
와async
크롬, 파이어폭스, 사파리 등 최신 브라우저는<script>
에defer
와async
속성을 지원하여 렌더링 차단을 최소화할 수 있도록 돕습니다.
1️⃣ <script defer>
- HTML 파서는
<script defer>
를 만나도 파싱을 멈추지 않고 계속 진행합니다. - 문서의 파싱이 완료된 후, HTML에 작성된 순서대로 스크립트를 실행합니다.
- 순서가 보장되면서도 렌더링 차단을 줄일 수 있어, 일반적으로 외부 라이브러리나 애플리케이션 초기 로직에 많이 사용합니다.
<head>
<link rel="stylesheet" href="styles.css" />
<script src="ohtani.js" defer></script>
<script src="ahn.js" defer></script>
</head>
html
2️⃣ <script async>
- HTML 파서는
<script async>
를 만나면 스크립트를 다운로드하되, 파싱을 멈추지 않고 계속 진행합니다. - 다운로드가 완료되는 즉시 스크립트를 실행하며, 실행 시점에는 HTML 파싱이 잠시 멈춥니다.
- 스크립트 간 실행 순서가 보장되지 않기 때문에, 서로 의존성이 없는 광고 스크립트, 통계 스크립트, 위젯 등 “순서 상관없이 실행 가능한” 외부 스크립트에 주로 사용합니다.
<head>
<link rel="stylesheet" href="styles.css" />
<script src="analytics.js" async></script>
<script src="ads.js" async></script>
</head>
html
- 어쩔 때 defer? 어쩔 때 async?
- 중요 로직은
defer
: 페이지 렌더링과 밀접하게 연관된 초기화 코드는defer
를 사용해 문서 파싱이 끝난 후 실행되도록 하는게 좋습니다. - 서드파티 스크립트는
async
: 외부 광고, 통계, SNS 위젯 등 렌더링과 순서 관계가 없는 스크립트는async
로 로드해 렌더링 차단을 피할 수 있습니다. - 인라인 스크립트 최소화: 인라인 스크립트는 캐싱 이점이 적어 로드 성능에 부담이 될 수 있으므로, 가능하면 외부 파일로 분리하고
defer
또는async
로 로드하는게 좋습니다.
- 중요 로직은
3. 브라우저 렌더링 최적화
지금까지 브라우저 내부 구조와 렌더링 파이프라인을 살펴봤습니다.
이제 렌더링 속도와 사용자 경험을 높이기 위해 할 수 있는 몇가지 최적화 기법을 정리해보겠습니다.
3.1 Critical Rendering Path(CRP) 줄이기
Critical Rendering Path는 HTML 파싱 → CSSOM 생성 → 렌더 트리 구성 → 레이아웃 → 페인트
에 이르는 과정을 말하며,
이 경로가 짧을수록 First Contentful Paint(FCP)
나 Largest Contentful Paint(LCP)
같은 중요한 렌더링 지표가 빨라집니다.
-
크리티컬 CSS 인라인(Inlining Critical CSS)
페이지 상단에 보이는 최소한의 스타일만<style>
태그로 직접<head>
안에 넣습니다.
예를 들어, 헤더, 네비게이션 바, 로고, 폰트 설정 같은 핵심 레이아웃만 1–5KB 정도로 추려서 넣으면, 브라우저가 외부 CSS 파일을 모두 기다리지 않고 바로 렌더링할 수 있습니다.나머지 CSS는
<link rel="preload" href="main.css" as="style" onload="this.rel='stylesheet'">
식으로 비동기로 로드합니다.
<head>
<!-- 1. 크리티컬 CSS 인라인 -->
<style>
body {
margin: 0;
font-family: 'Noto Sans KR', sans-serif;
}
header {
display: flex;
align-items: center;
height: 60px;
background: #141414;
color: #fff;
}
.logo {
margin-left: 20px;
font-size: 1.5rem;
font-weight: bold;
}
nav {
margin-left: auto;
margin-right: 20px;
}
nav a {
color: #fff;
text-decoration: none;
margin: 0 10px;
}
/* …필요한 핵심 스타일만… */
</style>
<!-- 2. 전체 CSS 비동기 로드 -->
<link
rel="preload"
href="/styles/main.css"
as="style"
onload="this.rel='stylesheet'"
/>
<noscript>
<link rel="stylesheet" href="/styles/main.css" />
</noscript>
</head>
html
-
JS 번들 크기 최소화 & 코드 스플리팅(Code Splitting)
Webpack, Vite 같은 빌드 도구에서 “페이지별 번들”을 만들어, 사용자가 방문하지 않을 페이지까지 미리 다운로드하지 않도록 합니다.
Tree Shaking
으로 사용하지 않는 라이브러리 코드를 제거해 번들 파일 용량을 줄이고, 서드파티 라이브러리는 가능하면 CDN을 활용하거나 HTTP/3을 활성화해 네트워크 레이턴시를 최소화합니다.
-
이미지 최적화
JPEG, PNG 대신 WebP, AVIF 같은 차세대 이미지 포맷을 사용하면 파일 크기를 30–50% 정도 줄일 수 있습니다.
srcset
과sizes
를 활용해 디바이스 해상도에 맞는 이미지를 로드하고,loading="lazy"
속성을 추가해 화면에 보여질 때까지 이미지 다운로드를 미뤄 초기 로딩을 빠르게 할 수 있습니다.
<img
src="ohtani.avif"
type="image/avif"
srcset="ohtani.avif 1x, ohtani@2x.avif 2x"
alt="ohtani Image"
loading="lazy"
/>
html
-
폰트 로딩 최적화
@font-face
를 사용할 때font-display: swap;
옵션을
지정하면 커스텀 폰트 로딩 전에는 시스템 폰트를 먼저 보여줘FOIT
없이 텍스트”를
바로 볼 수 있습니다.구글 폰트 같은 CDN을 통해 제공되는 폰트를 직접 가져오면 네트워크
레이턴시를 줄이고, 브라우저 캐싱 이점도 얻을 수 있습니다.
3.2 레이지 로딩(Lazy Loading) 적극 활용
-
이미지 & 비디오 Lazy Loading
HTML5에서 지원하는<img loading="lazy">
를 사용하면, 사용자가 화면을 스크롤해 해당 이미지가 뷰포트에 가까워질 때 다운로드를 시작합니다.보다 정밀한 제어가 필요하다면 Intersection Observer API를 이용해
화면에 가까워질 때 미리 로드
하는 방식을 구현할 수 있습니다.동영상의 경우, 모바일 데이터 사용량이나 네트워크 상태에 따라 해상도(360p, 720p, 1080p)를 자동으로 조절하며 로드하는 어댑티브 스트리밍(Adaptive Streaming) 기법을 고려해보면 좋습니다.
-
JS/컴포넌트 Lazy Loading
React, Vue, Angular 등 프레임워크에서는 라우트 기반으로 컴포넌트를 동적으로 불러오는 코드를 쉽게 만들 수 있습니다.
const HomePage = React.lazy(() => import('./pages/HomePage'));
const AboutPage = React.lazy(() => import('./pages/AboutPage'));
tsx
이렇게 하면 사용자가 방문하지 않는 페이지의 JS 코드를 초기 번들에 포함하지 않아 메인 번들 크기를 작게 유지할 수 있습니다.
3.3 리플로우(Reflow) & 리페인트(Repaint) 최소화
- 읽기(Read)와 쓰기(Write) 분리
예를 들어,getBoundingClientRect()
같은 레이아웃 읽기 메서드와style.width = '100px'
같은 쓰기를 섞어 쓰면, 매번 레이아웃 계산이 강제되어 Layout Thrashing이 발생합니다.
해결책은 “한 번에 읽고 → 한 번에 쓰기”로 나누는 것입니다.
// 나쁜 예
divs.forEach((div) => {
const h = div.getBoundingClientRect().height;
div.style.height = `${h}px`; // 읽기 → 쓰기 → 읽기 → 쓰기 → 리플로우 반복
});
// 좋은 예
const heights = divs.map((div) => div.getBoundingClientRect().height);
divs.forEach((div, i) => {
div.style.height = `${heights[i]}px`;
}); // 먼저 읽기만, 그다음에 쓰기만 → 리플로우 최소화
tsx
- CSS 클래스 토글 활용
스타일 변경이 필요할 때 여러 속성을 직접 쓰는 대신, 미리 정의된 CSS 클래스를 한 번에 바꾸면 리플로우를 최소화할 수 있습니다.
/* 예시: 미리 정의된 클래스 */
.box {
width: 100px;
height: 100px;
background: red;
}
.box.active {
width: 150px;
background: blue;
}
css
// JS
boxElement.classList.toggle('active');
jsx
-
GPU 친화적 애니메이션
애니메이션을 만들 때top
,left
같은 레이아웃 속성을 변경하면 리플로우가 발생하기 때문에, 대신transform: translate()
나opacity
같은 속성을 사용하는 것이 좋습니다.예를 들어,
element.style.left = '100px'
대신element.style.transform = 'translateX(100px)'
을 쓰면 리플로우 없이 GPU 레이어에서 애니메이션이 이루어집니다.
마무리
프론트엔드 개발의 재미는 코드가 화면에 바로 반영되는 즉각적인 피드백에 있지만,
저는 보이지 않는 최적화가 사용자 경험을 바꾸는 순간에 더 큰 가치를 느낍니다.
예를 들어, 페이지 로딩 속도를 0.1초씩 줄이는 작업을 100번 반복하면, 사용자는 총 10초를 절약할 수 있습니다.
이 10초는 웹 서비스의 첫인상과 체류 시간, 이탈률 같은 핵심 지표에 분명한 변화를 만들어냅니다.
사용자가 그 차이를 명확히 인식하지 못하더라도, 서비스가 더 빠르고 자연스럽게 느껴지는 경험은 분명히 남습니다.
아직 많이 배우고 있는 개발자지만, 앞으로도 작은 최적화를 꾸준히 쌓아 더 쾌적한 웹 환경을 만드는 일에 집중하고 싶습니다.
읽어주셔서 감사합니다.