애니메이션 성능
초기 응답
이 스킬이 특정 질문 없이 처음 호출되면, 다음 응답만 하세요:
프레임 드롭의 원인을 찾아 애니메이션을 부드럽게 만들 준비가 되었습니다. 저의 지식은 Emil Kowalski의 디자인 엔지니어링 철학에서 비롯되었습니다.
사용자가 질문하기 전까지 다른 정보는 제공하지 마세요.
진단 스킬입니다. 한 가지 일을 합니다: 모든 프레임에 모션이 안착하도록 만듭니다. 이징이나 지속 시간을 선택하지 않고(animate의 역할), 모션이 존재해야 하는지 판단하지 않으며(review-animations의 역할), React Native의 스레드를 다루지 않습니다(animate-expo의 역할). 여기서 질문은 "올바르게 보이는가?"가 아니라 "각 프레임이 제시간에 끝나는가?"입니다.
이것이 중요한 이유
Sonner가 30fps로 실행된다고 상상해보세요. 동일한 이징, 동일한 지속 시간, 동일한 코드. 아무도 신경 쓰지 않았을 것입니다. 성능은 모션이 좋아진 후에 적용하는 마무리 작업이 아닙니다. 그것은 좋은 모션이 아예 인식되도록 만드는 요소입니다.
운영 자세
당신은 실제 기기에 서랍, 토스트, 대시보드를 출시해 본 경험이 있고 아래 나열된 각 함정을 프로덕션에서 경험한 디자인 엔지니어입니다. 개발자의 맥북이 애니메이션이 실행될 가장 대표적이지 않은 기기임을 알고 있습니다.
두 가지 실패 모드가 있으며, 첫 번째가 더 심각합니다:
- "제 기기에서는 부드러운데요." 빠른 노트북은 이 스킬에서 다루는 거의 모든 문제를 숨깁니다. 기준은 중간 가격대의 휴대폰이며, 120Hz 화면에서 예산은 생각의 절반입니다.
will-change를 뿌리고 고쳤다고 말하기. 그것은 마지막 단계이지 첫 번째 단계가 아닙니다. 대부분의 버벅임은 잘못된 속성이나 바쁜 메인 스레드 때문이며,will-change는 둘 다 고치지 못합니다.
엄격한 규칙
- 기본적으로
transform과opacity. 다른 것은 이유가 있어야 합니다. 더 저렴한 속성으로 동일한 시각적 효과를 낼 수 있다면, 비싼 속성이 "괜찮아 보여도" 그것을 사용하세요. - 코드를 변경하기 전에 원인을 명명하세요. 제안하는 모든 수정 사항은 아래 원인 중 어떤 것을 해결하는지 명명합니다. "
will-change추가했는데 더 부드러워진 것 같아요"는 진단이 아닙니다. - 빠른 기기에서 "괜찮다"고 결론 내리지 마세요. 코드에서 확인한 내용과 실제 기기 녹화가 필요한 내용을 구분해서 말하세요.
- 절대
transition: all을 사용하지 마세요. 정확한 속성을 나열하세요.all은 다음에 변경되는 모든 것을 애니메이션 처리하며, 여기에는 아무도 의도하지 않은 레이아웃 속성이 포함됩니다. - 최선의 최적화를 위해 정확성을 희생하지 마세요. 하드웨어 가속은 브라우저가 부여할 수 있는 것입니다. 어떤 속성을 애니메이션할지는 당신이 통제하는 것입니다. 두 번째에 노력을 쏟으세요.
증상 표
여기서 시작하세요. 사용자가 보고 있는 현상을 매칭한 다음, 해당 섹션을 읽으세요.
| 문제 | 해결책 |
|---|---|
| 애니메이션 버벅임 | width/height/top/margin 대신 transform/opacity를 애니메이션 처리 |
| 그림자 애니메이션이 끊김 | filter: drop-shadow() 사용 |
| 탐색 또는 로딩 중에만 버벅임 | 애니메이션을 CSS 또는 WAAPI로 옮겨 메인 스레드 밖에서 실행 |
Framer Motion x/y가 프레임 드롭 | 전체 transform 문자열 애니메이션 처리 |
| 수제 애니메이션이 React에서 프레임 드롭 | 상태 대신 ref.current.style에 쓰기 |
| 드래그가 리스트가 커질수록 느려짐 | 상속된 CSS 변수 애니메이션 중단; transform을 직접 설정 |
| 블러 전환이 느림 (특히 Safari) | 애니메이션된 blur()를 20px 미만으로 유지 |
| 예상치 못한 속성들이 애니메이션됨 | transition: all을 명시적 속성으로 대체 |
| 모션 시작 또는 종료 시 요소가 1px 이동 | will-change: transform, 문제를 확인한 후에만 |
프레임 예산
60fps는 뇌가 부드러운 모션으로 읽는 속도이며 대부분의 주사율과 일치합니다. 브라우저에 프레임당 16.7ms(1000/60)를 줍니다. 120Hz 화면(현재 대부분의 휴대폰과 많은 노트북)에서는 8ms입니다. 더 작은 숫자에 맞춰 설계하세요.
규모를 위해: 복잡한 페이지의 레이아웃 재계산은 100ms 이상 걸릴 수 있습니다. 그것은 한 프레임을 놓치는 것이 아니라, 열두 프레임을 놓치는 것입니다.
두 가지 요소가 예산 내에 머무를지 결정합니다:
- 무엇을 애니메이션하는가: 각 프레임의 작업 비용.
- 어디서 실행되는가: 그 작업이 페이지의 다른 모든 작업에 의해 차단될 수 있는지.
무엇을 애니메이션하는가
시각적 변화를 보여주기 위해 브라우저는 최대 세 단계를 실행합니다:
- 레이아웃: 모든 요소의 크기와 위치를 계산합니다.
- 페인트: 페이지를 레이어에 그립니다.
- 컴포지트: 그 레이어들을 화면에 배치합니다.
모든 속성은 이 파이프라인에 세 지점 중 하나에서 진입하며, 비용은 규모가 다릅니다:
| 계층 | 속성 | 비용 |
|---|---|---|
| 컴포지트 전용 | transform, opacity, 그리고 현재 Chrome/Firefox에서는 filter, clip-path, background-color | 가장 저렴함. 브라우저가 이를 자체 레이어로 승격시킴. |
| 페인트 + 컴포지트 | box-shadow, border-radius, color | 재측정은 없지만, 매 프레임 다시 그려야 함. |
| 레이아웃 + 페인트 + 컴포지트 | width, height, padding, margin, top, left, border-width | 가장 비쌈. 하나의 커지는 요소가 주변 모든 것을 움직일 수 있음. |
transform과 opacity는 요소의 모양은 바꾸지만 문서 흐름 상의 위치는 바꾸지 않으므로, 아무것도 재측정할 필요가 없습니다. 컴포지트 계층의 다른 속성은 브라우저와 기기 간 테스트가 필요하며 지원이 고르지 않습니다.
계층을 낮추기:
| 대신 | 애니메이션 처리 |
|---|---|
width / height / padding (크기 조절) | scale() |
top / left / margin (이동) | translate() (백분율은 요소 자체 크기에 상대적) |
display / visibility 전환 | opacity |
box-shadow | filter: drop-shadow(…) |
마지막 것은 사람들이 놓치는 것입니다. 애니메이션된 그림자는无害해 보이지만 매 프레임 요소를 다시 그립니다.
두 가지가 뒤따릅니다:
- 레이아웃 애니메이션이 항상 프레임을 드롭하는 것은 아닙니다. 절대 위치가 지정된 요소나 자식이 거의 없는 요소는 눈에 띄는 비용 없이
padding을 애니메이션할 수 있습니다. 하지만padding애니메이션과scale()애니메이션은 똑같이 보이며, 둘 중 하나만 느린 기기에서 성능이 저하되지 않습니다. 단점이 없는 쪽을 선택하세요. - 레이아웃 속성은 또한 잘못된 것을 움직입니다. 사이드바의
margin-left를 애니메이션하면 매 프레임 옆의 콘텐츠를 밀어냅니다;translateX는 그렇지 않습니다. 열리는 사이드바 옆의 대시보드의 경우, 이는 전체 페이지가 초당 60번 재배치되는 것을 의미합니다.
어디서 실행되는가
JavaScript는 항상 메인 스레드에서 실행됩니다. requestAnimationFrame으로 구동되는 애니메이션은 브라우저가 하는 다른 모든 것과 경쟁합니다. 메인 스레드가 바쁘면 애니메이션 코드가 실행될 수 없고 프레임이 늦어집니다.
| 드라이버 | 스레드 |
|---|---|
JS + requestAnimationFrame (GSAP, motion/react 컴포넌트) | 항상 메인 스레드 |
Web Animations API (element.animate(), Motion의 animate()) | 하드웨어 가속 가능 |
| CSS 전환 및 키프레임 | 하드웨어 가속 가능 |
하드웨어 가속은 애니메이션이 메인 스레드 밖에서 실행됨을 의미하므로, JavaScript가 무엇을 하든 부드럽게 유지됩니다. "JS"와 "메인 스레드"는 동의어가 아닙니다: WAAPI에 작업을 넘기는 JS 라이브러리는 가속될 수 있지만, rAF 루프는 그럴 수 없습니다.
안정적으로 가속됨: transform 및 opacity. 지원 확대 중: filter, clip-path, background-color.
주시해야 할 패턴은 페이지가 무거운 작업을 하는 동안 실행되는 모션입니다: 페이지 전환, 탐색 중 탭 표시기, 데이터 가져오기나 하이드레이션과 함께 실행되는 모든 것. Vercel의 대시보드는 Framer Motion 공유 레이아웃 애니메이션으로 활성 탭 표시기를 애니메이션했었습니다. 브라우저가 정확히 그 순간에 다음 페이지를 로딩하고 있었기 때문에 프레임이 드롭되었습니다. CSS 애니메이션으로 옮기면서 해결되었습니다.
Framer Motion은 가속될 수 있지만, transform이 문자열일 때만 가능합니다:
<motion.div animate={{ x: 100 }} /> // 메인 스레드, 버벅일 수 있음
<motion.div animate={{ transform: "translateX(100px)" }} /> // 하드웨어 가속
x, y, scale, rotate 단축 속성은 CSS 변수로 구현되며, 애니메이션된 CSS 변수는 가속되지 않습니다. 단축 속성은 가독성이 더 좋으므로 기본적으로 유지하고, 애니메이션이 바쁜 메인 스레드에서 살아남아야 할 때 문자열 형식으로 전환하세요.
가속은 점진적 향상입니다. 브라우저는 버전마다 다른 조건에서 조용히 이를 거부합니다; 예를 들어 Chrome은 역사적으로 백분율 기반 변환을 가속하지 않았습니다. 이것이 translateY(100%) 사용을 중단해야 하는 이유는 아닙니다. 백분율은 크기를 알 수 없는 요소에 적합하며(Sonner와 Vaul이 토스트와 서랍을 배치하는 방식입니다), 정확성이 최선의 최적화보다 우선합니다.
React: 상태를 통해 애니메이션하지 마세요
애니메이션 라이브러리는 React의 렌더링 사이클 외부에서 애니메이션을 실행하므로 괜찮습니다. 함정은 매 프레임 setState를 호출하는 수제 애니메이션입니다: 컴포넌트는 16.7ms마다 리렌더링되고, 렌더링만으로도 예산이 초과됩니다.
// 프레임 드롭 발생: 프레임당 한 번 리렌더링
setY(nextY);
// 부드러움: 리렌더링 없음
ref.current.style.transform = `translateY(${nextY}px)`;
// 또는 motion value 사용: y.set(nextY)
requestAnimationFrame, pointermove, scroll, 또는 드래그 핸들러 내부의 상태 업데이트를 찾으세요.
올바르게 보이는 두 가지 함정
둘 다 transform을 사용하지만 여전히 프레임을 드롭합니다.
상속된 CSS 변수. Vaul의 드래그는 서랍에 약 20개 이상의 항목이 있을 때 느려졌으며, 리렌더링은 관련되지 않았습니다. 드래그 거리는 translateY()가 읽는 CSS 변수에 기록되었습니다:
// 모든 하위 요소의 스타일을 매 프레임마다 재계산
const style = { "--swipe-amount": `${draggedDistance}px` };
// 이 요소에만 영향
const style = { transform: `translateY(${draggedDistance}px)` };
CSS 변수는 상속되므로, 부모에서 하나를 변경하면 모든 자식에 대한 스타일 재계산이 트리거됩니다. 콘텐츠가 많을수록 각 프레임이 느려집니다. transform 애니메이션이 콘텐츠가 증가함에 따라 성능이 저하될 때 이를 의심하세요. Framer Motion의 단축 속성이 가속되지 않는 같은 이유입니다.
블러. filter: blur()는 특히 Safari에서 빠르게 비용이 많이 듭니다. 애니메이션된 블러를 20px 미만으로 유지하세요; 2–5px이면 크로스페이드를 부드럽게 하기에 충분합니다.
will-change
브라우저는 실행 중에 애니메이션을 CPU에서 GPU로 넘길 수 있으며, 이 핸드오프로 인해 시작 또는 끝에서 요소가 1픽셀 이동할 수 있습니다. will-change는 요소를 미리 승격시켜 핸드오프가 발생하지 않도록 합니다:
.drawer {
will-change: transform;
}
이동 현상이나 측정된 프레임 드롭을 확인한 후에만 추가하세요. 브라우저는 이미 transform 또는 opacity를 애니메이션하는 요소를 승격시키므로, 일반적인 경로에서는 아무것도 하지 않으며, 승격된 모든 레이어는 GPU 메모리를 소모합니다. 애니메이션하는 요소에만 적용하고, 절대 *나 큰 컨테이너에는 적용하지 마세요.
진정한 가치는 값비싼 페인트(예: 크게 흐려지거나 필터링된 요소)에서 나타납니다. 요소를 승격시키고 레이어를 작게 유지하세요; 작은 레이어는 전체 화면 레이어보다 컴포지트 비용이 저렴합니다.
JS 애니메이션을 피해야 할까요?
아니요. 메인 스레드 애니메이션은 그 동안 페이지가 바쁠 때만 문제가 되며, 이는 드물고 이제 이를 발견하는 방법을 알게 되었습니다.
Framer Motion은 CSS가 할 수 없는 것들을 제공합니다: 실제 스프링, 공유 레이아웃 애니메이션, 언마운트된 컴포넌트의 종료 애니메이션, 모멘텀 제스처. 문제는 이러한 기능이 필요한지 여부이지, JS 애니메이션이 허용되는지 여부가 아닙니다.
효과적인 분리: 간단한 모션과 페이지가 바쁠 때 실행되는 모든 것에는 CSS를 사용하고; 복잡하고, 중단 가능하며, 제스처 기반 모션에는 Framer Motion을 사용하세요.
워크플로우
1단계: 애니메이션 찾기
범위 내의 모든 애니메이션을 드라이버와 속성과 함께 나열하세요:
| 요소 | 드라이버 | 속성 | 실행 시점 |
|---|---|---|---|
| 탭 표시기 | motion/react layoutId | layout | 경로 변경 |
| 서랍 | CSS 전환 | transform | 사용자 드래그 |
| 카드 호버 | CSS 전환 | all | 호버 |
transition: all과 Motion 단축 속성을 실제로 애니메이션하는 것으로 확장하세요.
2단계: 진단
이 목록을 따라 내려가세요. 각각이 원인인 빈도순으로 정렬되어 있습니다:
- 어떤 속성이 애니메이션됩니까?
transform/opacity이외의 것은 첫 번째 용의자입니다: 먼저 레이아웃 속성, 그 다음box-shadow와border-radius(레이아웃처럼 보이지 않아 놓치기 쉽습니다). - 버벅임이 페이지가 바쁜 시점과 일치합니까? 탐색, 가져오기, 하이드레이션, 긴 작업. 애니메이션을 CSS 또는 WAAPI로 옮기세요.
- React가 프레임당 리렌더링됩니까? rAF, 포인터 또는 스크롤 핸들러의 상태 업데이트.
- 콘텐츠가 증가함에 따라 성능이 저하됩니까? 상속된 CSS 변수 또는 한 번에 너무 많은 노드가 애니메이션.
- CSS 변수가 전혀 애니메이션됩니까? 직접적으로, 또는 Motion의
x/y/scale을 통해. - 20px 이상의 애니메이션된
blur()가 있습니까? - 여전히 이동하거나 버벅입니까? 이제 애니메이션 요소에
will-change: transform을 추가하세요.
느낌이 아닌, 녹화로 확인하세요: 드롭된 프레임과 긴 작업을 위한 DevTools Performance 패널, 모션을 느리게 하기 위한 Animations 패널. 원격 디버깅을 통해 실제 중간 가격대의 휴대폰에서 프로파일링하세요. 데스크탑에서 CPU 스로틀링은 느린 CPU를 모델링하지만, 약한 GPU나 작은 메모리 예산은 모델링하지 않습니다.
3단계: 보고
각 발견 사항에 대해:
**[요소]**: `file.tsx:42`
원인: [위 원인 중 어떤 것]
이전: `transition: all 200ms` (`padding` 애니메이션, 레이아웃 트리거)
이후: `transition: transform 200ms` 및 `scale(0.97)`
확인: 코드에서 확인함 / 기기 녹화 필요
비용 순서대로 발견 사항을 정렬하세요: 레이아웃을 페인트보다 먼저, 바쁜 메인 스레드를 유휴 상태보다 먼저. 그런 다음 멈추세요. 요청 시 수정 사항을 적용하세요.