browser · high priority
브라우저 렌더링 — *Critical Rendering Path* 와 *Pixel Pipeline*
HTML 한 덩어리가 *어떻게 픽셀이 되는가* — Parse → Style → Layout → Paint → Composite
학습 개요
탄생 배경
쉬운 설명
복잡한 개념을 실생활 비유로 설명합니다.
“*책 한 권을 영화로* — 인쇄 → 편집 → 상영”
브라우저 렌더링은 *책 한 권을 영화로 만드는 작업* 과 같습니다. *Parse* 는 책의 *글자를 읽고 (HTML)*, 동시에 *연출 노트를 읽는 (CSS)* 단계입니다. 두 노트를 합쳐 *대본 (Render Tree)* 이 만들어지는데, *연출 노트가 다 안 도착하면 대본을 못 만듭니다 (CSS render-blocking)*. *Layout* 은 *세트 위에서 배우와 소품의 자리* 를 정하는 일이고, *Paint* 는 *각 장면을 그림으로 그리는 일*, *Composite* 는 *그려진 그림들을 겹쳐 영사기로 쏘는 일* 입니다. 한 장면 (한 프레임) 이 잘못되었을 때, *세트가 통째로 흔들렸으면 처음부터 (Layout)*, *조명만 바뀌었으면 그림만 다시 (Paint)*, *카메라 각도만 살짝 (transform) 이면 영사기 단계만 (Composite)* 다시 합니다. *영화 첫 장면이 빨리 보이느냐 (LCP)* 는 *첫 대본까지의 시간 (Critical Rendering Path)* 이 짧으냐의 문제이고, *액션 장면이 부드러우냐 (INP, 60fps)* 는 *프레임마다 어느 단계까지 다시 도느냐 (Pixel Pipeline)* 의 문제입니다. 두 문제는 *시간축* 이 다른 *별개의 최적화* 입니다.
핵심 개념
*CSS 는 기본적으로 render-blocking 자원* 이다
CSSOM 이 완성되지 않으면 *어떤 노드를 어떻게 그릴지* 결정할 수 없다. 그래서 브라우저는 *모든 <link rel="stylesheet"> 의 다운로드/파싱을 기다린* 후에 *첫 페인트* 를 진행한다. 이게 *FCP/LCP* 가 *CSS 다운로드 시간* 에 직접 영향받는 이유. 해결: *Critical CSS 인라인 + 비핵심 CSS 의 lazy load* (media="print" onload="this.media='all'" 패턴).
*동기 스크립트* 는 *parser-blocking + render-blocking*
<script src="..."> (defer/async 없이) 는 *HTML 파싱을 일시 중단* 시키고, 또한 *그 위에 있는 CSSOM 이 완성될 때까지 실행도 못 한다* (스크립트가 CSSOM 을 읽을 수 있어야 하므로). 결과적으로 *파서 + 렌더 양쪽을 동시에 막는다*. 해결: <script defer> (DOMContentLoaded 직전 실행, 순서 보장) 또는 <script async> (다운로드 즉시 실행, 순서 X) 가 표준.
*defer* vs *async*
- HTML 파싱과 *병렬 다운로드*
- *DOMContentLoaded 직전* 실행
- *문서 순서* 로 실행
- 대부분의 앱 코드의 표준 선택
1<script src="app.js" defer></script>
- HTML 파싱과 *병렬 다운로드*
- *다운로드 끝나는 즉시* 실행 (파싱 중단)
- *순서 보장 X*
- 독립적 분석 스크립트 등
1<script src="analytics.js" async></script>
동작 원리
*Parse* 단계는 HTML 을 *DOM 트리*, CSS 를 *CSSOM 트리* 로 만든다. 두 트리를 결합해 *Render Tree* (실제로 화면에 그릴 노드만) 를 만든다. *Layout* 단계는 각 노드의 *기하 (위치, 폭, 높이)* 를 계산한다. *Paint* 단계는 각 레이어를 *비트맵* 으로 칠한다. *Composite* 단계는 *컴포지터 스레드* 가 비트맵 레이어들을 *변환·합성* 해 화면에 올린다. 이후 인터랙션은 *어떤 속성을 바꿨는가* 에 따라 *어느 단계부터 다시 도는지* 가 결정된다 — *width* 는 Layout 부터, *color* 는 Paint 부터, *transform/opacity* 는 Composite 만.
핵심 구성 요소
HTML Parser
바이트 → 토큰 → DOM 트리. *blocking* 스크립트를 만나면 일시 중단.
CSS Parser
바이트 → CSSOM 트리. *디폴트로 render-blocking* — CSSOM 없이는 Style 계산 불가.
Preload Scanner
메인 파서가 막혀도 *별도 스레드* 가 HTML 을 미리 훑어 *서브리소스를 prefetch*.
Render Tree Builder
DOM × CSSOM → Render Tree. `display: none` 노드는 제외.
Layout Engine
각 노드의 *기하 (x, y, width, height)* 계산. 변경 시 *reflow*.
Paint
각 레이어를 *비트맵* 으로. `color`, `box-shadow` 등이 변경되면 *repaint*.
Compositor Thread
비트맵 레이어들을 *변환 (translate/scale/opacity)* 만으로 합성. *메인 스레드와 독립*.
흐름 설명
HTML 1 바이트가 들어오면 Parser 가 토큰화 시작. `<link rel="stylesheet">` 발견 시 CSS 다운로드 (병렬), `<script>` (defer/async 가 아니면) 발견 시 *파서 일시 중단*. CSSOM 완성 후 *DOM × CSSOM → Render Tree* 가 만들어지고 *Layout → Paint → Composite* 로 *첫 픽셀* 이 화면에. 이후 사용자 입력/JS 가 스타일을 바꾸면 *변경된 속성의 종류에 따라* 어느 단계부터 다시 도는지가 결정.
코드 예제
<!doctype html> <html> <head> <!-- ❌ render-blocking 의 표준 예 --> <link rel="stylesheet" href="big.css"> <script src="legacy.js"></script> <!-- ✅ render-blocking 회피 패턴 --> <link rel="preload" href="hero.webp" as="image"> <link rel="stylesheet" href="critical.css"> <link rel="stylesheet" href="non-critical.css" media="print" onload="this.media='all'"> <script src="app.js" defer></script> </head> <body>...</body> </html>
실무 적용
어떤 상황에서 사용하는가
커머스 메인 — *LCP 가 4 초로 느림*, *스크롤 시 가끔 버벅임*, *Lighthouse 의 CLS 가 0.25 (Poor)*. 시니어 한 명에게 *원인 분석부터 처방* 까지 맡겨졌다.
어떻게 적용하는가
(1) *Performance 탭 녹화 + Lighthouse* 로 베이스라인 측정. *Network* 에서 *render-blocking* 자원 (회색 막대) 식별 — 보통 *큰 CSS 번들과 동기 스크립트*. (2) *LCP 처방* — 동기 스크립트 → `defer`, *비핵심 CSS* → `media="print" onload` 트릭으로 비동기 로드, *Critical CSS* 4~14KB 를 인라인. *LCP 이미지* 는 `<link rel="preload" as="image" fetchpriority="high">` + `next/image` 의 `priority` 속성. *폰트* 는 `<link rel="preload" as="font" crossorigin>` + `next/font` 로 메트릭 매칭. (3) *CLS 처방* — 모든 `<img>` 에 `width`/`height` 명시 또는 `aspect-ratio`, 광고/임베드 슬롯에 `min-height` 예약, 커스텀 폰트는 `font-display: swap` + `size-adjust` 로 폴백과 메트릭 일치. 동적 배너는 *공간 예약* 또는 *고정 위치*. (4) *INP/스크롤 버벅임 처방* — Performance 의 *Frames* 트랙에서 *frame drop* 을 클릭해 *어느 단계가 길었는지* 색깔 분석. *보라 (Layout/Style)* 가 길면 *layout thrashing* — read/write 분리, `contain`/`content-visibility` 적용. *노란 (Scripting)* 이 길면 *long task* 분할 또는 Web Worker. *transform/opacity* 가 아닌 속성 애니메이션 → 합성 트랙 속성으로 변경. (5) *Rendering 패널의 Paint flashing/Layer borders* 로 시각 검증. (6) *web-vitals 라이브러리* 로 RUM 환경에서 *실 사용자 수치* 를 추적해 회귀를 막음.
흔한 실수와 안티패턴
- *동기 스크립트를 head 에 무방비로* 둠 → parser + render 양쪽 차단.
- *Critical CSS 없이 거대 CSS 번들* → CSSOM 완성까지 첫 페인트 지연.
- *이미지 width/height 미명시* → CLS 기여.
- *`document.body.offsetWidth` 를 루프에서 호출* → forced layout × N (thrashing).
- *`will-change` 남발* → GPU 메모리 폭증으로 오히려 느려짐.
- *polyfill 동기 로드* → 모던 브라우저까지 차단되는 비대칭 손해.
- *RUM 없이 lab 데이터만* → 실 사용자 디바이스의 INP/LCP 와 괴리.
흔한 오해
*"DOM 이 만들어지면 바로 페인트된다"*
교정*틀렸다*. DOM 만으로는 *어떻게 그릴지* 모른다. *CSSOM* 이 함께 있어야 *Render Tree* 가 만들어지고 그제야 Layout → Paint 가 시작된다. 그래서 CSS 는 *render-blocking* 이다.
왜 중요브라우저는 *스타일 정보 없이* 노드를 그릴 수 없다 — 폰트·색·박스 어떤 것도 결정 안 됨.
*"reflow 는 항상 비싸다"*
교정*상황에 따라 매우 다르다*. `contain: layout` 으로 격리된 작은 서브트리의 reflow 는 *지역적* 으로 끝난다. 반대로 `<body>` 직접 자식의 폭 변경은 *페이지 거의 전체* 가 reflow 후보가 될 수 있다. 비용은 *영향 범위에 비례*.
왜 중요Layout 알고리즘은 *영향 범위* 로 한정된다. 격리 도구 (`contain`, `content-visibility`) 는 그 범위를 줄인다.
*"Composite 단계만 GPU 가 한다"*
교정*GPU 의 활용은 더 넓다*. 모던 브라우저는 *Paint 도 GPU 에 일부 위임* 하기도 하고 (rasterization on GPU), Composite 는 *대부분 GPU 합성*. 하지만 *어디까지 GPU 가 처리하는지는 엔진/플랫폼별로 다르며*, 개발자 모델로는 *transform/opacity 의 애니메이션이 컴포지터 트랙* 이라는 것만 견고하게 잡으면 충분.
왜 중요엔진 내부의 GPU 활용도는 *최적화 디테일* 이라 시간이 지나며 변한다. 견고한 것은 *어느 속성이 어느 단계를 트리거하는가*.
*"Preload Scanner 가 있으니 동기 스크립트도 괜찮다"*
교정*아니다*. Preload Scanner 는 *서브리소스 prefetch 만* 한다. *파서 일시 중단* 자체는 그대로다. 또한 동기 스크립트는 *그 위 CSSOM 이 완성되기 전엔 실행도 못 한다*. 결국 *render-blocking 자원* 의 본질은 그대로.
왜 중요Preload Scanner 는 *완화* 일 뿐 *해결* 이 아니다. 표준 답은 `defer`/`async`.
면접 질문
답변 방향 힌트
"CSS, 동기 스크립트", "defer/async", "Critical CSS 인라인", "preload"
반드시 언급할 키워드
- CRP = HTML 다운로드 → Parse → CSSOM → Render Tree → Layout → Paint 의 *첫 페인트까지의 길*
- 대표 차단 자원: *기본 `<link rel="stylesheet">`*, *동기 `<script>`*
- 동기 스크립트 → `defer` 또는 `async`
- 비핵심 CSS → `media="print" onload="this.media='all'"` 트릭
- Critical CSS (4~14KB) HTML 인라인
- LCP 이미지 → `<link rel="preload" as="image" fetchpriority="high">`
- Preload Scanner = 메인 파서가 막혀도 *별도 스레드* 가 HTML 을 미리 훑어 prefetch
예상 꼬리 질문
- `<script type="module">` 은 *defer 처럼 동작* 한다고 하는데 정확한 의미는?
- *HTTP/2 Server Push* 가 사라지고 *Early Hints (103)* 가 등장한 이유는?
자기 점검
CSS 가 *render-blocking* 인 이유를 *한 문장* 으로.
기대 키워드
자주 하는 오해
*"CSS 는 시각적 자원이라 차단 안 한다"* — 오히려 그렇기 때문에 *CSSOM 이 모이지 않으면 Render Tree 를 못 만든다*.
*forced layout* 이 발생하는 *최소 코드 패턴* 한 줄.
기대 키워드
자주 하는 오해
*"읽기만 하면 안전하다"* — 직전에 *write* 가 있었다면 read 가 *Layout 을 강제 실행* 시킨다.
*transform 애니메이션* 이 *Composite only* 가 되는 조건은?
기대 키워드
자주 하는 오해
*"transform 은 항상 Composite only"* — 정적 transform 은 *합성 단계는 가지만 별도 레이어가 아닐 수 있다*. 애니메이션 트리거가 있어야 GPU 텍스처로 분리.
CLS 점수 *공식* 을 한 문장으로.
기대 키워드
자주 하는 오해
*"시프트 거리 = CLS 점수"* — 영향받은 *영역 비율* 도 같이 곱한다.
학습 자료
- web.dev — How browsers work / Critical Rendering PathCRP 의 *공식 가이드*.Docweb.dev
- MDN — Critical rendering pathDOM, CSSOM, Render Tree 의 정의와 차단 원리.DocMDN
- How browsers work (Tali Garsiel)브라우저 내부 동작의 *오리지널 정리*. 길지만 한 번은 읽어야 할 글.BlogTali Garsiel & Paul Irish, web.dev
- web.dev — Rendering performancePixel Pipeline 과 60fps 의 표준 가이드.Docweb.dev
- CSS Triggers속성별 *Layout/Paint/Composite* 트리거를 정리한 사이트.Blogcsstriggers.com
- web.dev — Optimize CLSCLS 의 정의·계산·처방.Docweb.dev
- MDN — content-visibility오프스크린 렌더링 스킵의 표준.DocMDN