브라우저 렌더링 파이프라인과 컴포지팅

숏폼 피드에서 슬라이드가 끊기는 문제를 해결하면서 컴포지팅(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를 항상 만들어야 한다 화면이 안 변하면 안 만든다. 변해야 할 때 못 만드는 게 문제