화면에서 상자 하나를 왼쪽에서 오른쪽으로 옮기는 방법은 여러 가지다. left 값을 바꿔도 되고, transform: translateX()를 써도 되고, JavaScript로 매 프레임 위치를 계산해 넣어도 된다. 결과 화면은 똑같아 보인다. 그런데 페이지가 바빠지면 어떤 방법은 뚝뚝 끊기고 어떤 방법은 끝까지 매끄럽다.
이 차이를 설명하려면 브라우저가 코드를 픽셀로 바꾸는 길, 곧 렌더링 파이프라인을 알아야 한다. 이 책의 장들은 이 길 위의 한 구간이나 그 바로 앞 단계를 다룬다. 그래서 0장에서 지도를 먼저 펼친다.
코드가 픽셀이 되는 다섯 단계
브라우저는 HTML과 CSS를 받아 대략 다음 순서로 화면을 만든다. 자세한 흐름은 중요 렌더링 경로 노트에 있다.
- 파싱: HTML을 읽어 DOM 트리를, CSS를 읽어 CSSOM을 만든다.
- 스타일: 요소마다 어떤 CSS 규칙이 걸리는지 계산해 최종 스타일(computed style)을 정한다.
- 레이아웃: 각 요소의 크기와 위치를 계산한다. 다시 계산하는 일을 리플로우라고 부른다.
- 페인트: 글자·색·테두리·그림자를 어떤 순서로 그릴지 기록한다. 다시 그리는 일을 리페인트라고 부른다. 이 기록을 실제 픽셀로 바꾸는 래스터는 Chrome에서 메인 스레드 밖의 래스터 스레드가 맡는다(Chrome 렌더러 구조 글).
- 합성: 따로 그려 둔 레이어들을 겹쳐 최종 화면을 만든다. 이 단계는 메인 스레드가 아니라 컴포지터 스레드(compositor thread)가 맡는다(합성 노트).
첫 화면을 그릴 때는 다섯 단계를 모두 지난다. 중요한 것은 그다음이다. 무언가 바뀔 때마다 브라우저는 바뀐 것에 따라 필요한 단계부터 다시 한다.
무엇을 바꾸면 어디부터 다시 하나
| 바꾼 것 | 예 | 다시 하는 곳 |
|---|---|---|
| 크기·위치(기하) | width, height, left, top, margin, font-size | 레이아웃부터 |
| 모양만 | color, background-color, box-shadow | 페인트부터 |
| 레이어 변형·투명도 | transform, opacity | 합성만 |
어느 단계부터 시작하든 그 뒤 단계는 차례로 다시 한다. 레이아웃부터라면 레이아웃 → 페인트 → 합성이다.
left를 바꾸면 그 요소의 위치가 바뀌었으니 레이아웃부터 다시 해야 한다. 그 변화가 주변 요소에 영향을 주는지도 브라우저가 따져야 한다. transform은 레이아웃 결과를 건드리지 않는다. 이미 그려 둔 레이어를 옮기거나 돌리거나 키우기만 한다. 그래서 합성 단계만 다시 하면 된다.
표는 출발점일 뿐이다. 어느 속성이 어느 단계를 부르는지는 브라우저와 상황에 따라 조금씩 다르다. transform도 요소에 자기 레이어가 없으면 페인트가 다시 일어날 수 있다. 확실히 알고 싶을 때는 짐작하지 말고 Chrome DevTools의 Performance 패널로 기록해 본다.
메인 스레드와 컴포지터 스레드
스타일·레이아웃·페인트 기록은 메인 스레드에서 일어난다. JavaScript도 같은 메인 스레드에서 돈다(이벤트 루프). 그래서 JavaScript가 메인 스레드를 오래 붙잡고 있으면 레이아웃도 페인트도 그동안 멈춘다.
합성은 다르다. 브라우저는 transform과 opacity만 바뀌는 CSS 애니메이션을 컴포지터 스레드에 넘겨 메인 스레드와 상관없이 진행할 수 있다. Chrome도 Firefox도 이 최적화를 문서로 설명한다(Chrome 블로그: 하드웨어 가속 애니메이션, MDN: CSS와 JavaScript 애니메이션 성능). Firefox에서는 OMTA라고 부른다. 다만 조건에 따라서는 애니메이션을 컴포지터 스레드에 넘기지 못하고 메인 스레드에서 돌리는 예외가 있다.
CSS로 transform을 애니메이션하는 상자, CSS로 left를 애니메이션하는 상자, JavaScript가 매 프레임 transform을 바꾸는 상자가 오간다. 이때 JavaScript가 메인 스레드를 1초 동안 붙잡으면, 그동안 계속 움직이는 상자는?
메인 스레드가 막혀 있는 동안 누른 버튼은 바로 반응하지 않는다. 클릭 이벤트는 대기열에 쌓였다가 메인 스레드가 풀린 뒤에 처리된다. 사용자가 "눌러도 반응이 없다"고 느끼는 순간이 바로 이것이다. 웹 바이탈의 INP는 클릭·탭·키 입력부터 다음 화면이 그려질 때까지의 지연을 재고, 그중 가장 긴 값(이상치는 뺀다)을 페이지의 점수로 삼는다(web.dev INP). 메인 스레드가 막히면 이 지연 가운데 입력을 받은 뒤 처리가 시작되기까지 기다리는 시간(입력 지연)이 길어진다.
이 책의 지도
이 책의 장들은 지금 본 파이프라인의 한 구간씩을 맡는다. 3장만은 파이프라인에 들어가기 전, React가 무엇을 다시 렌더링할지 정하는 단계를 다룬다.
- 1장 리플로우와 리페인트: 레이아웃 단계를 쓸데없이 여러 번 부르는 코드(레이아웃 스래싱)가 얼마나 느린지 직접 재 본다.
- 2장 스크롤에 맞춰 움직이는 세 가지 방법:
scroll이벤트, IntersectionObserver, CSS 스크롤 기반 애니메이션(scroll-driven animations)이 각각 어느 스레드에서 일하는지 비교한다. - 3장 상태 관리 라이브러리를 직접 만들어 보기: 상태가 바뀌었을 때 무엇을 다시 렌더링할지 정하는 구독 구조를 직접 만들어 본다.
- 4장 CSS를 언제 만드나: 스타일 단계에 들어갈 CSS를 빌드 때 만드느냐, 브라우저에서 실행 중에 만드느냐에 따라 무엇이 달라지는지 본다.
1. background-color만 바꾸면 다시 하는 단계는?
2. JavaScript가 requestAnimationFrame으로 transform을 바꾸는 애니메이션은 메인 스레드가 막히면?