HTML을 받은 순간부터 화면이 그려지기까지
web | 2026년 8월 26일
브라우저가 서버에서 HTML을 받으면 곧바로 화면이 나타날 것 같지만, HTML은 화면 그 자체가 아니다. 어떤 요소가 있고 서로 어떻게 연결되어 있는지 설명하는 문서에 가깝다.
브라우저는 이 문서를 자신이 다룰 수 있는 구조로 바꾸고 CSS를 적용한다. 각 요소의 위치와 크기를 계산하고, 무엇을 어떤 순서로 그릴지 정한 뒤, 여러 레이어를 합쳐야 비로소 사용자가 보는 화면이 만들어진다.
이 글은 이전 글인 「naver.com을 입력한 순간부터 HTML을 받기까지」의 다음 이야기다. 네트워크를 건너온 HTML의 첫 바이트를 받은 순간부터 브라우저가 화면에 픽셀을 표시하기까지의 과정을 따라가 본다.
HTML은 도착하는 동안에도 읽힌다
브라우저는 HTML 파일 전체가 도착할 때까지 기다리지 않는다. 네트워크를 통해 데이터가 들어오는 즉시 앞부분부터 읽기 시작한다. 문서가 길더라도 전체 다운로드가 끝나기 전에 화면의 일부를 먼저 보여 줄 수 있는 이유다.
HTML이 브라우저가 이해하는 구조로 바뀌는 과정은 다음과 같이 단순화할 수 있다.
바이트 → 문자 → 토큰 → 노드 → DOM
브라우저는 응답의 인코딩 정보에 따라 바이트를 문자로 해석한다. 그다음 시작 태그와 종료 태그, 속성과 텍스트를 토큰으로 구분한다. HTML 파서는 토큰을 하나씩 처리하며 노드를 만들고 이들의 관계를 트리로 연결한다.
아직 HTML을 받는 중이어도 이미 도착한 부분은 파싱할 수 있고, 파싱 중에 발견한 다른 자원은 별도로 내려받기 시작할 수 있다. 브라우저는 다운로드와 파싱, 렌더링 준비를 가능한 한 겹쳐서 수행한다.
HTML을 DOM으로 만든다
다음과 같은 HTML을 받았다고 가정해 보자.
<body>
<main>
<h1>오늘의 뉴스</h1>
<p>새로운 소식을 확인하세요.</p>
</main>
</body>
브라우저는 태그를 단순한 문자열로 보관하지 않는다. 요소 사이의 관계를 표현하는 DOM(Document Object Model)을 만든다.
body
└── main
├── h1
│ └── "오늘의 뉴스"
└── p
└── "새로운 소식을 확인하세요."
DOM에서 body는 main의 부모이고 h1과 p는 main의 자식이다. JavaScript가 document.querySelector("h1")로 요소를 찾거나 새로운 노드를 추가할 수 있는 것도 브라우저가 HTML을 이런 객체 구조로 바꾸었기 때문이다.
HTML 파서는 잘못된 문서를 만났다고 바로 작업을 포기하지 않는다. 닫는 태그가 빠졌거나 잘못 중첩된 마크업도 HTML 표준의 오류 복구 규칙에 따라 가능한 구조로 보정한다. 따라서 작성한 마크업과 개발자 도구에서 확인한 DOM이 항상 완전히 같지는 않을 수 있다.
DOM은 문서의 구조를 표현하지만 아직 각 요소의 색상이나 크기, 화면에서의 위치는 알지 못한다. 이를 결정하려면 CSS가 필요하다.
HTML을 읽다가 다른 자원을 만난다면
HTML에는 브라우저가 추가로 가져와야 할 자원의 주소도 포함된다.
<link rel="stylesheet" href="/style.css" />
<script src="/app.js"></script>
<img src="/news.jpg" alt="오늘의 뉴스" />
파서가 link, script, img 같은 요소를 만나면 CSS, JavaScript, 이미지 요청을 시작한다. 일반적으로 이미지는 요청만 시작하고 HTML 파싱을 계속할 수 있지만, 속성 없는 고전적인 script는 파싱을 중단시킬 수 있다.
브라우저에는 메인 파서보다 앞서 문서를 살피는 preload scanner도 있다. 메인 파서가 스크립트 실행 등으로 멈추더라도 앞으로 필요할 자원을 발견해 미리 요청함으로써 대기 시간을 줄인다.
다만 JavaScript가 동적으로 추가하는 자원이나 CSS 안의 background-image처럼 해당 파일을 해석해야 발견할 수 있는 자원도 있다. 중요한 자원이 늦게 발견될수록 첫 화면을 만드는 시간도 길어질 수 있다.
CSS를 CSSOM으로 만든다
CSS도 문자열 그대로 화면에 적용되지 않는다. 브라우저는 CSS를 파싱해 CSSOM(CSS Object Model)을 만든다.
브라우저는 기본 스타일과 작성자가 선언한 규칙을 함께 살핀다. 선택자가 어떤 요소와 일치하는지 찾고, 상속과 명시도, 선언 순서 같은 캐스케이드 규칙을 계산해 각 요소의 최종 스타일을 결정한다.
외부 CSS를 만났다고 HTML 파싱 자체가 반드시 멈추는 것은 아니다. DOM은 계속 만들 수 있다. 그러나 현재 화면에 적용되는 스타일시트는 기본적으로 렌더링을 차단한다. CSSOM이 없는 상태에서 먼저 그리면 스타일이 없는 화면이 잠깐 보이는 FOUC(Flash of Unstyled Content)가 발생할 수 있기 때문이다.
CSS는 JavaScript 실행에도 영향을 준다. JavaScript는 계산된 스타일을 읽거나 요소의 크기를 측정할 수 있다. 앞에서 선언된 스타일시트가 준비되지 않았다면 브라우저는 정확한 결과를 위해 스크립트 실행을 기다리게 할 수 있다.
모든 CSS가 첫 화면을 막는 것은 아니다. 예를 들어 media="print"인 스타일시트는 화면을 처음 그리는 데 필요하지 않으므로 현재 렌더링을 차단하지 않는다. 첫 화면에 필요한 CSS와 나중에 사용해도 되는 CSS를 구분하는 일이 중요한 이유다.
JavaScript는 언제 HTML 파싱을 멈출까
async, defer, type="module"이 없는 외부 스크립트를 만나면 브라우저는 HTML 파싱을 멈춘다. 파일을 내려받고 코드를 실행한 뒤에야 다음 HTML을 읽는다.
JavaScript가 DOM을 읽고 바꿀 수 있기 때문이다. document.write()로 마크업을 삽입하거나 이미 만들어진 노드를 제거할 수도 있다. 브라우저는 문서 순서에 맞는 결과를 보장하기 위해 스크립트를 만난 지점에서 실행을 완료한다.
일반 script : 파싱 중단 → 다운로드 → 실행 → 파싱 재개
async : 병렬 다운로드 → 준비되는 즉시 실행
defer : 병렬 다운로드 → HTML 파싱 완료 후 문서 순서대로 실행
async 스크립트는 다운로드가 끝나는 즉시 실행된다. 실행 중에는 파싱이 잠시 멈출 수 있고 여러 스크립트의 순서도 보장되지 않는다. 따라서 다른 코드나 DOM 구조에 의존하지 않는 분석 도구 등에 적합하다.
defer 스크립트는 HTML 파싱이 끝난 뒤 문서에 등장한 순서대로 실행된다. DOM 전체가 필요한 애플리케이션 코드나 서로 의존하는 스크립트에 사용할 수 있다. <script type="module">도 기본적으로 파싱을 막지 않고 지연 실행되는 특성을 가진다.
여기서 파싱 차단과 렌더링 차단은 구분해야 한다. 일반 스크립트는 기본적으로 HTML 파서를 멈춘다. 실행 시간이 길면 메인 스레드를 점유해 화면도 늦출 수 있지만, 무엇을 직접 차단하는지 나누어 보아야 문제를 정확히 진단할 수 있다.
DOM과 CSSOM으로 Render Tree를 만든다
브라우저는 DOM의 구조와 CSSOM의 스타일을 결합해 화면을 그리는 데 사용할 Render Tree를 만든다.
DOM + CSSOM → Render Tree
DOM에 존재한다고 모두 Render Tree에 포함되는 것은 아니다. head, meta, script처럼 시각적으로 표현되지 않는 요소는 그릴 대상이 아니다. display: none인 요소도 레이아웃에 참여하지 않으므로 제외된다. 반면 visibility: hidden인 요소는 보이지 않지만 공간을 차지하므로 레이아웃에 참여한다.
반대로 ::before와 ::after로 만든 콘텐츠는 DOM 노드가 아니지만 화면에는 나타날 수 있다. DOM은 문서의 의미 구조이고 Render Tree는 화면을 위한 시각적 구조인 셈이다.
실제 브라우저 엔진은 다른 내부 용어나 여러 중간 구조를 사용할 수 있다. 여기서는 렌더링 흐름을 설명하기 위한 개념적인 이름으로 사용한다.
Layout: 어디에 얼마나 크게 놓을지 계산한다
화면에 그릴 대상을 정했어도 정확한 위치와 크기는 아직 알 수 없다. Layout 단계에서는 각 요소가 뷰포트에서 차지할 공간을 계산한다.
width: 80%는 부모의 크기가 정해져야 실제 너비를 알 수 있고, font-size: 2rem은 루트 요소의 글꼴 크기를 기준으로 계산해야 한다. 브라우저는 부모와 자식의 제약, 박스 모델, 줄바꿈과 글꼴 등을 고려해 상대적인 값을 실제 좌표와 크기로 바꾼다.
요소 하나의 변화가 주변까지 영향을 미칠 수도 있다. 부모의 너비가 줄어 텍스트가 다음 줄로 넘어가면 높이가 달라지고 아래 요소의 위치도 바뀐다. 기존 화면에서 위치와 크기를 다시 계산하는 작업을 흔히 Reflow라고 한다. 브라우저 문서와 도구에서는 Layout이라는 표현을 주로 사용하며 두 용어가 혼용되기도 한다.
JavaScript에서 크기를 바꾼 직후 위치를 읽는 작업을 반복하면 최신 값을 돌려주기 위해 Layout이 강제로 여러 번 수행될 수 있다. 이를 layout thrashing이라 하며 렌더링 성능을 떨어뜨리는 대표적인 패턴이다.
Paint: 무엇을 어떤 순서로 그릴지 기록한다
Layout으로 위치와 크기를 정했다면 Paint 단계에서는 각 요소를 어떻게 표현할지 정한다.
브라우저는 배경색, 테두리, 글자, 이미지, 그림자 같은 속성을 살피고 그리기 명령을 만든다. DOM 순서만 따르는 것이 아니라 z-index와 stacking context 등을 고려해 앞뒤 순서를 결정한다.
Paint는 무엇을 어떤 순서로 그릴지 기록하는 단계에 가깝다. 이 명령을 실제 픽셀로 변환하는 작업을 Rasterize라고 한다. 브라우저는 화면 전체를 하나의 이미지로 매번 처리하기보다 필요한 영역과 레이어를 나누어 래스터화할 수 있다.
Composite: 여러 레이어를 하나의 화면으로 합친다
브라우저는 페이지 일부를 별도의 합성 레이어로 분리할 수 있다. 각 레이어가 래스터화되면 Composite 단계에서 올바른 위치와 순서로 합쳐 최종 화면을 만든다. 이 작업은 GPU의 도움을 받을 수 있다.
이미 별도 레이어가 된 요소의 transform이나 opacity를 변경하면 Layout과 Paint 없이 레이어의 위치나 투명도만 바꾸어 합성할 수 있다. 부드러운 애니메이션에 두 속성이 자주 권장되는 이유다.
그렇다고 모든 요소를 레이어로 만드는 것이 빠른 것은 아니다. 레이어마다 메모리와 관리 비용이 든다. will-change를 근거 없이 많은 요소에 적용하면 오히려 성능을 해칠 수 있으므로 실제 병목을 확인한 경우에만 사용하는 편이 안전하다.
화면이 바뀔 때 모든 단계를 다시 거칠까
첫 화면 뒤에도 렌더링은 계속된다. JavaScript가 DOM이나 스타일을 바꾸고, 사용자가 스크롤하며, 이미지와 웹 폰트가 늦게 도착할 수 있기 때문이다.
브라우저가 항상 전체 파이프라인을 반복하는 것은 아니다. 바뀐 속성에 따라 필요한 단계만 다시 수행할 수 있다.
width 변경 → Layout → Paint → Composite
background 변경 → Paint → Composite
transform 변경 → Composite만으로 처리될 수 있음
이 표는 이해를 위한 일반화다. 실제 범위는 브라우저 엔진과 요소의 상태, 레이어 구성에 따라 달라진다. 특정 속성의 비용을 외우기보다 DevTools의 Performance 패널처럼 실제 결과를 보여 주는 도구로 확인하는 것이 정확하다.
첫 화면은 언제 보였다고 말할 수 있을까
DOMContentLoaded는 HTML 파싱과 defer 및 모듈 스크립트 실행이 완료된 시점을 나타낸다. load는 이미지와 스타일시트 같은 의존 자원까지 로드된 시점이다. 두 이벤트 모두 사용자가 실제로 콘텐츠를 본 순간과 같지는 않다.
사용자 경험에 가까운 지표는 다음과 같다.
- FP(First Paint)는 배경색처럼 무엇이든 처음 그려진 시점이다.
- FCP(First Contentful Paint)는 텍스트나 이미지 같은 콘텐츠가 처음 나타난 시점이다.
- LCP(Largest Contentful Paint)는 뷰포트 안 주요 콘텐츠로 볼 수 있는 큰 요소가 그려진 시점을 측정한다.
HTML이 빨리 도착해도 렌더링을 막는 CSS가 늦거나 JavaScript가 메인 스레드를 오래 점유하면 FCP와 LCP는 늦어진다. 반대로 모든 자원의 다운로드가 끝나지 않아도 첫 화면에 필요한 구조와 스타일이 준비되면 일부 콘텐츠를 먼저 보여 줄 수 있다.
이전 글의 네트워크 시간과 이번 글의 렌더링 시간은 분리된 문제가 아니다. 핵심 자원이 얼마나 빨리 도착하고 브라우저가 얼마나 빠르게 처리하는지가 함께 체감 로딩 시간을 만든다.
전체 과정 한눈에 보기
HTML 수신
→ HTML 파싱
→ DOM 생성
→ CSS 파싱
→ CSSOM 생성
→ Render Tree 생성
→ Layout
→ Paint
→ Rasterize
→ Composite
→ 화면 표시
이 흐름은 이해를 위한 단순화다. 실제 브라우저에서는 HTML을 받는 동안 DOM을 만들고 추가 자원을 요청하며, 준비된 부분을 먼저 렌더링할 수 있다. 첫 화면 이후에는 필요한 단계만 다시 실행한다. 브라우저 엔진마다 내부 자료구조와 세부 구현에도 차이가 있다.
중요한 것은 단계의 이름을 외우는 일이 아니다. 각 단계가 어떤 정보를 만들고, 무엇을 기다리며, 어떤 변화에 다시 실행되는지 이해하는 것이다.
마치며
HTML은 화면의 완성본이 아니라 브라우저가 화면을 만들기 위한 출발점이다. 브라우저는 HTML을 DOM으로, CSS를 CSSOM으로 바꾸고 화면에 필요한 구조를 만든다. 위치와 크기를 계산하고 그리기 명령을 픽셀로 변환한 뒤 여러 레이어를 합쳐 사용자에게 보여 준다.
이 과정을 알면 화면이 늦게 나타나는 문제도 구체적으로 나눌 수 있다. HTML이나 CSS의 도착이 늦은지, JavaScript가 파싱이나 메인 스레드를 오래 막는지, 불필요한 Layout과 Paint가 반복되는지 살펴볼 수 있다.
주소창에 naver.com을 입력하는 한 번의 동작은 HTML을 가져오는 것으로 끝나지 않는다. 전달받은 문서가 브라우저 안에서 구조와 스타일, 좌표와 픽셀로 바뀌어야 비로소 우리가 아는 웹페이지가 된다.