DOM + CSSOM = 렌더 트리
이전 글에서 HTML 바이트가 DOM 트리로, CSS 바이트가 CSSOM 트리로 변환되는 과정을 살펴봤습니다. 이 두 트리가 모두 완성되면, 브라우저는 이를 결합해 렌더 트리를 만듭니다.
렌더 트리 구축 과정은 다음과 같습니다.
- DOM 트리의 루트 (
html)에서 시작해 보이는 노드 를 순회합니다 - 각 보이는 노드에 대해 CSSOM에서 일치하는 규칙을 찾아 계산된 스타일 (computed style) 을 적용합니다
- 보이는 노드와 그 스타일을 렌더 트리에 추가합니다
DOM Tree + CSSOM Tree
|
결합 (Attachment)
|
Render Tree (보이는 노드 + 계산된 스타일)렌더 트리의 각 노드를 Blink 엔진에서는 LayoutObject, WebKit에서는 RenderObject로 부릅니다. 이름은 다르지만 역할은 동일합니다, 화면에 그려질 요소와 그 스타일 정보를 담고 있습니다.
렌더 트리에서 빠지는 것들
모든 DOM 노드가 렌더 트리에 포함되는 것은 아닙니다. 다음 요소들은 렌더 트리에서 제외 됩니다.
- 비시각적 요소 :
<head>,<meta>,<script>,<link>등 화면에 직접 표시되지 않는 요소 display: none요소 : 이 속성이 적용된 요소와 그 모든 자식 노드 가 렌더 트리에서 완전히 제거됩니다. 공간도 차지하지 않습니다
.hidden {
display: none; /* 렌더 트리에서 완전 제거 → 레이아웃에 영향 없음 */
}중요한 점은 부모에 display: none이 적용되면 자식도 렌더 트리에서 제외된다는 것입니다. 이것은 CSS 상속이 아니라, 부모의 박스 자체가 생성되지 않기 때문입니다. 부모가 display: none이면 자식이 display: block을 명시해도 렌더 트리에 포함되지 않습니다.
렌더 트리에 추가되는 것들
반대로, DOM에는 존재하지 않지만 렌더 트리에 추가되는 요소도 있습니다.
- 의사 요소 (pseudo-elements) :
::before,::after는 CSS에서 정의되지만 DOM 트리에는 없습니다. 렌더 트리 구축 시 해당 위치에 삽입됩니다
h1::before {
content: "# "; /* DOM에는 없지만 렌더 트리에는 포함 */
color: gray;
}- 익명 박스 (anonymous boxes) : 블록 요소 안에 인라인 콘텐츠와 블록 콘텐츠가 섞여 있으면, 브라우저가 자동으로 익명 블록 박스를 생성해 인라인 콘텐츠를 감쌉니다
시각화
아래 시각화에서 DOM + CSSOM이 렌더 트리로 결합되고, 박스 모델이 적용된 후 레이아웃이 계산되는 전체 과정을 단계별로 확인하세요.
박스 모델
렌더 트리의 각 요소는 CSS 박스 모델에 따라 사각형 박스를 생성합니다. 박스는 안쪽에서 바깥쪽으로 네 영역으로 구성됩니다.
margin → border → padding → content
(바깥) (안쪽)각 영역의 역할:
| 영역 | 설명 | 기본 배경 |
|---|---|---|
| content | 텍스트, 이미지 등 실제 내용이 표시되는 영역 | background 적용 |
| padding | content와 border 사이의 안쪽 여백 | background 적용 |
| border | 요소의 테두리. 두께, 스타일, 색상 지정 가능 | border-color 적용 |
| margin | 다른 요소와의 바깥 여백. 투명 (배경 없음) | 항상 투명 |
box-sizing의 차이
box-sizing 속성은 width와 height가 어느 영역까지를 의미하는지 결정합니다.
/* content-box (기본값): width = content 영역만 */
.box-content {
box-sizing: content-box;
width: 200px;
padding: 20px;
border: 2px solid;
/* 실제 차지 폭: 200 + 40 + 4 = 244px */
}
/* border-box: width = content + padding + border */
.box-border {
box-sizing: border-box;
width: 200px;
padding: 20px;
border: 2px solid;
/* 실제 차지 폭: 200px (content는 200 - 40 - 4 = 156px) */
}대부분의 CSS 리셋에서 *, *::before, *::after { box-sizing: border-box; }를 적용하는 이유가 바로 이것입니다. border-box가 직관적이고 레이아웃 계산이 예측 가능합니다.
레이아웃 (Reflow)
렌더 트리가 완성되고 박스 모델이 적용되면, 브라우저는 레이아웃 (또는 Reflow) 단계를 수행합니다. 레이아웃의 목표는 뷰포트 안에서 각 박스의 정확한 위치(x, y)와 크기(width, height)를 계산 하는 것입니다.
레이아웃은 렌더 트리의 루트부터 시작해 자식 방향으로 재귀 순회합니다.
- 뷰포트 크기 를 초기 컨테이닝 블록(initial containing block)으로 설정
- 루트 요소의 크기를 뷰포트에 맞게 결정
- 자식 요소를 순회하며 각 박스의 좌표와 크기를 계산
- 퍼센트 값 은 부모 요소의 해당 속성 기준으로 해석
auto값 은 컨텍스트에 따라 계산 (예:margin: 0 auto는 남은 공간을 균등 분배)
뷰포트: 1024px
body: width = 1024px (기본 width: auto = 부모 100%)
.container: width = 80% = 819px
margin: 0 auto = 좌: 102px, 우: 103px
h1: width = 819px (블록 요소 = 부모 100%)
height = 33px (콘텐츠 기반)
position: (102, 0)
p: width = 819px
height = 40px (16px 폰트 x 1.5 줄높이 + 패딩)
position: (102, 33)Normal Flow
특별한 배치 속성 (float, position: absolute/fixed, flex, grid)이 적용되지 않은 요소는 Normal Flow 에 따라 배치됩니다. Normal Flow는 두 가지 포맷팅 컨텍스트로 구성됩니다.
Block Formatting Context (BFC)
블록 레벨 요소 (div, p, h1 등)는 수직 방향 으로 쌓입니다.
- 각 블록 박스는 새 줄에서 시작
- 부모의 content 영역 전체 너비를 차지 (width: auto)
- 수직 마진은 마진 겹침 (margin collapsing) 이 발생
/* 마진 겹침 예시 */
.box-a { margin-bottom: 20px; }
.box-b { margin-top: 30px; }
/* 두 박스 사이 간격: max(20, 30) = 30px (합산이 아님) */Inline Formatting Context (IFC)
인라인 레벨 요소 (span, a, em, 텍스트 노드)는 수평 방향 으로 나열됩니다.
- 한 줄에 공간이 부족하면 다음 줄로 넘어감 (줄 바꿈)
- 수직 마진은 무시됨
line-height와vertical-align으로 세로 배치 제어
실무 사례
display:none vs visibility:hidden vs opacity:0
이 세 속성은 모두 요소를 "숨기지만", 렌더 트리와 레이아웃에 미치는 영향이 완전히 다릅니다.
| 속성 | 렌더 트리 | 레이아웃 공간 | 이벤트 | 접근성 |
|---|---|---|---|---|
display: none | 제외 | 없음 | 불가 | 스크린 리더 무시 |
visibility: hidden | 포함 | 차지함 | 불가 | 스크린 리더 무시 |
opacity: 0 | 포함 | 차지함 | 가능 | 스크린 리더 읽음 |
/* 렌더 트리에서 완전 제거, 레이아웃 재계산 발생 */
.removed { display: none; }
/* 렌더 트리에 포함, 투명하지만 자리 차지, 리페인트만 발생 */
.invisible { visibility: hidden; }
/* 렌더 트리에 포함, 투명하지만 자리 차지 + 클릭 가능, 리페인트만 발생 */
.transparent { opacity: 0; }성능 관점에서 display: none은 토글할 때마다 레이아웃 재계산(reflow)을 유발합니다. 빈번하게 토글하는 요소라면 visibility: hidden이나 opacity: 0이 더 효율적입니다.
레이아웃 성능 팁
레이아웃은 비용이 큰 연산입니다. 다음 사항을 고려하세요.
- DOM 깊이 최소화 : 레이아웃은 트리를 재귀 순회하므로 깊이가 깊을수록 느립니다
- 강제 동기 레이아웃 회피 :
offsetWidth읽기 → 스타일 변경 →offsetWidth읽기 패턴은 브라우저가 레이아웃을 여러 번 강제 실행하게 만듭니다 - 레이아웃 범위 제한 :
contain: layout을 사용하면 해당 요소 내부의 변경이 외부 레이아웃에 영향을 주지 않습니다
// 나쁜 패턴: 강제 동기 레이아웃 (layout thrashing)
for (const el of elements) {
const width = el.offsetWidth; // 레이아웃 강제 실행
el.style.width = width + 10 + "px"; // 레이아웃 무효화
}
// 좋은 패턴: 읽기/쓰기 분리
const widths = elements.map((el) => el.offsetWidth); // 읽기 일괄
elements.forEach((el, i) => {
el.style.width = widths[i] + 10 + "px"; // 쓰기 일괄
});다음 단계
렌더 트리의 각 노드에 정확한 위치와 크기가 결정되었습니다. 이제 브라우저는 이 기하학적 정보를 바탕으로 실제 픽셀을 화면에 그리는 페인팅 (Painting) 단계로 넘어갑니다.
다음 글에서는 페인트 순서, 레이어 분리, 래스터라이제이션 과정을 살펴봅니다.