브라우저 렌더링 파이프라인과 컴포지팅
브라우저 렌더링 파이프라인과 컴포지팅
숏폼 피드에서 슬라이드가 끊기는 문제를 해결하면서 컴포지팅(composite) 과정을 정리했다.
1. 브라우저가 첫 화면을 만들기까지
HTML과 CSS는 텍스트다. 브라우저는 이를 여러 단계를 거쳐 화면으로 바꾼다.
HTML ─파싱→ DOM 트리 문서 구조
CSS ─파싱→ CSSOM 트리 스타일 규칙
│
├─ 합침 → 렌더 트리 화면에 나올 것만
│ ↓
│ Layout 각 노드가 어디에, 얼마나 크게
│ ↓
│ 레이어 분리 독립적으로 변할 것들을 따로 떼어냄
│ ↓
│ Paint 각 레이어에 그리기
│ ↓
└─────→ Composite 레이어들을 겹쳐 최종 한 장으로
DOM · CSSOM · 렌더 트리
DOM은 문서 구조고 CSSOM은 스타일 규칙이다. 둘을 합쳐 렌더 트리를 만드는데, 여기엔 실제로 화면에 나올 것만 들어간다. 렌더 트리에 없으면 Layout도 Paint도 안 한다. display: none과 visibility: hidden의 비용 차이는 렌더 트리에 포함되는지 여부에서 생긴다.
display: none과 visibility: hidden의 비용 차이
DOM · CSSOM 그대로
↓
렌더 트리 해당 노드(와 자손)가 빠지거나 다시 들어감
↓
Layout 자리가 생기거나 사라지므로 주변까지 재계산
↓
Paint → Composite
그래서 display 토글은 가장 비싼 속성이다. 한 요소가 자리를 비우면 형제·부모까지 연쇄로 다시 계산되기 때문이다. 단 position: absolute/fixed 요소는 flow에서 이미 빠져 있어 형제 위치에는 영향이 없고, 재계산 범위가 자기 자신으로 제한된다.
세 가지를 비교하면 다음과 같다.
| 렌더 트리 포함 | 자리 차지 | 다시 하는 단계 | |
|---|---|---|---|
display: none |
X | X | 렌더 트리 → Layout → Paint → Composite |
visibility: hidden |
O | O | Paint → Composite |
opacity: 0 |
O | O | Composite만 (별도 레이어일 때) |
셋 다 요소를 안 보이게 하지만 다시 하는 단계가 다르다. 자주 껐다 켜는 UI라면 display 토글을 피한다. 메모리와 연산을 줄여야 하면 display: none이나 언마운트를 쓴다.
뒤에 나오는 사례에서 화면 밖 카드를
visibility: hidden으로 처리한 이유는 비용보다 렌더 박스였다.display: none이면 렌더 트리에서 빠져 그 안의<video>버퍼링·디코딩이 보장되지 않는데, 미리 받아두는 게 목적이었기 때문이다.opacity: 0은 안 보이기만 하고 매 합성마다 그대로 겹쳐지므로, 줄이려던 컴포지터 부하가 줄지 않는다.
Layout
렌더 트리의 각 노드가 어디에, 얼마나 크게 놓이는지 계산한다. 뷰포트 크기에 따라 결과가 달라지고, 한 요소의 크기가 바뀌면 형제·부모까지 연쇄로 다시 계산될 수 있다. 그래서 reflow라고도 부른다.
레이어 분리
브라우저는 화면 전체를 한 장으로 그리지 않는다. 독립적으로 변할 만한 것들을 별도 레이어로 분리한다.
레이어로 승격되는 대표적인 경우:
-
<video>,<canvas>,<iframe> - 3D transform이 걸린 요소,
will-change: transform/opacity - 애니메이션 중인
transform/opacity - 일부
position: fixed
레이어를 나누면 바뀐 레이어만 다시 처리한다. 영상이 매 프레임 바뀌어도 그 레이어만 갱신하고, 배경과 텍스트는 다시 그리지 않는다.
승격의 비용
승격된 요소는 자기 비트맵을 GPU 메모리에 텍스처로 따로 갖는다. 승격 안 된 요소는 부모 레이어에 같이 그려져서 별도 텍스처가 없다.
승격하면 다음 세 가지 비용이 생긴다.
| 언제 발생하나 | 내용 | |
|---|---|---|
| GPU 메모리 | 레이어가 있는 동안 계속 | 레이어마다 비트맵 텍스처를 GPU 메모리에 하나씩 유지한다 |
| 텍스처 업로드 | 레이어를 만들거나 내용이 바뀔 때 | CPU에서 만든 비트맵을 GPU로 올린다 |
| 합성 연산 | 매 프레임 | 겹칠 레이어가 늘어난다. GPU가 처리하는 연산이라 비용이 작다 |
GPU 메모리가 셋 중 가장 큰 이유는 두 가지다.
- 텍스처 크기가 레이어의 물리 픽셀 수에 비례한다(픽셀당 RGBA 4바이트). 고해상도 기기에서 화면을 채우는 레이어는 한 장에 최대 약 11MB다(390 × 844 CSS px, DPR 3 기준. 추정).
- 업로드는 레이어를 만들거나 내용이 바뀔 때만 생기지만, 메모리는 레이어가 있는 동안 계속 점유한다. 텍스처를 GPU에 올려 두기 때문에 합성이 빠르고, 대신 그 메모리를 계속 쓴다.
Paint — 그리고 Raster, Upload
Paint 단계에서 픽셀을 만든다고 알기 쉽지만, 실제로는 세 단계로 나뉜다.
Paint 메인 스레드가 "그리기 명령 목록"을 만듦 ← 아직 픽셀이 아니다
↓
Raster 그 명령을 실제 픽셀(비트맵)로 변환 ← 별도 스레드 / GPU
↓
Upload 비트맵을 텍스처로 GPU 메모리에 올림
이 시점에는 아직 레이어별로 따로 그려져 있다.
정적 레이어와 비디오 레이어의 부하 차이
같은 “레이어 한 장”이어도 부담의 종류가 다르다.
| 자리(메모리) | 작업량 | |
|---|---|---|
| 정적 이미지 레이어 | 차지함 | 한 번 만든 뒤 재사용 |
| 비디오 레이어 | 차지함 | 프레임레이트만큼 새 프레임이 계속 도착 |
<video>는 처리 경로도 다르다.
일반 요소 Paint → Raster → GPU 텍스처
<video> 하드웨어 디코더 → GPU 텍스처 ← Paint · Raster를 안 거침
디코딩된 프레임이 디코더에서 GPU로 바로 들어간다. 비디오 레이어의 비용은 그리는 쪽이 아니라 새 텍스처가 계속 들어오는 쪽이다.
그래서 영상이 재생 중이면 합성이 계속 발생한다. 화면이 바뀌지 않으면 브라우저는 프레임을 만들지 않지만(4번 참고), 영상은 계속 화면을 바꾼다.
다만 정지 내용의 프레임이 왔을 때 컴포지터가 합성을 건너뛰는지는 확인하지 못했다. 건너뛴다면 정지 구간에서는 합성이 발생하지 않을 수 있다. 엔진마다 다를 수 있고 WebKit이 어느 쪽인지는 모른다.
Composite
칠해진 레이어들을 정해진 순서·위치·투명도로 포개서 최종 화면 한 장을 만든다.
[배경 레이어] + [영상 레이어] + [오버레이 레이어] → 화면 한 장
옛날 애니메이션 셀(cel)과 같다. 투명 필름에 배경·캐릭터·이펙트를 각각 그려두고, 카메라로 찍기 전에 겹치는 것.
- Paint = 각 셀에 그림을 그리는 것
- Composite = 셀을 포개서 한 장으로 찍는 것
composite는 그리지 않는다. 이미 그려진 셀의 위치와 투명도만 바꿔서 다시 포갠다. 캐릭터가 움직일 때 캐릭터를 다시 그리지 않고 셀만 옮기는 것 — 그게 transform 애니메이션이다.
2. 무엇을 바꾸면 어디서부터 다시 하나
첫 화면을 그린 뒤로는 바뀐 것에 따라 되돌아가는 지점이 다르다. 위치·크기가 바뀌면 Layout부터, 색만 바뀌면 Paint부터, 겹치는 방식만 바뀌면 Composite만 다시 한다.
| 바꾸는 속성 | 다시 하는 단계 |
|---|---|
width, height, top, font-size
|
Layout → Paint → Composite (전부) |
background-color, box-shadow, border-radius
|
Paint → Composite |
transform, opacity |
Composite만 |
애니메이션에 transform과 opacity를 권장하는 이유다. 다른 속성은 앞 단계부터 다시 하지만, 이 둘은 겹치는 방식만 바꾼다.
3. 메인 스레드와 컴포지터 스레드
메인 스레드 JS 실행 · Style · Layout · Paint
컴포지터 스레드 Composite ← 별개 스레드 + GPU
컴포지팅은 메인 스레드에서 돌지 않는다.
무거운 페이지에서 버튼은 안 눌리는데 스크롤은 잘 되는 경험이 이것 때문이다. 메인 스레드가 JS로 막혀 있어도, 스크롤은 컴포지터 스레드가 따로 처리한다.
왜 스크롤이 composite만으로 되나
스크롤하면 콘텐츠가 움직이지만 요소들끼리의 위치·크기 관계는 바뀌지 않는다. 그려 둔 레이어의 위치만 이동하므로 Layout과 Paint를 다시 하지 않는다.
4. 프레임이란 무엇인가
화면에 표시되는 정지 이미지 한 장.
디스플레이는 초당 정해진 횟수만큼 화면을 새로 그린다(주사율). 60Hz면 약 16.7ms마다 한 번.
0ms 16.7ms 33.3ms 50ms
│ │ │ │
프레임1 프레임2 프레임3 프레임4
브라우저는 그 주기마다 한 장을 만들어 넘겨야 하고, 한 프레임에서 실행되는 작업(JS → Style → Layout → Paint → Composite)이 16.7ms 안에 끝나야 한다.
16.7ms 안에 끝나지 않으면
새 프레임이 안 나오고, 디스플레이는 직전 프레임을 한 번 더 보여준다. 이게 프레임 드랍이고, 연속되면 “멈춘 것 처럼” 보인다.
165ms ÷ 16.7ms ≈ 10프레임
165ms 동안 프레임을 못 만들었다면, 같은 그림을 10번 반복해서 보여준 것이다.
프레임 예산은 매 프레임 적용된다
한 프레임의 마감을 놓치면 다음 주기까지 기다려야 한다.
12ms 걸림 → 슬롯 안에 들어감 → 60fps
20ms 걸림 → 슬롯 놓침, 다음 주기 대기 → 실질 33.3ms = 30fps
3.3ms를 초과하면 fps가 절반이 된다. fps는 60 → 30 → 20 → 15 단위로 떨어진다.
그리고 16.7ms를 다 쓸 수도 없다. 브라우저 자체 작업이 있어 실제로 쓸 수 있는 시간은 약 10ms다(web.dev Rendering Performance 기준). 120Hz 기기면 이게 절반(8.3ms)으로 줄어든다.
영상 프레임과 화면 프레임
| 영상 프레임 | 영상 자체가 초당 몇 장인가 |
| 화면 프레임 | 브라우저가 화면에 그리는 장수 = fps |
“32 → 38fps”는 화면 프레임이다. 영상 화질이 아니라 브라우저가 초당 몇 장을 만들어냈는가. 슬라이드 애니메이션·자막 전환·진행바가 전부 이 프레임 위에서 움직인다.
화면이 안 변하면 브라우저는 프레임을 만들지 않는다. 정지 화면에서 0fps는 정상이다. 문제는 변해야 하는데 못 만들 때다.
5. 네이티브 스크롤 vs JS 제어
숏폼 피드를 만들 때 scroll-snap(브라우저 기본 스크롤)과 포인터 이벤트 기반 직접 제어 중에 골라야 했다. 두 방식의 처리 경로는 다음과 같다.
scroll-snap
손가락 → 컴포지터가 스크롤 위치 직접 갱신 → composite
(메인 스레드를 안 거침)
JS 제어
손가락 → pointermove → 메인 스레드에서 JS 실행
→ state 갱신 → 렌더 → transform 값 변경 → composite
(메인 스레드를 반드시 거침)
둘 다 transform을 쓴다. 애니메이션 자체는 똑같이 composite-only다. 차이는 그 값을 계산할 때 메인 스레드를 거치는지 여부다.
그래서 순수 성능은 네이티브가 유리하다. 그럼에도 JS 제어를 고른 이유는 성능이 아니라 제어권이었다.
| 필요했던 것 | 네이티브로는 |
|---|---|
| 전환 duration·easing을 스펙대로 고정 | 브라우저 스냅 물리에 의존 — 지정 불가 |
| “지금 어느 카드가 활성인가”로 재생·정지·되감기 제어 | 스크롤 위치에서 역산해야 함 |
| 활성 카드 주변만 마운트해서 미디어 인스턴스 정리 보장 | 언마운트 시점을 직접 알 수 없음 |
| 특정 UI 영역에서 제스처 자체를 차단 | 스크롤 컨테이너 단위라 세밀한 예외가 어려움 |
성능과 제어권의 트레이드오프였다.
6. 실제 사례: 병목이 컴포지터였던 경우
5번에서 JS 제어는 메인 스레드를 거쳐서 불리하다고 했지만, 실측 결과 병목은 메인 스레드가 아니었다.
Safari에서 카드를 넘길 때 직전 영상이 상단에 남은 채 멈췄다가 한 번에 이동하는 문제가 있었다. CSS에서 원인을 찾다가 여러 번 실패한 뒤, Timeline을 녹화해서 작업 종류별로 시간을 합산했다.
| 작업 | 6.17초 녹화 중 |
|---|---|
| composite | 1,033ms (17%) |
| recalculate-styles | 27.7ms |
| layout | 16.6ms |
| paint | 7.1ms |
| JS 전체 | ~165ms |
메인 스레드 작업(JS, style, layout, paint)은 합쳐서 약 216ms였고, composite는 1,033ms였다. 화면은 멈췄지만 JS 실행 시간은 짧았다. 그래서 메인 스레드에서 원인을 찾는 동안 진단이 늦어졌다.
will-change: transform을 적용했을 때 더 나빠졌던 것도 이것으로 설명된다. 레이어를 더 승격시켜서 컴포지터의 작업이 늘었다.
will-change의 문제는 하나 더 있다. 브라우저는 애니메이션이 시작될 때 요소를 승격시키고 끝나면 강등시킨다. will-change를 상시 적용하면 브라우저가 강등하지 않고 계속 승격 상태를 유지한다. 강등을 막으려고 적용했지만, 그 결과 레이어가 GPU 메모리를 계속 점유했다. MDN도 “너무 많은 요소에 너무 오래 걸지 말 것”을 명시적으로 경고한다.
여기서 어느 자원이 병목이었는지는 구분하지 못했다. 1번에서 나눈 세 가지(GPU 메모리 / 텍스처 업로드 / 합성 연산) 중 무엇인지 모른다. Safari Web Inspector의 Layers 탭이 레이어 개수와 메모리 사용량을 보여주므로 그 탭에서 구분할 수 있었지만 확인하지 않았다. 다음에 같은 문제를 만나면 거기부터 볼 것.
부하를 합성 횟수와 한 번의 단가로 나누기
부하를 무겁다/가볍다로만 보면 조치가 서로 달랐던 이유를 설명할 수 없다. 합성 횟수를 만드는 것과 합성 한 번의 단가를 올리는 것은 다르다.
총 부하 = 합성 횟수 × 한 번의 단가
| 하는 일 | 언제 | |
|---|---|---|
| 재생 중 영상 프레임 유입 | 합성 횟수를 만듦 | 재생 내내 |
transform 애니메이션 |
합성 횟수를 요구함 (초당 60회) | 슬라이드 400ms |
| 카드마다 깔린 전체화면 이미지 레이어 | 합성 단가를 올림 | 상시 |
| 새 영상 디코딩 시작 | 순간 스파이크 | 재생 개시 시점 |
이미지 오버레이는 정적이라 다시 그리지 않는다. 하지만 합성할 때마다 같이 겹쳐야 하는 대상이라, 합성 횟수는 늘리지 않고 한 번의 단가를 올린다. 이 레이어를 없앴을 때 합성 횟수는 같고 한 번의 단가가 내려갔다.
네 번째는 앞의 것들과 또 다르다. 재생 중 프레임이 들어오는 것과, 디코더를 초기화하고 첫 프레임까지 준비하는 것은 별개다. 실측에서 재생 개시 직후 87~128ms 멈춘 게 이 스파이크였다.
transform 자체는 비싸지 않다. 레이어 하나를 옮기는 것은 컴포지터가 처리하는 기본 작업이다. transform 애니메이션은 초당 60번 합성을 요청했고, 다른 부하가 없었다면 문제가 되지 않았다.
정리하면 이렇게 겹쳤다.
단가: 카드마다 img 오버레이 → 매 합성이 비쌈
꾸준: 재생 중 영상 프레임 유입 → 합성이 계속 요구됨
요구: 슬라이드 transform → 400ms 동안 초당 60회 요구
스파이크: 같은 400ms 안에 새 영상 디코딩 시작
=
예산 초과 → 43 · 80 · 42ms 동안 프레임을 못 만듦
넷이 같은 400ms에 겹친 것이 문제였다.
그래서 조치도 부하 종류별로 달랐다. transform은 건드리지 않았다.
전체화면 이미지 레이어 → 같은 박스의 background-image로 통합 단가 낮추기
재생 개시를 transitionend 이후로 스파이크를 애니메이션 밖으로
화면에 나타날 수 없는 카드는 visibility: hidden 그릴 대상 축소
영상 프레임 유입은 손대지 않았다. 콘텐츠라 줄일 수 없으니 시작 시점만 바꿨다.
결과는 레이어 합성 351ms → 64ms, 슬라이드 중 화면 정지 165ms → 0, 드래그 중 414ms → 41ms.
이 영상이 몇 fps인지는 확인하지 않았다. 정지 이미지 몇 장을 슬라이드쇼로 인코딩한 영상이라 내용 변화는 거의 없지만, 디코더는 인코딩된 프레임레이트만큼 프레임을 계속 내보낸다. 그게 곧 합성 횟수인지(정지 구간에서 건너뛰는지)까지는 위에서 적은 대로 모른다. “영상이 재생 중이면 컴포지터가 계속 일한다”는 방향은 맞지만, 그 양이 얼마인지는 세지 않았다.
컴포지터가 멈추는 내부 메커니즘은 확정하지 못했다. 브라우저 내부를 본 것이 아니라, 관찰한 수치와 조치 후 변화로 추정한 것이다. 다만 각 부하를 제거했을 때 수치가 모두 내려갔으므로, 메커니즘은 확인하지 못했지만 각 부하와 화면 정지 사이의 관계는 확인했다.
7. 눈으로 확인하는 법
레이어는 화면에 보이지 않는다. 개발자 도구에서 표시할 수 있다.
Chrome DevTools → ⋮ → More tools → Rendering
| 옵션 | 보이는 것 |
|---|---|
| Layer borders | 합성 레이어 경계가 화면에 테두리로 표시된다. 카드마다 몇 겹이 쌓여 있는지 눈으로 보임 |
| Frame Rendering Stats | 실시간 fps. 드래그하면서 숫자가 떨어지는 걸 볼 수 있음 |
| Paint flashing | 다시 칠해지는 영역이 초록색으로 표시됨. composite-only인지 확인용 |
Safari는 Web Inspector → Layers 탭에서 같은 걸 본다. Safari에서만 생기는 문제는 Safari에서 확인해야 한다.
8. 헷갈렸던 것 정리
| 오해 | 실제 |
|---|---|
| 컴포지팅이 무거우면 메인 스레드가 막힌 것 | 별개 스레드. 메인 스레드 작업이 적어도 화면은 멈출 수 있다 |
display: none 토글은 DOM부터 다시 |
DOM·CSSOM은 그대로. 렌더 트리 → Layout부터 다시 |
| Paint에서 픽셀이 만들어진다 | Paint는 그리기 명령. 픽셀은 Raster, 그다음 GPU로 Upload |
| 레이어 분리는 그냥 논리적 구분 | 승격된 레이어는 자기 텍스처를 GPU 메모리에 들고 있다 |
| 레이어가 많으면 합치는 게 느려진다 | 합성 연산은 셋 중 제일 싸다. 더 큰 건 GPU 메모리·업로드 |
| 이미지 레이어와 비디오 레이어는 같은 부하 | 이미지는 자리, 비디오는 계속 들어오는 작업량 |
will-change를 걸면 빨라진다 |
레이어를 늘린다. 브라우저가 강등하지 않고 승격 상태를 유지한다 |
transform 애니메이션이 부하다 |
컴포지터의 기본 작업이라 비용이 작다. 문제는 같은 시점의 다른 부하 |
| 1,033ms 동안 한 번 멈췄다 | 녹화 전체의 누적치다. 실제 정지는 그중 일부 |
| fps가 낮으면 영상이 끊긴다 | fps는 화면 갱신 빈도. 영상 재생과 다른 축 |
| 60fps를 항상 만들어야 한다 | 화면이 안 변하면 안 만든다. 변해야 할 때 못 만드는 게 문제 |