CSS vs JS 애니메이션, 진짜 차이는 스레드에 있다
JS 애니메이션이 느리다는 말의 이유
들어가며
CSS vs. JavaScript (Josh W. Comeau) 를 읽고 설명을 더해 정리한 글입니다.
“JS 애니메이션은 CSS보다 느리니까 웬만하면 CSS 트랜지션을 써라”라는 말, 다들 한 번쯤 들어보셨을 텐데요. 저자는 이 통념의 결론은 맞지만, 흔히 대는 이유가 틀렸다고 말합니다. JS가 좌표를 계산하고 DOM을 건드리는 비용 때문이 아니라는 거죠. 그럼 진짜 이유는 뭘까요?
같은 애니메이션, 두 가지 구현
좌우로 튕기는 공 애니메이션을 CSS 키프레임으로 만들면 이렇게 생겼습니다.
@keyframes bounce {
to {
transform: translateX(calc(var(--bounce-magnitude) * -1));
}
}
.ball {
--bounce-magnitude: 200px;
animation: bounce 1000ms infinite alternate;
}
같은 애니메이션을 순수 JavaScript로 만들면 requestAnimationFrame 루프가 됩니다.
매 프레임(보통 초당 60회)마다 경과 시간을 기준으로 위치를 계산해서 스타일을 직접 갱신하는 방식이에요.
const startTime = performance.now()
const ball = document.querySelector(".ball")
function animate() {
const elapsedTime = performance.now() - startTime
// 경과 시간을 기반으로 x 좌표 계산
ball.style.transform = `translateX(${x}px)`
window.requestAnimationFrame(animate)
}
직관적으로는 JS 버전이 느린 이유를 “매 프레임 좌표를 계산해야 해서” 혹은 “JS에서 DOM을 조작하는 비용 때문에”라고 생각하기 쉬운데요. 요즘 브라우저 엔진에게 이 정도 연산은 1밀리초도 안 되는 가벼운 작업입니다.
진짜 차이는 “어느 스레드에서 도느냐”
핵심은 JS 애니메이션은 메인 스레드에서 돌고, CSS 트랜지션과 키프레임은 별도 스레드(컴포지터 스레드)에서 돈다는 점입니다.
메인 스레드는 애플리케이션의 거의 모든 일이 일어나는 곳이에요.
React 같은 프레임워크의 리렌더링, fetch 응답 파싱, 클릭 같은 사용자 인터랙션 처리가 전부 여기서 실행됩니다.
requestAnimationFrame 루프도 이 대기열에 같이 줄을 서기 때문에, 메인 스레드가 바빠지는 순간 애니메이션 프레임이 밀리기 시작해요.
원문에는 주기적으로 메인 스레드를 블로킹하면서 두 버전을 비교하는 데모가 있는데요. 결과가 아주 극적입니다. JS 루프로 만든 공은 블로킹될 때마다 뚝뚝 멈추지만, CSS 키프레임으로 만든 공은 아무 일 없다는 듯 부드럽게 움직여요. 메인 스레드가 아무리 바빠도 컴포지터 스레드는 영향을 받지 않거든요.
실제 서비스에서도 이런 장면을 자주 봅니다. 데이터를 불러오는 동안 스피너를 돌렸는데, 응답이 도착해서 UI를 갱신하는 순간 스피너가 잠깐 얼어붙는 경험이요. JS로 구현된 스피너가 응답 파싱, 리렌더링과 메인 스레드를 놓고 경쟁하다가 밀린 겁니다.
CSS라고 저절로 빨라지는 건 아니다
그런데 여기에 반전이 하나 있습니다. CSS 애니메이션이라고 전부 별도 스레드에서 도는 게 아니에요. 무엇을 움직이느냐가 더 중요합니다.
브라우저가 화면을 그리는 과정은 크게 레이아웃(요소의 위치와 크기 계산) → 페인트(픽셀로 그리기) → 컴포지트(레이어 합성) 순서로 진행되는데요.
transform, opacity, filter 같은 속성은 레이아웃과 페인트를 다시 계산할 필요가 없어서 컴포지터 스레드에서 처리할 수 있습니다.
반면 margin-left처럼 요소의 위치를 바꾸는 속성은 매 프레임 레이아웃을 다시 계산해야 하고, 레이아웃 계산은 메인 스레드의 일이에요.
그래서 margin-left를 CSS 키프레임으로 움직이면, CSS를 썼는데도 메인 스레드가 바쁠 때 똑같이 버벅입니다.
결국 언어의 문제가 아니라 속성과 스레드의 문제인 거죠.
애니메이션에는 가능한 한 transform과 opacity를 쓰라는 오래된 조언이 여기서 나온 겁니다.
라이브러리 비교: Motion vs GSAP
이 렌즈로 보면 애니메이션 라이브러리를 고르는 기준도 달라집니다. 원문에서는 Motion (구 Framer Motion) 과 GSAP 을 같은 블로킹 데모로 비교하는데요. 메인 스레드가 막혔을 때 Motion은 CSS 키프레임처럼 부드럽게 이어지고, GSAP은 JS 루프처럼 멈춥니다.
Motion이 부드러운 비결은 Web Animations API(WAAPI) 입니다.
WAAPI는 CSS 키프레임과 같은 저수준 애니메이션 엔진을 JS에서 쓸 수 있게 해주는 브라우저 API라서, 조건이 맞으면 애니메이션을 별도 스레드로 넘길 수 있어요.
반면 GSAP은 requestAnimationFrame 기반이라 메인 스레드를 벗어날 수 없습니다.
마무리
저자의 실무 원칙은 단순합니다.
기본은 네이티브 CSS 애니메이션/트랜지션이고, CSS만으로 안 되는 게 확실할 때만 Motion 같은 라이브러리를 씁니다.
그리고 View Transitions, linear() 타이밍 함수, 스크롤 기반 Animation Timeline까지 나온 요즘 CSS는 충분히 강력해져서, 라이브러리가 정말 필요한 경우 자체가 많지 않다고 해요.
저는 이 글에서 “느리다”는 감각을 진단하는 순서를 배웠습니다. 라이브러리 탓, 언어 탓을 하기 전에 지금 무슨 속성을 어느 스레드에서 움직이고 있는지 먼저 보는 것. JS냐 CSS냐라는 질문을 메인 스레드냐 컴포지터 스레드냐, 레이아웃을 건드리느냐 아니냐로 바꿔 들으면, 성능 문제의 원인과 해결책이 훨씬 명확해집니다.