성능 좋은 웹 애니메이션
끊기는 웹 애니메이션의 #1 원인을 제거하세요: 브라우저가 매 프레임마다 레이아웃을 재계산(리플로우)하거나 리페인트하도록 강제하는 속성 애니메이션. 브라우저는 레이아웃 → 페인트 → 합성 단계로 렌더링합니다. width, height, top, left, margin, padding, box-shadow, 또는 filter를 애니메이션하면 매 프레임마다 레이아웃 및/또는 페인트가 다시 실행되어 메인 스레드를 차단합니다. **transform**과 **opacity**를 애니메이션하면 합성기(주로 GPU)에서 전적으로 실행되어 레이아웃과 페인트를 건너뛰며, 이것이 60/120fps에서 부드러운 애니메이션을 만드는 비결입니다.
사용 시기
애니메이션이 끊기거나 프레임이 드랍될 때, 호버/스크롤 효과가 무겁게 느껴질 때, 크기/위치/그림자를 애니메이션할 때, 레이아웃 변경(카드 재정렬, 컨테이너 간 요소 이동)을 부드럽게 애니메이션해야 할 때, height: auto를 애니메이션할 때, 또는 애니메이션 코드의 성능을 검토할 때 사용하세요.
핵심 규칙: transform과 opacity만 애니메이션
모든 "비싼" 애니메이션을 저렴한 등가물로 매핑하세요.
| 애니메이션 (비싼) | 트리거 | 대체 (저렴) |
|---|---|---|
width / height | 레이아웃 + 페인트 | transform: scaleX()/scaleY() (+ 실제 크기는 FLIP) |
top / left / margin | 레이아웃 + 페인트 | transform: translate() |
box-shadow | 페인트 | 그림자를 담은 의사 요소의 opacity 애니메이션 |
filter: blur() | 페인트 (무거움) | 두 레이어를 opacity로 교차 페이드, 또는 제한적으로 사용 |
background-position | 페인트 | 하위 레이어에서 transform: translate() |
color / background | 페인트 | 보통 괜찮음; 또는 레이어 교차 페이드 |
transform으로 위치와 크기 조정
/* 나쁨: 매 프레임 레이아웃 애니메이션 */
.box { transition: left 300ms, width 300ms; left: 0; width: 100px; }
.box:hover { left: 200px; width: 200px; }
/* 좋음: 합성기 전용 */
.box {
transition: transform 300ms ease;
transform: translateX(0) scaleX(1);
transform-origin: left center;
}
.box:hover { transform: translateX(200px) scaleX(2); }
scaleX는 내부 콘텐츠(텍스트)를 늘립니다. 왜곡 없이 실제 크기 조정을 하려면 FLIP을 사용하세요.
의사 요소 불투명도를 통한 저렴한 box-shadow
box-shadow를 애니메이션하면 매 프레임마다 큰 흐린 영역을 다시 페인트합니다. 대신 ::after에 그림자를 한 번 그리고, 그 opacity만 애니메이션하세요.
.card { position: relative; }
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 12px 28px rgba(0,0,0,0.35);
opacity: 0;
transition: opacity 300ms ease;
pointer-events: none;
}
.card:hover::after { opacity: 1; }
흐린 그림자는 한 번 래스터화되고, 호버는 합성기 불투명도만 변경합니다 — 모든 프레임 속도에서 부드럽습니다.
FLIP: 레이아웃 변경을 저렴하게 애니메이션
FLIP(First, Last, Invert, Play)은 transform만 사용하여 레이아웃 변경(재정렬, 크기 조정, 컨테이너 간 이동)을 애니메이션합니다. 요소가 있었던 위치(First)와 있을 위치(Last)를 측정하고, 역변환을 적용하여 시각적으로 움직이지 않은 것처럼 보이게 한 다음(Invert), 변환을 다시 원래대로 애니메이션합니다(Play). DOM은 실제 최종 레이아웃에 있게 되며, 모션은 순수 합성기 작업입니다.
function flip(el, mutate) {
const first = el.getBoundingClientRect(); // First
mutate(); // DOM/레이아웃 변경
const last = el.getBoundingClientRect(); // Last
const dx = first.left - last.left;
const dy = first.top - last.top;
const sx = first.width / last.width;
const sy = first.height / last.height;
el.animate(
[
{ transformOrigin: 'top left',
transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})` }, // Invert
{ transformOrigin: 'top left', transform: 'none' }, // Play
],
{ duration: 300, easing: 'cubic-bezier(0.2, 0, 0, 1)' }
);
}
// 사용법: 새 그리드 위치로 이동하는 요소 애니메이션
flip(card, () => targetContainer.appendChild(card));
Web Animations API animate()는 합성기에서 transform을 실행합니다. 여러 요소의 경우, 모든 getBoundingClientRect() 읽기를 어떤 변경보다 먼저 일괄 처리하세요 (아래 레이아웃 스래싱 참조).
height: auto 애니메이션
height: auto는 역사적으로 보간이 불가능했습니다. 최신 및 폴백 접근 방식:
/* 최신 (Chrome 129+/지원 브라우저): opt size 키워드를 애니메이션에 포함 */
.panel {
interpolate-size: allow-keywords; /* auto로/부터 애니메이션 활성화 */
height: 0;
overflow: clip;
transition: height 300ms ease;
}
.panel.open { height: auto; }
/* calc-size()도 작동: height: calc-size(auto, size); */
/* 오늘날 강력한 폴백: CSS 그리드 1fr -> 0fr */
.wrapper {
display: grid;
grid-template-rows: 0fr; /* 접힘 */
transition: grid-template-rows 300ms ease;
}
.wrapper.open { grid-template-rows: 1fr; }
.wrapper > .content { overflow: hidden; min-height: 0; }
그리드 트릭은 트랙 크기를 애니메이션하며(합성기 친화적이고 널리 지원됨), JS 높이 측정이 필요 없습니다. 대상 브라우저가 지원하는 곳에서는 interpolate-size: allow-keywords / calc-size(auto, size)를 사용하고, 크로스 브라우저 기본값으로 그리드 기법을 유지하세요.
레이아웃 스래싱 방지 (읽기 일괄 처리 후 쓰기)
쓰기 후에 레이아웃 속성(offsetWidth, getBoundingClientRect, scrollTop, getComputedStyle)을 읽으면 동기식 리플로우가 강제됩니다. 루프에서 읽기와 쓰기를 번갈아 수행하면("레이아웃 스래싱") 프레임당 수십 번의 강제 리플로우가 발생할 수 있습니다.
// 나쁨: 읽기, 쓰기, 읽기, 쓰기... 매 반복마다 리플로우 강제
items.forEach((el) => {
const w = el.offsetWidth; // 읽기 (레이아웃 강제)
el.style.width = w * 1.5 + 'px'; // 쓰기 (레이아웃 무효화)
});
// 좋음: 모든 읽기를 먼저 일괄 처리한 다음 모든 쓰기
const widths = items.map((el) => el.offsetWidth); // 모든 읽기
items.forEach((el, i) => { // 모든 쓰기
el.style.width = widths[i] * 1.5 + 'px';
});
프레임 동기화 작업의 경우 requestAnimationFrame 콜백에서 읽고 쓰기를 적용하세요. fastdom과 같은 라이브러리가 이 읽기/쓰기 스케줄링을 공식화합니다.
will-change: 신중하게 사용
will-change: transform은 요소를 미리 자체 합성기 레이어로 승격시켜 애니메이션 시작 시 끊김을 방지합니다. 그러나 모든 승격된 레이어는 GPU 메모리를 소비하며, 과도하게 사용하면 성능이 저하됩니다.
.menu { will-change: transform; } /* 애니메이션할 요소에만 */
규칙: 애니메이션 직전에만 적용(예: 호버/부모 상태), 유휴 상태가 되면 제거(will-change: auto), 많은 요소에 무분별하게 적용하지 않음, 크거나 많은 수의 노드에 영구적으로 남겨두지 않음. 단일 transform: translateZ(0) 핵도 동일한 승격을 수행하지만 되돌리기가 더 어렵습니다 — will-change를 선호하세요.
제공 및 검증 (독립형 HTML)
패키지 도우미 (
scripts/):scripts/seek-shot.sh anim.html 0 1.5 3이?t=N하네스를 고정하고 각 순간을 스크린샷으로 캡처합니다.scripts/contact-sheet.sh sheet.png frame-*.png는 한눈에 검토할 수 있도록 타일링합니다.scripts/README.md를 참조하세요.
산출물은 브라우저에서 직접 열리는 하나의 자체 포함 .html 파일입니다 — 마크업, CSS/JS 애니메이션, 고정 하네스가 모두 하나의 파일에 있습니다. 이 스킬에서 검증은 두 가지 측면으로 이루어집니다: 프레임이 올바르게 보여야 하고 그리고 생성 비용이 저렴해야 합니다(합성기 전용).
출력 계약:
- 하나의
.html, CDN을 통한 종속성(있는 경우), CSS 전환/@keyframes또는 Web Animations API로 구동되는 애니메이션. - 스크린샷이 결정적 프레임에 고정되도록 하는 고정 메커니즘:
- CSS
@keyframes→?t=N이el.style.animationDelay = (-N)+'s'; el.style.animationPlayState = 'paused'를 설정합니다. - WAAPI / JS → 애니메이션 객체를 유지하고
anim.pause(); anim.currentTime = N*1000을 실행합니다.
- CSS
검증 루프 — 고정 → 스크린샷, 그런 다음 끊김 프로파일링:
- 시작/중간/끝(
?t=0, 중간, 끝)에서 헤드리스로 열고, 각각 스크린샷을 캡처합니다. 모션이 시각적으로 올바른지 확인합니다(scaleX로 인한 텍스트 잘림/늘어짐 없음, FLIP이 실제 레이아웃에 정확히 위치, 그림자/높이 전환이 올바르게 보임). - 합성기 전용인지 확인 — 이 스킬의 핵심입니다. 다음 중 하나를 수행합니다:
- DevTools → Performance: 애니메이션을 녹화하고, 프레임당 보라색 "Layout" 또는 녹색 "Paint" 밴드가 없는지 확인합니다("Composite Layers"만 있어야 함).
- 또는 헤드리스 트레이스: 시각적 확인을 위해
npx playwright screenshot을 사용하고, CDP/tracing캡처로 애니메이션 윈도우 동안Layout/Paint이벤트가 발생하지 않았는지 단언합니다.
- FPS/"Frame Rendering Stats" 오버레이가 60을 유지하는지 확인합니다 — 드랍된 프레임은 비싼 속성이 다시 사용되었음을 의미합니다.
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/anim.html?t=0.3" frame.png
완료 전 확인 사항:
- 독립형으로 열림 — 콘솔 오류 없음.
- 프레임당
transform/opacity만 애니메이션됨. DevTools에 프레임당 레이아웃/페인트 없음. - 시작/중간/끝에서 스크린샷 캡처 — 정확함,
scaleX텍스트 왜곡 없음, FLIP이 정확히 위치. - 60fps 유지.
will-change가 적시에 적용되고 유휴 상태에서 제거됨(잔여 레이어 없음). prefers-reduced-motion준수.
빠른 참조
| 목표 | 이렇게 하세요 |
|---|---|
| 요소 이동 | transform: translate() |
| 왜곡 없이 크기 조정 | transform을 사용한 FLIP |
| 호버 시 그림자 | 그림자 의사 요소의 opacity 애니메이션 |
| 콘텐츠 높이로 확장 | 그리드 0fr→1fr, 또는 interpolate-size: allow-keywords |
| 여러 요소 이동 | getBoundingClientRect 읽기를 일괄 처리한 다음 쓰기 |
| 부드러운 애니메이션 시작 | 적시에 will-change, 유휴 상태에서 제거 |
| 합성기 전용인지 확인 | DevTools Performance: 프레임당 보라색 "Layout"/녹색 "Paint" 없음 |
주의사항
scaleX/scaleY는 텍스트와 자식을 늘립니다. 콘텐츠가 선명하게 유지되어야 하면 FLIP을 사용하세요.transform백분율은 요소 자체의 박스에 상대적이며, 부모가 아닙니다 —left: %와 다릅니다.will-change또는translateZ(0)를 과도하게 사용하면 너무 많은 레이어가 생성되어 성능이 저하됩니다. 애니메이션하는 것만 승격하세요.filter와backdrop-filter는 합성기와 관련이 있지만 여전히 비용이 많이 듭니다. 블러 반경을 애니메이션하는 대신 불투명도 교차 페이드를 통해 그 존재를 애니메이션하세요.- 그리드
1fr→0fr콘텐츠는overflow: hidden과min-height: 0이 있어야 축소됩니다. - 필수적이지 않은 모션은 항상
@media (prefers-reduced-motion: reduce)뒤에 게이트하세요.
참조 파일
references/patterns-and-profiling.md— 전체 실행 가능한 예제(아코디언, 목록 재정렬, 패럴랙스), 합성기 전용 프레임 확인을 위한 DevTools 프로파일링 안내, 축소 모션 패턴, 속성 비용 치트 시트.