CSSMedium#82
CSS 애니메이션과 JavaScript 애니메이션의 차이점과 각각의 적합한 사용 시나리오를 설명해주세요.
#CSS#애니메이션#성능#GPU
힌트
브라우저의 렌더링 파이프라인과 합성(Compositing) 레이어를 생각해보세요.
정답 및 해설
CSS 애니메이션과 JavaScript 애니메이션의 차이점과 각각의 적합한 사용 시나리오를 설명해주세요.
CSS 애니메이션과 JavaScript 애니메이션은 웹에서 동적인 시각 효과를 구현하는 두 가지 주요 방법입니다. 브라우저 렌더링 파이프라인의 어느 단계에서 처리되느냐에 따라 성능 특성이 크게 달라집니다. 올바른 방법을 선택하면 부드러운 60fps 애니메이션을 구현하면서 메인 스레드의 부하를 최소화할 수 있습니다.
브라우저 렌더링 파이프라인
애니메이션의 성능을 이해하려면 먼저 브라우저 렌더링 과정을 알아야 합니다.
JavaScript → Style → Layout → Paint → Composite
(GPU 합성)
각 단계를 트리거하는 CSS 속성:
- Layout 트리거: width, height, margin, padding, top, left, font-size ...
(가장 비싸다 → 리플로우/Reflow 발생)
- Paint 트리거: background, color, box-shadow, border-radius ...
(중간 비용 → 리페인트/Repaint 발생)
- Composite만: transform, opacity
(가장 저렴 → GPU 레이어에서 처리, 메인 스레드 비용 없음)
CSS 애니메이션
@keyframes와 animation 속성
/* @keyframes로 애니메이션 정의 */
@keyframes slideIn {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}
/* animation 속성으로 적용 */
.card {
animation: slideIn 0.3s ease-out forwards;
}
.button:hover {
animation: pulse 0.5s ease-in-out infinite;
}
animation 속성 상세
.animated-element {
animation-name: slideIn; /* 애니메이션 이름 */
animation-duration: 0.5s; /* 지속 시간 */
animation-timing-function: ease; /* 속도 곡선 */
animation-delay: 0.2s; /* 시작 지연 */
animation-iteration-count: 1; /* 반복 횟수 (infinite 가능) */
animation-direction: normal; /* 방향 (alternate, reverse) */
animation-fill-mode: forwards; /* 종료 후 상태 유지 */
animation-play-state: running; /* 재생/일시정지 */
/* 단축 표기 */
animation: slideIn 0.5s ease 0.2s 1 normal forwards;
}
CSS transition (단순 상태 전환)
.button {
background-color: #007bff;
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
transition: background-color 0.2s ease,
transform 0.15s ease,
box-shadow 0.2s ease;
}
.button:hover {
background-color: #0056b3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.button:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
GPU 가속 활용 (will-change)
/* GPU 레이어로 승격 예고 → 사전 최적화 */
.animated-card {
will-change: transform, opacity;
}
/* 주의: 남용하면 메모리 사용량 증가 */
/* 애니메이션이 시작되기 직전에 추가하고, 종료 후 제거하는 것이 좋음 */
/* JavaScript로 동적 관리 */
element.addEventListener('mouseenter', () => {
element.style.willChange = 'transform';
});
element.addEventListener('animationend', () => {
element.style.willChange = 'auto';
});
JavaScript 애니메이션
requestAnimationFrame 기본 사용
// requestAnimationFrame: 브라우저의 다음 리페인트 전에 콜백 호출
// 일반적으로 1초에 60번 호출 (60fps)
function animate(element, targetX, duration) {
const startX = element.getBoundingClientRect().left;
const distance = targetX - startX;
let startTime = null;
function step(timestamp) {
if (!startTime) startTime = timestamp;
const elapsed = timestamp - startTime;
const progress = Math.min(elapsed / duration, 1);
// 이징 함수 적용 (easeInOut)
const eased = easeInOutCubic(progress);
element.style.transform = `translateX(${distance * eased}px)`;
if (progress < 1) {
requestAnimationFrame(step); // 다음 프레임 예약
}
}
requestAnimationFrame(step);
}
// 이징 함수
function easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
복잡한 인터랙티브 애니메이션
// 스크롤 기반 시차 효과 (Parallax)
class ParallaxEffect {
constructor(elements) {
this.elements = elements;
this.ticking = false;
}
handleScroll() {
const scrollY = window.scrollY;
if (!this.ticking) {
requestAnimationFrame(() => {
this.updateParallax(scrollY);
this.ticking = false;
});
this.ticking = true; // 중복 RAF 방지
}
}
updateParallax(scrollY) {
this.elements.forEach(({ element, speed }) => {
const offset = scrollY * speed;
element.style.transform = `translateY(${offset}px)`;
});
}
init() {
window.addEventListener('scroll', this.handleScroll.bind(this), { passive: true });
}
}
// 사용
const parallax = new ParallaxEffect([
{ element: document.querySelector('.hero-bg'), speed: 0.3 },
{ element: document.querySelector('.hero-text'), speed: 0.5 }
]);
parallax.init();
물리 기반 애니메이션 (스프링)
// 스프링 물리 시뮬레이션
class SpringAnimation {
constructor(config = {}) {
this.stiffness = config.stiffness || 100; // 탄성 계수
this.damping = config.damping || 10; // 감쇠 계수
this.mass = config.mass || 1; // 질량
}
animate(element, targetValue, onUpdate, onComplete) {
let currentValue = parseFloat(element.dataset.value || 0);
let velocity = 0;
let rafId;
const step = (timestamp) => {
const force = -this.stiffness * (currentValue - targetValue);
const dampingForce = -this.damping * velocity;
const acceleration = (force + dampingForce) / this.mass;
velocity += acceleration * 0.016; // ~60fps delta time
currentValue += velocity * 0.016;
onUpdate(currentValue);
const isSettled = Math.abs(targetValue - currentValue) < 0.01
&& Math.abs(velocity) < 0.01;
if (!isSettled) {
rafId = requestAnimationFrame(step);
} else {
onUpdate(targetValue);
onComplete && onComplete();
}
};
rafId = requestAnimationFrame(step);
return () => cancelAnimationFrame(rafId); // 취소 함수 반환
}
}
Web Animations API (WAAPI)
JavaScript에서도 GPU 가속을 활용할 수 있는 현대적인 API입니다.
// Web Animations API: JS에서 CSS 애니메이션처럼 GPU 가속 활용
const element = document.querySelector('.card');
// animate() 메서드
const animation = element.animate(
[
{ transform: 'translateX(-100%)', opacity: 0 }, // keyframe 0%
{ transform: 'translateX(0)', opacity: 1 } // keyframe 100%
],
{
duration: 500,
easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
fill: 'forwards',
delay: 200
}
);
// 애니메이션 제어
animation.pause();
animation.play();
animation.reverse();
animation.cancel();
// 완료 감지
animation.finished.then(() => {
console.log('애니메이션 완료');
});
// 진행 상태 확인
console.log(animation.currentTime); // ms 단위
console.log(animation.playbackRate); // 재생 속도 (1 = 보통)
GSAP (GreenSock Animation Platform)
복잡한 JS 애니메이션에 가장 많이 사용되는 라이브러리입니다.
// GSAP으로 타임라인 기반 시퀀스 애니메이션
import gsap from 'gsap';
// 기본 트윈
gsap.to('.card', {
x: 200,
opacity: 0.5,
duration: 1,
ease: 'power2.out'
});
// 타임라인으로 순차 애니메이션
const tl = gsap.timeline({ repeat: -1, yoyo: true });
tl.from('.title', { y: -50, opacity: 0, duration: 0.5 })
.from('.subtitle', { y: 30, opacity: 0, duration: 0.4 }, '-=0.2') // 0.2초 겹침
.from('.button', { scale: 0, duration: 0.3, ease: 'back.out(1.7)' });
// 스크롤 트리거 (ScrollTrigger 플러그인)
gsap.registerPlugin(ScrollTrigger);
gsap.from('.feature-card', {
scrollTrigger: {
trigger: '.features-section',
start: 'top 80%',
end: 'bottom 20%',
scrub: true
},
x: -100,
opacity: 0,
stagger: 0.2 // 각 요소 0.2초 간격으로 순차 적용
});
CSS vs JavaScript 애니메이션 비교
성능 관점
CSS transform/opacity 애니메이션:
메인 스레드 ──────────────────────────────────────>
(JS, Style, Layout, Paint)
GPU 스레드 ─── [Composite: transform/opacity 처리] ──>
↑ 메인 스레드 블로킹에 영향 없음
JS layout 변경 애니메이션 (left, width 등):
메인 스레드 ─── [JS] ─ [Style] ─ [Layout] ─ [Paint] ─ [Composite] ──>
↑ 모든 단계 거침, Jank 발생 가능
언제 무엇을 사용할까?
사용 사례별 선택 가이드:
단순 호버/전환 효과 → CSS transition
────────────────────────────────────
버튼 호버, 색상 변경, 요소 등장/퇴장
반복되는 UI 애니메이션 → CSS @keyframes
────────────────────────────────────────
로딩 스피너, 깜빡임 효과, 무한 루프
스크롤 기반 애니메이션 → JavaScript (GSAP ScrollTrigger)
──────────────────────────────────────────────────────
패럴랙스, 요소 등장 제어, 진행 표시
물리/수학 기반 애니메이션 → JavaScript
──────────────────────────────────────
스프링 효과, 입자 시스템, 게임 효과
복잡한 시퀀스 애니메이션 → JavaScript (GSAP)
─────────────────────────────────────────────
단계별 소개 화면, 스토리텔링
사용자 인터랙션 기반 → JavaScript
───────────────────────────────
드래그 앤 드롭, 커서 추적, 터치 제스처
요약 표
| 구분 | CSS 애니메이션 | JavaScript 애니메이션 |
|---|---|---|
| 실행 스레드 | GPU (transform/opacity) | 기본 메인 스레드, WAAPI는 GPU 가능 |
| 성능 | transform/opacity는 Jank 없음 | rAF + transform 사용 시 우수 |
| 동적 제어 | 제한적 (클래스 추가/제거) | 완전한 제어 가능 |
| 복잡한 로직 | 어려움 | 용이 |
| 물리 시뮬레이션 | 불가능 | 가능 |
| 중간 취소/역방향 | 제한적 | 자유로움 |
| 코드 복잡도 | 낮음 | 높음 (라이브러리로 보완) |
| 적합한 사례 | UI 전환, 간단한 반복 효과 | 인터랙티브, 시퀀스, 물리 기반 |
| 최적화 방법 | 설명 | 적용 대상 |
|---|---|---|
| transform/opacity 사용 | Composite 단계만 트리거 | CSS/JS 모두 |
| will-change | GPU 레이어 사전 승격 | 미리 알 수 있는 애니메이션 |
| requestAnimationFrame | 브라우저 최적 시점에 실행 | JS 애니메이션 |
| passive 이벤트 리스너 | 스크롤 이벤트 최적화 | 스크롤 기반 |
| WAAPI | JS에서도 GPU 가속 | 복잡한 JS 애니메이션 |