04 — Rendering Performance (60fps의 메커니즘)
한 줄 답: 브라우저는 매 프레임 Style → Layout → Paint → Composite의 4단계 파이프라인을 실행한다. 어느 속성이 어느 단계까지 trigger하는가가 jank의 유일한 원인이다.
transform/opacity는 마지막 Composite만 trigger → 60fps에 안전.width/top은 Layout부터 trigger → 매 프레임 전체 트리 재계산 → jank.
Why — 왜 이게 모션 챕터의 핵심인가
모션 디자인 결정의 거의 모든 트레이드오프는 *“이 속성을 보간하면 60fps가 깨지는가”*로 귀결된다.
- 60fps = 16.67ms/frame. 30fps = 33ms.
- 사용자는 >100ms 지연을 끊김으로 인지, **>16ms (단일 long frame)**을 흔들림으로 인지.
- 즉, 부드러움은 디자이너의 직관이 아니라 프레임 예산 관리다.
이 챕터는 프레임 예산이라는 회계장부를 정확히 읽는 법을 다룬다.
How — 파이프라인 4단계
1) Style 계산
DOM 트리 + CSS 규칙 → 각 요소의 Computed Style. Cascade·Inheritance가 여기서 일어난다 (01-cascade 참조).
비용: 보통 가장 빠름. 단 CSS 변수 트리가 깊거나 :has()가 많으면 늘어남.
2) Layout (Reflow)
Computed style을 기반으로 각 박스의 위치·크기 계산. 부모/형제와의 관계 (flex, grid, normal flow)를 모두 풀어야 함.
Layout-trigger 속성 (변경 시 layout 재계산):
- 박스 크기:
width,height,min/max-*,aspect-ratio - 박스 위치:
top,left,right,bottom,margin,padding,border-width - 폰트/텍스트:
font-size,font-family,line-height,letter-spacing,text-align - Flow:
display,position,float,flex-*,grid-* - ⚠️ 단 하나가 변하면 그 박스의 자식과 형제 전부 layout 재계산될 수 있음 → 트리가 크면 매우 비싸다.
3) Paint (Repaint)
각 박스 안에 픽셀을 그림. 색·테두리·그림자·그라데이션·이미지.
Paint-trigger 속성 (Layout은 건너뛰지만 Paint 필요):
color,background-*,border-color,border-radiusbox-shadow,outlinevisibility: hidden ↔ visible(display는 layout)
4) Composite
미리 분리된 *레이어(GPU texture)*들을 합성해 화면에 출력. 변환·합성은 GPU가 직접 수행.
Composite-only 속성:
transform,translate,rotate,scaleopacityfilter(조건부 — 요소가 자체 레이어일 때)
이 속성들은 이미 그려진 픽셀을 옮기거나 투명도만 조정하므로 Layout/Paint를 건너뛴다 → 가장 빠르다.
What — 구체 기법
A. Composite-only 디자인 패러다임
원칙: 애니메이션의 모든 보간 속성을 transform·opacity·filter로 한정.
| 의도 | ❌ Layout-trigger | ✅ Composite-only |
|---|---|---|
| 옆으로 슬라이드 | left: 100px | transform: translateX(100px) |
| 카드 확장 | width: 240px | transform: scaleX(1.2) |
| 페이드인 | display: block 직접 | opacity: 0 → 1 (+ allow-discrete) |
| 사라지기 | display: none 직접 | opacity: 0 (또는 visibility: hidden) |
| 위로 슬라이드인 | margin-top: -20px → 0 | transform: translateY(-20px) → 0 |
| 회전 메뉴 | top 변경 | transform: rotate() |
B. will-change — Composite 레이어 promotion
브라우저에게 *“이 요소를 별도 GPU 레이어로 미리 promote해두라”*는 힌트.
.draggable { will-change: transform; }효과:
- 별도 레이어로 분리 → 변경 시 주변 형제와 독립적으로 합성.
- Paint가 미리 GPU 텍스처로 캐시됨 → 변환만 일어남.
비용:
- 레이어 = GPU 메모리. 모바일은 한계가 빠르다.
- Style/Layout/Paint 전부 영향 가능 — 예:
will-change: transform은 containing block을 새로 만든다 (position: fixed자식의 좌표계 깨짐). - 너무 많이 쓰면 오히려 느려진다 (레이어 합성 비용이 늘어남).
BP 정책:
/* 호버에 반응하는 동안만 promote */
.thing:hover { will-change: transform; }
/* 끝나면 자동으로 풀림 — :hover가 빠지면 will-change도 빠짐 */또는 JS로 동적 토글:
el.addEventListener('mouseenter', () => el.style.willChange = 'transform');
el.addEventListener('animationend', () => el.style.willChange = 'auto');C. FLIP Technique
Paul Lewis (2014) — layout이 바뀐 결과를 transform으로 역계산해서 흉내 내자.
문제: 카드를 그리드의 한 자리에서 다른 자리로 부드럽게 이동시키고 싶다. 위치는 layout 결과 — transition으로 보간 불가. width/top으로 하면 jank.
해법 4단계:
코드:
const first = el.getBoundingClientRect(); // F
parent.appendChild(el); // 위치 바뀜
const last = el.getBoundingClientRect(); // L
const dx = first.left - last.left; // Invert
const dy = first.top - last.top;
el.style.transform = `translate(${dx}px, ${dy}px)`;
el.getBoundingClientRect(); // force layout (sync)
el.style.transition = 'transform 300ms'; // Play
el.style.transform = '';왜 부드러운가: 마지막 단계의 보간이 transform만이라 composite-only. Layout은 맨 처음 한 번만 일어남.
이 패턴은 React/Vue의 List Reordering Animation (FLIP Toolkit, Framer Motion layout prop, Vue Transition Group)에 모두 들어있다. 2023년 View Transitions API는 FLIP을 브라우저가 자동으로 해주는 것이다 (05-view-transitions).
D. Compositor가 안 도와주는 함정
box-shadow: animation 시 매 프레임 Paint 발생 → 느림. 대안: 미리 그림자 두 레이어를 stack해두고 opacity로 토글.
.card {
position: relative;
}
.card::before, .card::after {
position: absolute; inset: 0;
box-shadow: 0 2px 4px rgba(0,0,0,.1);
border-radius: inherit;
transition: opacity 200ms;
}
.card::after {
box-shadow: 0 8px 24px rgba(0,0,0,.2);
opacity: 0;
}
.card:hover::after { opacity: 1; }backdrop-filter + animation: iOS Safari에서 GPU 메모리 폭증 + 반복 paint. 금지.
border-radius 보간: 브라우저에 따라 paint trigger. 큰 요소·자주 변하는 곳에선 피한다.
E. DevTools로 측정
Chrome DevTools:
- Performance 탭 → Record → 인터랙션 수행 → Stop
- 빨간 막대 (Long Frame) = 16ms 초과 프레임
- Layout / Paint / Composite 색깔 막대로 어느 단계가 비용 큰지 확인
- Layers 패널에서 GPU 레이어 수·메모리 확인
지표:
- First Input Delay / INP — 인터랙션 응답 지연 (Core Web Vitals)
- CLS (Cumulative Layout Shift) — Layout이 예기치 않게 일어난 빈도
What-if — 잘못 만지면
1) Forced synchronous layout (Layout Thrashing)
for (const el of items) {
el.style.width = el.offsetWidth + 10 + 'px'; /* ❌ */
}offsetWidth는 현재까지의 layout을 즉시 flush해서 읽는다. 그러고 width를 변경하면 다음 읽기에서 다시 flush. → N개 요소면 N번의 Layout. 제곱에 가까운 비용.
해결: 읽기와 쓰기를 분리.
const widths = items.map(el => el.offsetWidth); /* 한 번에 읽기 */
items.forEach((el, i) => el.style.width = widths[i] + 10 + 'px');2) top/left로 무한 슬라이드
@keyframes slide {
to { left: 100%; } /* ❌ 매 프레임 Layout */
}대안: transform: translateX(100%).
3) 200개 카드에 will-change: transform
GPU 메모리 한계 초과 → 스왑 발생 → 오히려 더 느려짐. 모바일 Chrome은 레이어 256개 이상이면 강제 합성 평면화.
4) backdrop-filter 위에 animation
iOS Safari에서 전체 화면이 멈춤. backdrop-filter는 매 프레임 뒤 배경을 다시 캡처해 흐리게 처리 → 가장 비싼 paint 작업 중 하나.
5) :has() 안에서 layout-trigger 속성
form:has(input:invalid) {
height: 200px; /* ❌ 매 keypress마다 layout */
}:has()는 style invalidation 범위가 넓다. layout-trigger 속성과 결합하면 입력할 때마다 전체 페이지 재계산.
6) “Hardware acceleration” 강제 트릭 (구식)
.thing { transform: translateZ(0); } /* 또는 -webkit-transform-style: preserve-3d */2010년대 “GPU 레이어 강제” 해킹. 지금은 will-change가 정석. 둘 다 남발하면 메모리 폭발. translateZ(0)이 무해하다는 미신은 거짓이다.
Insight — 한 단락 이야기
“60fps는 기적이 아니라 프레임 예산 회계다.”
1995년 SGI의 그래픽 워크스테이션은 vsync라는 개념을 정립했다 — 화면 주사율(60Hz)에 맞춰 그릴 수 있는 만큼만 그리고 나머지는 버리자. 이 발상이 30년 후 브라우저에 들어왔다. 16.67ms 안에 모든 일을 끝내거나, 끝낼 수 있는 부분만 하라. 그리고 가장 빠른 부분은 GPU. transform·opacity가 특별한 이유는 수학적으로 단순해서가 아니라 이미 GPU 텍스처로 캐시된 픽셀을 행렬 곱으로 옮기기만 하면 되기 때문. Layout은 본질적으로 트리를 풀어야 하므로 병렬화·캐시가 어렵고, Paint는 벡터→래스터라 메모리 대역을 먹는다. 2014년 Paul Lewis의 FLIP은 이 GPU 친화적 경로로 우회하는 알고리즘적 트릭이었고, 2023년 View Transitions는 그 트릭을 브라우저가 자동화한 것이다. *60fps의 비밀은 결국 “GPU가 잘 하는 일만 시키는 것”*이다.
요약 + Mermaid
- 렌더 파이프라인: Style → Layout → Paint → Composite.
- 어느 속성이 어느 단계까지 trigger하는가가 모든 jank의 원인.
- Composite-only:
transform,opacity,filter(조건부). - Layout-trigger:
width,top,margin,font-size,display등 — animation 금지. will-change는 promote 힌트. 남발 = 메모리 폭발. 동적 토글이 BP.- FLIP (Paul Lewis, 2014): First/Last/Invert/Play — layout 결과를 transform으로 흉내. 모든 모던 리스트 애니메이션의 토대.
- Layout Thrashing: read/write 교차로 N번의 sync layout flush. 읽기·쓰기 분리.
- DevTools Performance + Layers 패널로 실측. 직관 금지.
다음: 05-view-transitions — FLIP을 브라우저가 자동으로 해주는 새 API.