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) 단계로 넘어갑니다.

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