"주소창에 URL 한 줄 쳤을 뿐인데, 브라우저 안에서는 생각보다 많은 일이 벌어진다."
- 렌더링 파이프라인이 어떻게 흐르는지
- CSS/JS가 렌더링을 어떻게 막거나 도와주는지
- DOM / CSSOM / Render Tree / Layout / Paint / Composite 관계
- 레이아웃 스래싱에서 왜 성능이 깨지는지
결론 먼저
먼저, 큰 그림만 잡고 가자.
- 브라우저는 HTML, CSS, JS를 받아서 다음 순서로 화면을 만든다.
- HTML 파싱 → DOM 트리
- CSS 파싱 → CSSOM 트리
- DOM + CSSOM → Render Tree
- Render Tree 기반으로 Layout → Paint → Composite
- CSS는 렌더‑블로킹이 될 수 있다.
- CSSOM이 완성되어야 Render Tree를 만들 수 있기 때문이다.
- JS는 파서‑블로킹이 될 수 있다.
- 기본 <script>는 HTML 파싱을 잠시 멈추게 만든다.
- defer/async/type="module"로 어느 정도 제어 가능하다.
- 성능 이슈에서 자주 나오는 게 레이아웃 스래싱(Layout Thrashing)이다.
- "읽기(레이아웃 계산) → 쓰기(스타일 변경)"가 프레임 안에서 섞여 반복될 때 터진다.
- 읽기 묶고 쓰기 묶는 패턴으로 바꾸는 게 핵심.
이제 위 내용들을 조금 더 그림 + 코드와 함께 풀어본다.
1. 렌더링 파이프라인
먼저 브라우저가 화면을 그리는 전체 흐름부터 보자
(네트워크) ➞ HTML / CSS / JS 다운로드
│
├─ HTML 파싱 ──▶ DOM 트리
├─ CSS 파싱 ──▶ CSSOM 트리
└─ JS 실행 ──▶ (파서 차단 여부 주의)
DOM + CSSOM ➞ Render Tree ──▶ Layout ──▶ Paint ──▶ Composite ─▶ 화면
(보이는 것만) (크기/좌표) (픽셀로 그림) (레이어 합성)
위 과정을 그림으로 보면 이런 느낌이다.

각 단계는 대략 이런 역할을 한다.
- DOM
- HTML을 파싱해서 만든 문서 구조 트리
- 태그들이 노드(Node)로 이어진 계층 구조
- CSSOM
- CSS를 파싱해서 만든 스타일 규칙 트리
- 어떤 선택자에 어떤 스타일이 적용되는지 정보 포함
- Render Tree
- DOM + CSSOM을 결합해서 만든 화면에 보이는 노드만 모은 트리
- display: none 요소는 여기서 제외된다.
- visibility: hidden은 자리는 차지하지만 안 보이는 상태.
- Layout (=Reflow)
- Render Tree 기준으로 각 요소의 크기와 위치(좌표)를 계산한다.
- Paint
- Layout 결과를 바탕으로 요소들을 픽셀로 그리는 단계
- Composite
- 여러 레이어를 GPU에서 합성해서 최종 화면을 만든다.
실무에서는 이 전체 과정을 줄여서 그냥 "렌더링 파이프라인"이라고 부르는 경우가 많다.
2. 쉬운 용어 정리
용어가 헷갈리기 쉬운 부분들을 먼저 정리해 보자.
렌더‑블로킹(Render‑blocking)
- 브라우저가 화면을 그리기 전에 반드시 준비되어야 하는 리소스
- 준비되기 전까지 렌더링을 멈춰야 한다.
- 대표적인 예가 CSS다.
- CSSOM이 완성되어야 Render Tree를 만들 수 있기 때문.
파서‑블로킹(Parser‑blocking)
- HTML을 읽는 파서가 잠시 멈추는 상황
- 기본 <script> 태그를 만나면 이렇게 된다.
- HTML 파싱 중단
- 스크립트 다운로드 및 실행
- 실행이 끝나면 파싱 재개
메인 파서 vs Preload Scanner
- 메인 파서(Main Parser)
- HTML을 읽어서 DOM을 만드는 본 담당자
- Preload Scanner(= Speculative Parser)
- 메인 파서가 멈춰 있어도
- 내부에서 링크 / 스크립트 / 이미지 같은 리소스를 미리 찾아서 다운로드를 시작한다.
- 덕분에 체감 로딩 속도가 빨라진다.
3. JavaScript와 렌더링
이제 자바스크립트가 이 파이프라인에 어떻게 영향을 주는지 보자.
스크립트 로딩 옵션 정리
속성 파싱과 병행 여부 실행 시점 파서 차단 여부 특징
| 기본 <script> | 경우에 따라 다름 | 도착 즉시 실행 | ✅ 블로킹 | HTML 파싱을 잠시 멈춘다 |
| defer | ✅ 병행 | DOMContentLoaded 직전, 순서 보장 | ❌ 비차단 | 일반적으로 권장 옵션 |
| async | ✅ 병행 | 도착 즉시 실행 | ❌ 비차단 | 실행 순서가 보장되지 않는다 |
| type="module" | ✅ 의존성 병렬 | 기본적으로 defer처럼 동작 | ❌ 비차단 | 모듈 스코프, 중복 로드 방지 |
실무에서 가장 무난한 조합은 이 두 가지다.
<script src="/app.js" defer></script>
<script type="module" src="/app.mjs"></script>
생각 없이 <script>만 잔뜩 쓰면,
파서‑블로킹 때문에 초기 렌더링이 불필요하게 늦어질 수 있다는 걸 항상 염두에 두는 게 좋다.
4. 브라우저를 구성하는 주요 컴포넌트
조금 더 위에서 보면, 브라우저는 여러 컴포넌트가 모여 있는 하나의 프로그램이다.

각 박스가 하는 일은 대략 이렇다.
| 컴포넌트 | 하는 일 |
| User Interface | 주소창, 뒤로/앞으로, 북마크 등 브라우저 UI 전체 |
| Browser Engine | UI와 렌더링 엔진 사이를 조율하는 중간 관리자 |
| Rendering Engine | HTML/CSS 파싱 → 스타일 계산 → Layout/Paint/Composite |
| Networking | HTTP/3, 캐시, 요청 우선순위 관리 |
| JavaScript Engine | JS 파싱/컴파일/실행 |
| UI Backend / Graphics | 폰트 렌더링, 그리기 API(플랫폼 추상화) |
| Storage | Cookies, localStorage, IndexedDB, Cache API 등 |
그리고 중요한 포인트 하나
브라우저에서 JS는 렌더링 엔진/네트워크 엔진을 직접 만지지 않는다.
DOM / CSSOM / BOM 같은 Web API를 통해서만 페이지를 조작한다.
우리가 만지는 건 결국 Web API를 통해 노출된 인터페이스일 뿐,
브라우저 내부 모듈(렌더링 엔진, 네트워크 스택)을 바로 건드리는 건 아니다.
5. 트리(Tree)와 노드(Node)
렌더링 파이프라인에서 자꾸 DOM, CSSOM, Render Tree 같은 말이 나오는데,
결국 이 모든 것들은 트리 구조다.
트리와 노드
- 트리(Tree)
- 부모 → 자식으로 이어진 계층 구조 자료구조
- 노드(Node)
- 트리를 이루는 한 요소
- 값과 부모/자식 관계를 가진다.
DOM 트리 예시
DOM 트리:
└── div (id="container")
├── p (text: "Hello World!")
└── a (href="https://example.com", text: "Link")
CSSOM 트리 예시
CSSOM 트리:
├── p (선택자)
│ ├── color: red
│ └── font-size: 16px
└── div (선택자)
└── background-color: blue
정리하면
DOM = 구조/내용, CSSOM = 스타일 규칙.
이 둘을 합쳐서 Render Tree를 만들고,
Render Tree를 기준으로 Layout → Paint가 진행된다.
6. 레이아웃 스래싱(Layout Thrashing)
이제 성능 얘기를 조금만 하자
레이아웃 스래싱이란?
- 한 프레임 안에서 레이아웃 계산(읽기) 직후에
- 바로 스타일 변경(쓰기)을 섞어서 반복하는 패턴
이렇게 되면 브라우저는
- "읽기 → 레이아웃 재계산"
- "쓰기(스타일 변경) → 다시 레이아웃/페인트 필요"
이 과정을 계속 반복하게 되고, 결국 프레임 안에서 레이아웃/페인트가 여러 번 강제로 일어나는 상황이 된다.
안 좋은 예 (읽고→바로 쓰고→다시 읽고… 반복)
const el = document.querySelector('.box');
// 강제 레이아웃 읽기
const w = el.offsetWidth;
// 곧바로 쓰기(스타일 변경)
el.style.width = (w + 10) + 'px';
// 다시 읽기 → 또 레이아웃 계산
const h = el.offsetHeight;
el.style.height = (h + 10) + 'px';
위 코드는 읽기와 쓰기가 섞여 있어서 레이아웃 스래싱을 유발할 수 있다.
좋은 패턴 (읽기 묶고 → 쓰기 묶기)
const el = document.querySelector('.box');
// 1) 읽기만 먼저
const w = el.offsetWidth;
const h = el.offsetHeight;
// 2) 쓰기는 한 번에 (가능하면 다음 프레임에)
requestAnimationFrame(() => {
el.style.width = (w + 10) + 'px';
el.style.height = (h + 10) + 'px';
});
실무에서 기억해 두면 좋은거!
- "읽기 → 쓰기" 순서를 지키고, 프레임 안에서 섞지 않기
- 자잘한 스타일 변경은 클래스 토글로 묶어서 한 번에 적용하기
- 애니메이션은 가능하면 transform/opacity 위주로 설계하기
- 이 둘은 합성(Composite) 단계에서 처리되어 Layout/Paint 비용이 적다.
- 스크롤 측정 등 빈번한 읽기 작업은 requestAnimationFrame 타이밍에 모아서 처리하기
7. 체크리스트
실제 코드 짤 때, 대략 이렇게 스스로 체크하면 도움이 된다.
- 초기 렌더에 꼭 필요한 크리티컬 CSS만 <head>에 두었는가?
- 나머지 CSS는 지연 로드 또는 코드 스플리팅으로 나눠두었는가?
- 스크립트는 가능한 한 defer/type="module"을 쓰고,
정말 필요할 때만 동기 <script>를 쓰고 있는가? - DOM 읽기/쓰기를 섞어서 레이아웃 스래싱을 만들고 있지 않은가?
- 애니메이션은 가능한 transform/opacity 위주로 설계했는가?
8. 참조
마지막으로, 참고했던 문서들을 다시 적어 둔다.
- https://stackoverflow.com/questions/7515227/describe-the-page-rendering-process-in-a-browser
- https://codeburst.io/how-browsers-work-6350a4234634
- https://github.com/alex/what-happens-when?tab=readme-ov-file#browser
- 이미지 출처: https://codeburst.io/how-browsers-work-6350a4234634
오늘 글은 전체적인 브라우저 렌더링 흐름을 한 번에 훑어보는 느낌으로 정리했다.
다음에 성능 최적화나 Web Vitals 같은 걸 볼 때,
"아, 이게 Layout 단계에서 무거운 건가? Paint/Composite 문제인가?"를 구분할 수 있으면
디버깅이 훨씬 덜 막막해진다.
'FrontEnd > web' 카테고리의 다른 글
| [실무 기록] <link rel="stylesheet">로 CSS 불러오는데 간헐적으로 CORS 에러가 나던 이유 (해결: crossorigin 제거) (1) | 2026.01.13 |
|---|---|
| [실무 기록] Chrome Inspect에서 Pending authentication만 뜨고 안드로이드 디버깅이 안 될 때 (0) | 2026.01.12 |
| [실무 기록] 웹뷰 브릿지, 이거 누가 책임져야 하지? (0) | 2025.12.10 |
| 검색 엔진 크롤링과 자바스크립트 렌더링 정리 (3) | 2025.09.08 |
| 동기식(Synchronous) vs 비동기식(Asynchronous) (2) | 2024.12.06 |
