브라우저 렌더링 과정을 DOM과 CSSOM부터 이해하기
브라우저 렌더링 과정을 DOM과 CSSOM부터 이해하기
DOM은 문서 구조, CSSOM은 적용 가능한 스타일 규칙을 표현한다. 둘을 결합해 보이는 노드의 렌더 트리를 만든 뒤 크기와 위치를 계산하고 픽셀을 그린다.
목차
- #왜 이 문제가 생기는가
- #브라우저가 화면을 만드는 전체 흐름
- #DOM과 CSSOM은 언제 만들어질까
- #렌더 트리에서 합성까지
- #script 로딩 방식이 파싱을 바꾸는 방법
- #레이아웃 스래싱을 만드는 코드
- #transform과 opacity가 항상 빠른 것은 아니다
- #개발자 도구로 확인할 것
- #결론
- #관련 노트
왜 이 문제가 생기는가
버튼 하나의 너비를 바꿨을 뿐인데 스크롤이 끊기거나, 애니메이션을 추가한 뒤 저사양 기기에서만 버벅이는 경우가 있다. 이 문제를 단순히 “DOM 조작이 느리다”라고 설명하면 해결 방법을 찾기 어렵다. 브라우저는 HTML과 CSS를 받은 뒤 여러 중간 표현을 만들고, 요소의 크기와 위치를 계산한 다음, 실제 픽셀을 그려 화면에 합성한다.
중요한 것은 코드 한 줄이 이 과정 중 어느 단계를 다시 실행하게 하느냐다. 같은 모양의 변경이라도 width를 바꾸는 것과 transform을 바꾸는 것은 브라우저가 해야 하는 작업이 다를 수 있다.
아래 코드는 렌더링 원리를 설명하기 위해 만든 예시다. 특정 서비스나 프로젝트의 실제 코드를 옮긴 것이 아니다.
브라우저가 화면을 만드는 전체 흐름
큰 흐름은 다음과 같이 볼 수 있다.
flowchart LR
HTML[HTML 바이트] --> DOM[DOM]
CSS[CSS 바이트] --> CSSOM[CSSOM]
DOM --> RT[Render Tree]
CSSOM --> RT
RT --> Layout[Layout]
Layout --> Paint[Paint]
Paint --> Composite[Composite]
Composite --> Screen[화면]각 단계는 서로 다른 질문에 답한다.
| 단계 | 브라우저가 답하는 질문 | 결과의 예 |
|---|---|---|
| DOM | 문서에 어떤 노드가 있는가? | main 아래에 article과 button이 있다 |
| CSSOM | 어떤 선택자와 선언이 적용되는가? | .card의 padding은 16px이다 |
| Render Tree | 실제로 그릴 노드는 무엇인가? | display: none 노드는 제외된다 |
| Layout | 각 박스의 크기와 위치는 어디인가? | 버튼은 (40, 120)에 80×32로 배치된다 |
| Paint | 어떤 순서와 색으로 픽셀을 그리는가? | 배경, 테두리, 글자를 그린다 |
| Composite | 분리된 레이어를 어떤 순서로 합칠까? | 이동한 레이어를 최종 화면에 합친다 |
이 구분을 알면 성능 문제를 “렌더링이 느리다”가 아니라 “한 프레임 안에서 강제 레이아웃이 반복된다”처럼 더 구체적으로 말할 수 있다.
DOM과 CSSOM은 언제 만들어질까
HTML 파서는 내려받은 문서를 위에서 아래로 읽으며 DOM을 점진적으로 만든다. CSS도 스타일시트를 내려받아 파싱한 뒤 CSSOM을 만든다. DOM이 완성되기 전에도 브라우저는 가능한 만큼 작업하지만, 현재 요소에 어떤 스타일이 적용되는지 알 수 없다면 정확한 렌더 트리를 만들 수 없다.
예를 들어 다음 HTML을 생각해 보자.
<main class="page">
<h1>렌더링 실험</h1>
<p class="message">화면에 표시될 문장</p>
<p class="hidden">렌더 트리에서 제외될 문장</p>
</main>
.message {
color: royalblue;
}
.hidden {
display: none;
}
.hidden 요소는 DOM에는 존재한다. 따라서 JavaScript로 검색하거나 속성을 바꿀 수 있다. 하지만 display: none이 적용된 동안에는 자신과 자손이 레이아웃 박스를 만들지 않으므로 렌더 트리에서 제외된다. 반면 visibility: hidden은 보이지 않더라도 공간은 유지한다. “보이지 않는다”는 결과만 보고 둘을 같은 것으로 취급하면 레이아웃 동작을 오해하게 된다.
DOM에 노드가 있다고 반드시 픽셀이 그려지는 것은 아니다. 가상 요소처럼 DOM 노드가 없어도 CSS에 의해 그려지는 대상도 있다.
렌더 트리에서 합성까지
렌더 트리가 만들어지면 브라우저는 뷰포트 크기, 글꼴, 박스 모델, 배치 규칙을 이용해 각 요소의 기하 정보를 계산한다. 이것이 레이아웃이다. 부모의 너비가 바뀌어 자식의 줄바꿈과 높이가 달라진다면 영향을 받는 범위의 레이아웃을 다시 계산해야 한다.
그다음 페인트 단계에서는 배경, 그림자, 테두리, 글자 같은 시각 요소를 그릴 명령을 만든다. 마지막 합성 단계에서는 필요한 콘텐츠를 레이어 단위로 결합해 화면에 보여 준다. 모든 요소가 독립 레이어가 되는 것은 아니며, 레이어를 많이 만드는 것 역시 메모리와 관리 비용이 든다.
다음 변경을 대략적인 출발점으로 삼을 수 있다. 단, 실제 영향 범위는 브라우저와 주변 스타일에 따라 달라지므로 프로파일링이 최종 판단 기준이다.
| 변경 예 | 다시 실행될 가능성이 큰 단계 |
|---|---|
width, height, padding |
Layout → Paint → Composite |
color, background, box-shadow |
Paint → Composite |
transform, opacity |
주로 Composite |
script 로딩 방식이 파싱을 바꾸는 방법
일반 <script>를 파서가 만나면 HTML 파싱을 멈추고 스크립트를 내려받아 실행할 수 있다. 스크립트가 뒤에 나올 DOM을 읽거나 문서에 직접 쓰는 등 파싱 결과에 영향을 줄 수 있기 때문이다. 외부 스크립트의 위치와 속성이 초기 표시 속도에 영향을 주는 이유다.
| 방식 | 다운로드 | 실행 시점 | 실행 순서 |
|---|---|---|---|
일반 script |
파싱을 막을 수 있음 | 만난 즉시 | 문서 순서 |
defer |
파싱과 병렬 | 문서 파싱 후 | 문서 순서 유지 |
async |
파싱과 병렬 | 다운로드 직후 | 완료된 순서 |
<!-- 서로 의존하는 앱 코드는 순서가 보장되는 defer가 이해하기 쉽다. -->
<script defer src="/assets/runtime.js"></script>
<script defer src="/assets/app.js"></script>
<!-- 다른 코드와 독립적인 분석 스크립트의 예 -->
<script async src="/assets/metrics.js"></script>
async를 무조건 빠른 선택으로 보면 안 된다. app.js가 runtime.js에서 만든 전역 값에 의존한다면 다운로드 완료 순서가 달라지는 것만으로 오류가 생길 수 있다.
레이아웃 스래싱을 만드는 코드
브라우저는 보통 여러 스타일 변경을 모아서 처리한다. 하지만 레이아웃 값 읽기와 스타일 쓰기를 반복해서 섞으면, 최신 값을 반환하기 위해 브라우저가 대기 중인 스타일과 레이아웃 계산을 즉시 수행해야 할 수 있다.
const cards = [...document.querySelectorAll(".card")];
// 나쁜 예: 읽기와 쓰기가 반복해서 교차한다.
for (const card of cards) {
const nextWidth = card.offsetWidth + 12;
card.style.width = `${nextWidth}px`;
}
이를 layout thrashing이라고 부른다. 다음처럼 읽기와 쓰기를 분리하면 브라우저가 작업을 묶기 쉬워진다.
const cards = [...document.querySelectorAll(".card")];
// 1. 필요한 레이아웃 값을 먼저 모두 읽는다.
const widths = cards.map((card) => card.offsetWidth);
// 2. 다음 프레임에 스타일 변경을 모아 적용한다.
requestAnimationFrame(() => {
cards.forEach((card, index) => {
card.style.width = `${widths[index] + 12}px`;
});
});
더 좋은 해결책은 JavaScript로 각 너비를 계산할 필요가 있는지부터 검토하는 것이다. flex, grid, clamp() 같은 CSS 배치 기능만으로 표현할 수 있다면 레이아웃 책임을 브라우저에 맡기는 편이 코드도 단순해진다.
transform과 opacity가 항상 빠른 것은 아니다
transform과 opacity는 요소의 배치 자체를 다시 계산하지 않고 합성 단계에서 처리될 가능성이 높아 애니메이션에 자주 사용된다.
.drawer {
transform: translateX(100%);
transition: transform 180ms ease;
}
.drawer[data-open="true"] {
transform: translateX(0);
}
하지만 “합성만 일어나므로 항상 공짜”는 아니다. 큰 레이어를 매 프레임 합성하면 GPU 메모리와 대역폭을 사용한다. will-change: transform을 많은 요소에 상시 지정하면 불필요한 레이어가 늘 수 있다. 필요한 요소에, 실제 변경 직전에 제한적으로 사용하는 편이 안전하다.
그림자, 필터, 큰 이미지, 레이어 수, 변경 영역에 따라 페인트와 합성 비용은 달라진다. 최종 판단은 대상 기기에서 측정한 프레임 기록으로 해야 한다.
개발자 도구로 확인할 것
성능 탭에서 느린 상호작용을 기록한 뒤 다음 순서로 본다.
- 긴 메인 스레드 작업이 입력을 막았는가?
- 한 프레임에
Layout이 여러 번 실행되는가? Recalculate Style과Paint의 범위가 예상보다 넓은가?- 애니메이션 중 프레임 시간이 예산을 넘는가?
- 레이어 수와 페인트 영역이 변경 후 실제로 줄었는가?
코드를 수정하기 전과 후를 같은 시나리오로 기록해야 한다. 한 번의 캡처만 보고 “빨라 보인다”고 판단하면 네트워크 캐시나 실행 시점의 차이를 최적화 효과로 착각하기 쉽다.
DOM은 문서 구조, CSSOM은 적용 가능한 스타일 규칙을 표현한다. 둘을 결합해 보이는 노드의 렌더 트리를 만든 뒤 크기와 위치를 계산하고 픽셀을 그린다.
결론
브라우저 렌더링 최적화의 출발점은 변경한 코드가 DOM, 스타일, 레이아웃, 페인트, 합성 중 어디까지 영향을 주는지 구분하는 것이다. 읽기와 쓰기를 묶고, 가능한 배치는 CSS에 맡기며, transform 같은 규칙도 실제 측정으로 확인해야 한다. 렌더링 파이프라인을 알고 나면 막연한 “DOM 최적화”가 아니라 재계산 범위를 줄이는 구체적인 선택을 할 수 있다.