
웹 애니메이션 성능 — Framer Motion과 CSS, 언제 무엇을 쓸까
transform·opacity=컴포지터, 어느 스레드에서 실행하느냐가 성능을 가른다. CSS와 Framer Motion(하이브리드 엔진)의 트레이드오프, 레이아웃 스래싱·인터럽션·Next.js SSR 함정까지 다루는 실전 가이드.
요약
- 애니메이션이 버벅이는 이유는 대부분 "무엇을 애니메이션하느냐" 와 "어느 스레드에서 실행하느냐" 두 가지로 갈린다.
transform과opacity만 애니메이션하면 브라우저가 레이아웃·페인트를 건너뛰고 컴포지터(GPU) 로만 처리해 부드럽다.width·top·box-shadow같은 속성은 매 프레임 레이아웃/페인트를 다시 해 비싸다.- CSS 트랜지션/애니메이션은 (transform·opacity 한정) 컴포지터 스레드에서 실행돼 메인 스레드가 바빠도 끊기지 않는다. JS 애니메이션은 유연하지만 메인 스레드에서 실행돼 잘못 쓰면 잰다.
- Framer Motion(현 Motion) 은 하이브리드 엔진이다. transform·opacity 는 WAAPI로 하드웨어 가속하고, 스프링 물리·
layout애니메이션 등은 메인 스레드에서 실행한다. 강력한 만큼 번들 비용과 메인 스레드 부담이라는 대가가 있다. - 규칙은 단순하다. 간단한 것은 CSS, 인터랙션·제스처·레이아웃 전환은 Framer Motion. 그리고 무엇을 쓰든
transform/opacity로 수렴시키고prefers-reduced-motion을 존중한다.
라이브러리 API·번들 크기·브라우저 지원 범위는 바뀐다. 도입 전 Motion 공식 문서와 web.dev 애니메이션 가이드에서 최신본을 확인한다.
1. 왜 Framer Motion인가
성능을 논하기 전에, 왜 이 라이브러리를 쓰는지부터 짚는다. 도구 선택의 근거가 분명해야 이후의 트레이드오프도 판단할 수 있다.
1.1 강점
- 선언적 React API: 상태에 따라 애니메이션을 선언한다(
animate·whileHover·whileTap). 명령형 타임라인을 직접 관리하지 않아 React 코드와 자연스럽게 붙는다. - CSS로 어려운 것을 표준 방식으로: 드래그·제스처, 레이아웃 애니메이션(FLIP), 스프링 물리, 순차 등장(stagger·variants) 오케스트레이션.
- 하이브리드 엔진: transform·opacity 는 WAAPI로 하드웨어 가속하고, 복잡한 것만 JS로 처리해 성능과 표현력을 절충한다(뒤에서 상술).
- 인터럽션에 강함: 진행 중 애니메이션의 속도를 이어받아, 사용자가 도중에 개입해도 자연스럽다.
1.2 한계 (트레이드오프)
- 번들 비용: 기능이 많은 만큼 무겁다.
m+LazyMotion으로 필요한 기능만 지연 로드해 완화한다. - 메인 스레드 부담: 스프링·
layout등은 JS로 실행되므로, 오남용하면 잰다. - React 종속: React 프로젝트용이다. 순수 JS·타 프레임워크에는 Motion One·GSAP 등 다른 선택이 맞다.
- 단순 모션엔 과함: 호버·페이드 정도는 CSS가 더 싸고 가볍다.
1.3 시장 위치 (도입 근거)
React 애니메이션 라이브러리 중 가장 널리 쓰인다. npm 공식 주간 다운로드(2026년 9월 기준)는 다음과 같다.
| 라이브러리 | 주간 다운로드(건) |
|---|---|
Framer Motion (framer-motion) |
약 3,400만 |
Motion (신 패키지 motion) |
약 1,500만 |
GSAP (gsap) |
약 350만 |
react-spring (@react-spring/web) |
약 390만 |
framer-motion 과 신 패키지 motion 을 합치면 경쟁 라이브러리를 10배 이상 앞선다. "가장 많이 검증된 선택지"라는 점 자체가 도입·유지보수 관점의 근거가 된다. 수치는 시점에 따라 바뀌니 실시간은 npmtrends에서 확인한다.
1.4 언제 고르나
- React + 인터랙션·제스처·레이아웃 전환·오케스트레이션 이 필요하다 → Framer Motion.
- 단순 전환(호버·페이드·진입) 만이다 → CSS.
- 타임라인 기반 정밀 시퀀스·스크롤 시네마틱, 또는 비React → GSAP·Motion One 등을 검토.
2. 왜 어떤 애니메이션은 버벅이나 — 렌더링 파이프라인
2.1 파이프라인
브라우저는 화면을 이렇게 그린다.
JS/스타일 변경 → Style(계산) → Layout(배치) → Paint(픽셀) → Composite(합성)
- Layout(리플로우): 요소의 크기·위치를 다시 계산.
width·height·top·left·margin등을 바꾸면 발생. 한 요소가 바뀌면 주변까지 연쇄된다. - Paint: 색·그림자·배경 등 픽셀을 다시 칠함.
color·background·box-shadow등에서 발생. - Composite: 이미 그려진 레이어를 GPU가 합침.
transform·opacity는 이 단계만 거친다.
60fps는 프레임당 예산이 약 16.7ms 다. Layout·Paint를 매 프레임 반복하면 이 예산을 금세 넘겨 프레임이 밀린다(jank).
2.2 싼 속성 vs 비싼 속성
핵심 원칙: 이동은 top/left 가 아니라 transform: translate, 크기는 width/height 가 아니라 transform: scale 로 한다.
/* 나쁜 예: 매 프레임 레이아웃 — 비쌈 */ .box { transition: left .3s; } .box:hover { left: 40px; } /* 좋은 예: 컴포지터만 — 부드러움 */ .box { transition: transform .3s; } .box:hover { transform: translateX(40px); }
| 애니메이션 대상 | 유발 단계 | 비용 |
|---|---|---|
transform, opacity |
Composite | 낮음(권장) |
color, background, box-shadow, filter |
Paint | 중간 |
width, height, top, left, margin |
Layout | 높음(회피) |
box-shadow 애니메이션이 무겁다면, 그림자를 가진 가짜 요소를 얹고 그 opacity 를 애니메이션하는 우회가 흔하다.
3. CSS냐 JS냐 — 실행 위치가 성능을 가른다
3.1 CSS 트랜지션/애니메이션
transition·@keyframes 로 transform/opacity 를 움직이면, 브라우저가 이를 컴포지터 스레드에서 실행한다. 메인 스레드(JS)가 바빠도 애니메이션은 계속 부드럽다. 선언적이고 비용이 낮아, 호버·진입·상태 전환 같은 단순 모션의 1순위다.
한계는 표현력이다. 복잡한 오케스트레이션(순차 등장), 제스처(드래그), 스프링 물리, 중간 상태에서의 부드러운 방향 전환(interruption)은 CSS만으로 어렵다.
3.2 JS(requestAnimationFrame)
requestAnimationFrame 기반 JS 애니메이션은 메인 스레드에서 매 프레임 값을 계산한다. 동적·상호작용 애니메이션에 필수지만, 메인 스레드가 무거운 작업(대량 렌더·계산)을 하면 프레임을 놓친다. 무거운 핸들러는 디바운스/requestIdleCallback 등으로 분리한다.
3.3 Web Animations API(WAAPI)
element.animate() 는 JS로 제어하되, transform/opacity 같은 속성은 컴포지터에서 하드웨어 가속으로 실행될 수 있다. "JS의 제어 + CSS의 성능"을 절충한 표준 API이며, 아래 Framer Motion의 가속 경로가 바로 이것이다.
4. Framer Motion(Motion)의 하이브리드 엔진
Framer Motion(현재 이름 Motion)은 속성과 브라우저 기능에 따라 엔진을 골라 쓰는 하이브리드다.
4.1 언제 GPU, 언제 메인 스레드인가
- transform·opacity: 가능하면 WAAPI로 하드웨어 가속(컴포지터). → 부드럽고 메인 스레드 부담 적음.
- 스프링 물리, 색·레이아웃 등 합성 불가 속성: 메인 스레드 JS 드라이버로 폴백. → 표현력은 높지만 메인 스레드가 바쁘면 영향받음.
즉 같은 Framer Motion이라도 무엇을 애니메이션하느냐로 성능이 갈린다. 값은 x/scale/opacity 로 수렴시키는 것이 유리하다.
// 좋은 예: 가속 경로에 잘 맞는 형태 <motion.div initial={{ opacity: 0, y: 24 }} whileInView={{ opacity: 1, y: 0 }} />
4.2 layout 프롭(FLIP) — 강력하지만 비쌈
layout 프롭은 FLIP(First·Last·Invert·Play) 기법으로 레이아웃 변화를 transform 으로 부드럽게 잇는다. 리스트 재정렬·펼침에 강력하지만, 변화 전후 위치를 측정(레이아웃 읽기) 하므로 요소가 많으면 리플로우 비용이 커진다.
- 긴 리스트 전체에
layout을 남발하지 않는다. - 꼭 필요한 소수 요소에만 적용하고,
LayoutGroup으로 범위를 좁힌다.
4.3 번들 비용 — LazyMotion·m
Framer Motion은 기능이 많은 만큼 번들이 크다. m 컴포넌트 + LazyMotion 으로 필요한 기능만 지연 로드하면 초기 번들을 크게 줄인다.
import { LazyMotion, domAnimation, m } from 'framer-motion';
<LazyMotion features={domAnimation}>
<m.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} />
</LazyMotion>
m은motion의 경량 버전(기능은LazyMotion이 주입).- 드래그 등 추가 기능이 필요하면 해당 feature 번들만 더한다.
5. 레이아웃 스래싱 — 읽기와 쓰기를 분리하라
transform 만 써도 JS가 레이아웃 값을 잘못된 순서로 만지면 프레임이 밀린다. 애니메이션 성능의 숨은 병목이다.
5.1 무엇이 문제인가
브라우저는 스타일·레이아웃 계산을 미뤄 두었다가 한 번에 처리한다. 그런데 JS가 offsetWidth·getBoundingClientRect() 같은 레이아웃 값을 읽으면, 브라우저는 정확한 값을 주기 위해 대기 중인 레이아웃을 즉시 확정한다(강제 동기 레이아웃, forced synchronous layout). 쓰기(스타일 변경) 직후에 바로 읽으면 이 확정이 반복돼 한 프레임에 레이아웃이 여러 번 돈다(레이아웃 스래싱, layout thrashing).
5.2 어떻게 해결하나
한 프레임에서 읽기를 모두 먼저, 쓰기를 나중에 배치한다. 루프 안에서 읽기와 쓰기를 교차하지 않는다.
// 나쁜 예: 읽기-쓰기 교차 → 매 반복마다 강제 동기 레이아웃
els.forEach((el) => { const w = el.offsetWidth; el.style.width = w + 10 + 'px'; });
// 좋은 예: 읽기를 먼저 모아서, 쓰기는 나중에 일괄
const widths = els.map((el) => el.offsetWidth);
els.forEach((el, i) => { el.style.width = widths[i] + 10 + 'px'; });
- 측정이 꼭 필요하면
requestAnimationFrame으로 읽기/쓰기 타이밍을 분리한다. - Framer Motion의
layout(FLIP)도 내부적으로 측정 단계가 있으니, 적용 대상을 최소화하는 것이 이 원리의 연장선이다.
6. 인터럽션과 속도 인계 — 왜 스프링인가
"부드러움"은 프레임 속도만의 문제가 아니다. 사용자가 애니메이션 도중 개입할 때 자연스러운지가 체감 품질을 가른다.
6.1 무엇이 문제인가
CSS 트랜지션은 값을 시간(duration) 기준으로 보간한다. 그래서 애니메이션이 끝나기 전에 다시 트리거되면, 현재 속도를 잃고 처음부터 다시 시작해 "툭 끊기는" 느낌이 난다. 반면 스프링·값 기반 애니메이션(Framer Motion)은 그 순간의 위치와 속도를 이어받아(velocity handoff) 방향만 부드럽게 바꾼다.
6.2 어떻게 해결하나
- 사용자가 도중에 개입하는 인터랙션(드래그·토글 연타·호버 인아웃)에는 스프링을 쓴다.
- 정적·단발 전환(진입·페이지 상태)에는 CSS(duration) 로 충분하다.
// 드래그·제스처 — 현재 속도를 이어받는 스프링(중간 개입에 강함)
<motion.div drag dragConstraints={ref}
transition={{ type: 'spring', stiffness: 400, damping: 30 }} />
- 스프링은
duration이 아니라stiffness(강성)·damping(감쇠)으로 감을 조율한다. 값이 클수록 빠르고 단단하며,damping이 낮으면 통통 튄다.
7. 트레이드오프 — 무엇을 언제
| 상황 | 권장 | 이유 |
|---|---|---|
| 호버·진입·상태 전환(단순) | CSS | 컴포지터·무번들·선언적 |
| 순차 등장·스크롤 리빌(가벼움) | CSS 또는 Framer Motion | 규모에 따라 |
| 드래그·제스처·스프링 물리 | Framer Motion | CSS로는 어려움 |
| 리스트 재정렬·펼침(레이아웃 전환) | Framer Motion layout |
FLIP, 단 소수에만 |
| 인터럽트(중간 방향 전환) | Framer Motion(스프링) | 속도 인계 |
| 초기 번들이 민감한 랜딩 | CSS 우선 + LazyMotion |
JS 비용 절감 |
원칙: CSS로 되는 것은 CSS로, 안 되는 것만 Framer Motion. 그리고 무엇을 쓰든 transform/opacity 로 표현한다.
8. Next.js SSR 함정 — 하이드레이션 실수
Next.js(App Router) SSR + Framer Motion v12에서 자주 나오는 실수다. 서버가 그린 HTML과 클라이언트 초기 렌더가 다르면 하이드레이션 불일치 에러가 난다.
initial없이animate만 쓰기: 서버엔 스타일이 없고 클라이언트는 즉시 적용 → 불일치.- SVG
pathLength애니메이션: SSR에서 path 총 길이를 계산할 수 없어 불일치. - 없던
motion.button·motion.a새로 추가: CSS 커스텀 프로퍼티 초기값 불일치.
// 나쁜 예: initial 없음 → 서버/클라 불일치
<motion.div animate={{ width: '50%' }} />
// 좋은 예: initial 명시(그리고 width 대신 transform 권장)
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} />
// 좋은 예: 새 인터랙션 요소는 일반 태그 + CSS 로 대체(모션 래퍼 신규 생성 회피)
<button className="transition-transform duration-100 active:scale-[0.97]">클릭</button>
안전 패턴: initial 을 항상 명시하고, 새 인터랙션 래퍼를 만들기보다 기존 motion 요소에 whileTap 등을 추가하거나 일반 요소 + CSS 트랜지션으로 대체한다. pathLength 대신 opacity 로 SVG 등장을 표현한다.
9. 스크롤·접근성
9.1 스크롤 연동
- Framer Motion:
useScroll+useTransform으로 스크롤 진행도를 값에 매핑. 유연하지만 메인 스레드에서 계산한다 — 매핑 대상은transform/opacity로 한정한다. - 네이티브 CSS
scroll-timeline: 지원 브라우저에서는 스크롤 연동을 컴포지터에서 처리해 더 가볍다. 지원 범위를 확인하고, 미지원 시 JS로 폴백한다.
9.2 prefers-reduced-motion
모션 민감 사용자를 위해 반드시 감축 모드를 제공한다.
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
// Framer Motion const reduce = useReducedMotion(); <motion.div animate={reduce ? {} : { y: 0 }} initial={reduce ? false : { y: 24 }} />
10. 측정 — 감이 아니라 데이터로
- DevTools > Performance: 녹화 후 프레임 드롭·Long Task, Layout/Paint 발생 구간을 본다. 보라색 "Layout" 막대가 애니메이션 중 반복되면 레이아웃 스래싱 신호다.
- Rendering 패널: "Paint flashing"(다시 칠해지는 영역), "Layer borders"(합성 레이어), FPS 미터.
- 레이어 수 관리:
will-change나 가속으로 레이어가 과도하게 늘면 메모리를 먹는다. 애니메이션 직전에 켜고 끝나면 해제한다.
/* 애니메이션 대상에만, 그리고 남용 금지 */ .mover { will-change: transform; }
11. 한계와 안티패턴
| 안티패턴 | 문제 | 개선 |
|---|---|---|
top/left/width 애니메이션 |
매 프레임 레이아웃 | transform 으로 대체 |
| 쓰기 직후 레이아웃 값 읽기 | 강제 동기 레이아웃·스래싱 | 읽기 먼저·쓰기 나중 |
모든 요소에 will-change |
메모리 과다·역효과 | 대상 한정, 끝나면 해제 |
긴 리스트 전체에 layout |
리플로우 폭증 | 소수에만·LayoutGroup |
| 인터랙션에 CSS duration만 | 중간 개입 시 툭 끊김 | 스프링·속도 인계 |
| 단순 모션까지 Framer Motion | 번들·메인 스레드 낭비 | CSS로 처리 |
initial 없는 motion(SSR) |
하이드레이션 불일치 | initial 명시 |
| 감축 모드 미제공 | 접근성 위반 | prefers-reduced-motion 대응 |
실행 체크리스트
- 움직임·크기를
transform(translate·scale)으로, 페이드를opacity로 표현한다 - 단순 모션은 CSS, 제스처·레이아웃 전환은 Framer Motion으로 나눴다
- JS에서 읽기(측정)를 먼저 모으고 쓰기를 나중에 배치했다(스래싱 회피)
- 중간 개입이 있는 인터랙션은 스프링(속도 인계)으로 처리했다
- Framer Motion은
m+LazyMotion으로 번들을 줄였다 layout프롭은 소수 요소에만 쓴다- SSR: 모든
motion요소에initial을 명시하고pathLength를 피했다 prefers-reduced-motion(및useReducedMotion)을 처리했다- DevTools Performance/Rendering으로 프레임·레이어를 실제 측정했다
참고 자료 (공식·표준 문서)
아래는 작성 시점 기준의 공식·표준 문서다. 라이브러리 API·브라우저 지원 범위는 바뀌니 각 문서의 최신본을 확인한다.
애니메이션 성능 — 표준
web.dev — 애니메이션 가이드web.dev/articles/animations-guideweb.dev — 컴포지터 전용 속성·레이어 관리web.dev/articles/stick-to-compositor-only-properties-and-manage-layer-countweb.dev — 레이아웃 스래싱 피하기(Avoid large, complex layouts)web.dev/articles/avoid-large-complex-layouts-and-layout-thrashingMDN — Web Animations APIdeveloper.mozilla.org/en-US/docs/Web/API/Web_Animations_APIMDN — will-changedeveloper.mozilla.org/en-US/docs/Web/CSS/will-changeMDN — prefers-reduced-motiondeveloper.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motionMDN — CSS scroll-timelinedeveloper.mozilla.org/en-US/docs/Web/CSS/scroll-timeline