Ray Book
브라우저 렌더링 파이프라인

렌더 트리와 레이아웃

DOM과 CSSOM이 만나 렌더 트리가 되고, 각 요소의 위치와 크기가 결정되는 레이아웃 과정을 시각화합니다

browserrenderingrender-treelayoutbox-model

DOM + CSSOM = 렌더 트리

이전 글에서 HTML 바이트가 DOM 트리로, CSS 바이트가 CSSOM 트리로 변환되는 과정을 살펴봤습니다. 이 두 트리가 모두 완성되면, 브라우저는 이를 결합해 렌더 트리를 만듭니다.

렌더 트리 구축 과정은 다음과 같습니다.

  1. DOM 트리의 루트 (html)에서 시작해 보이는 노드를 순회합니다
  2. 각 보이는 노드에 대해 CSSOM에서 일치하는 규칙을 찾아 **계산된 스타일 (computed style)**을 적용합니다
  3. 보이는 노드와 그 스타일을 렌더 트리에 추가합니다
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이 렌더 트리로 결합되고, 박스 모델이 적용된 후 레이아웃이 계산되는 전체 과정을 단계별로 확인하세요.

DOM + CSSOM
DOM Tree
<html>
<head>
<meta>
<body>
<div.container>
<h1>
"Hello"
<p.hidden>
"숨겨진 텍스트"
<p.intro>
"Welcome"
<script>
CSSOM Tree
StyleSheet
body
margin: 0; font: 16px/1.5 sans-serif
.container
width: 80%; margin: 0 auto
h1
font-size: 24px; color: navy
h1::before
content: "# "; color: gray
.hidden
display: none
.intro
font-size: 16px; padding: 8px
DOM 트리와 CSSOM 트리가 모두 완성된 상태입니다. 브라우저는 이 두 트리를 결합해 렌더 트리를 만듭니다. DOM은 문서의 구조를, CSSOM은 각 요소에 적용될 스타일 정보를 담고 있습니다.

박스 모델

렌더 트리의 각 요소는 CSS 박스 모델에 따라 사각형 박스를 생성합니다. 박스는 안쪽에서 바깥쪽으로 네 영역으로 구성됩니다.

margin → border → padding → content
(바깥)                        (안쪽)

각 영역의 역할:

영역설명기본 배경
content텍스트, 이미지 등 실제 내용이 표시되는 영역background 적용
paddingcontent와 border 사이의 안쪽 여백background 적용
border요소의 테두리. 두께, 스타일, 색상 지정 가능border-color 적용
margin다른 요소와의 바깥 여백. 투명 (배경 없음)항상 투명

box-sizing의 차이

box-sizing 속성은 widthheight가 어느 영역까지를 의미하는지 결정합니다.

/* 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)를 계산하는 것입니다.

레이아웃은 렌더 트리의 루트부터 시작해 자식 방향으로 재귀 순회합니다.

  1. 뷰포트 크기를 초기 컨테이닝 블록(initial containing block)으로 설정
  2. 루트 요소의 크기를 뷰포트에 맞게 결정
  3. 자식 요소를 순회하며 각 박스의 좌표와 크기를 계산
  4. 퍼센트 값은 부모 요소의 해당 속성 기준으로 해석
  5. 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-heightvertical-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) 단계로 넘어갑니다.

다음 글에서는 페인트 순서, 레이어 분리, 래스터라이제이션 과정을 살펴봅니다.