애니메이션 라이브러리 모바일 최적화 A to Z

타락한스벨트전도사·2025년 11월 7일

TL;DR - 3줄 요약

문제: 내 라이브러리, 모바일에서 애니메이션이 뻑뻑하고 백그라운드 복귀 시 깨짐
해결: dt clamping + lag smoothing + 적절한 수치해석 선택
결과: 60fps → 불규칙 FPS 환경에서도 부드럽게 작동


1. 애니메이션 라이브러리 101

1.1 RAF + dt의 세계

CSS가 아닌 JavaScript 애니메이션 라이브러리의 핵심 구조:

시간축 다이어그램:
┌─────┬─────┬─────┬─────┐
│ dt1 │ dt2 │ dt3 │ dt4 │
└─────┴─────┴─────┴─────┘
  16ms  16ms  16ms  16ms  (이상적)
  
position 업데이트:
0 ──→ 16 ──→ 32 ──→ 48 ──→ 64
function animate() {
  const currentTime = performance.now();
  const dt = (currentTime - lastTime) / 1000;  // delta time (초 단위)
  
  position += velocity * dt;  // dt만큼 값 업데이트
  render(position);            // 화면 갱신
  
  lastTime = currentTime;
  requestAnimationFrame(animate);  // 재귀 호출
}

핵심: requestAnimationFrame(RAF)으로 브라우저의 화면 주사율에 맞춰 반복 실행하며, dt(delta time)만큼 값을 조작해 애니메이션을 진행시킨다.

1.2 Spring Physics (용수철 물리)

자연스러운 애니메이션의 비밀은 물리 시뮬레이션:

F = ma = -kx - cv

k: stiffness (용수철 계수 - 얼마나 뻣뻣한가)
c: damping (저항 계수 - 얼마나 빨리 멈추는가)
x: 목표로부터의 거리
v: 속도

의미:

  • 용수철이 당기는 힘 (-kx)
  • 저항력이 속도를 줄이는 힘 (-cv)
  • 이 둘이 합쳐져 자연스러운 움직임 생성

1.3 수치해석이란?

dt만큼 속도와 위치를 보정해서 다음 프레임을 계산하는 방법

가장 단순한 Euler 방법:

// 매 프레임마다
const acceleration = force / mass;
velocity += acceleration * dt;
position += velocity * dt;

문제는 어떤 방법으로 식을 세울 것인가가 성능과 안정성을 결정한다는 것!

2. 모바일의 현실: 16ms는 거짓말

2.1 실측 데이터

데스크톱에서는 60fps(16.67ms)가 일반적이지만, 모바일은 완전히 다른 세계:

환경평균 dt최대 dt비고
데스크톱 Chrome16.67ms~20ms안정적
iPhone 1316ms200ms백그라운드 복귀 시
Galaxy S2333ms150ms가변적
저사양 안드로이드50-100ms500ms+💀

2.2 가변 FPS의 문제들

// dt = 16ms:  ✅ 정상 작동
// dt = 100ms: ⚠️ Spring이 과도하게 튐
// dt = 5000ms: 💥 완전히 폭발

실제 시나리오:

  • 백그라운드 → 포그라운드 복귀: 5000ms+
  • CPU 과부하 (다른 앱 실행): 100-500ms
  • 프레임 드롭 (렌더링 지연): 50-100ms

2.3 내 경험담

"모바일에서 뻑뻑하다는 피드백을 받고, 기존 라이브러리(오래된 버전)를 뜯어봤더니 가변 FPS 대응이 전혀 안 되어 있었다. dt가 100ms만 넘어가도 Spring이 미친 듯이 튀고, 백그라운드에서 돌아오면 애니메이션이 순간이동했다. 결국 직접 만들기로 결정."

교훈: 모바일은 데스크톱이 아니다. 16ms를 기대하지 말고, 500ms도 대응할 수 있게 만들어야 한다.

3. 수치해석 방법 비교

3.1 5가지 방법 한눈에 보기

방법안정성정확도성능유연성모바일 추천
Explicit Euler✅✅
Semi-Implicit Euler✅✅
RK4 (룽게 쿠타)⚠️✅✅
Verlet/Leapfrog
Analytical Solution✅✅✅✅✅✅✅✅

3.2 Semi-Implicit Euler (Allen Chou 방식)

출처: Allen Chou - Game Math: Numeric Springing

사용처: Svelte Motion, 웹 애니메이션 라이브러리

// 정규화된 파라미터 사용
// ω (omega) = sqrt(k/m) : 각진동수 (얼마나 빨리 진동)
// ζ (zeta) = c/(2√km) : 감쇠비 (얼마나 빨리 멈춤)

void SpringSemiImplicitEuler(
    float &x,      // 현재 값
    float &v,      // 속도
    float xt,      // 목표 값
    float zeta,    // damping ratio
    float omega,   // angular frequency
    float h        // timestep (dt)
) {
    v += -2.0f * h * zeta * omega * v +    // 감쇠력
         h * omega * omega * (xt - x);      // 복원력
    x += h * v;  // 핵심: 속도 먼저, 위치 나중!
}

장점:

  • Explicit Euler보다 훨씬 안정적 (진동 폭이 계속 커지는 오류 없음)
  • 가변 dt에서도 안정적
  • 중간에 목표치가 변해도 연속된 움직임 유지 (내가 선택한 이유!)

왜 Explicit Euler는 안 되는가:

// Explicit Euler (위치 먼저, 속도 나중)
position += velocity * dt;
velocity += acceleration * dt;
// → 진동 폭이 계속 커지는 오류 (에너지 증가)

3.3 Analytical Solution (정확한 해)

출처: React Native Reanimated 소스코드

사용처: React Native Reanimated (네이티브)

// 미분방정식의 정확한 해를 직접 계산
const t = (currentTime - startTime) / 1000;
const envelope = Math.exp(-zeta * omega0 * t);

// Under-damped (ζ < 1, 진동하면서 멈춤)
const position = target + envelope * 
  (Math.sin(omega1 * t) * A + x0 * Math.cos(omega1 * t));

장점:

  • O(1) 연산 (프레임 수와 무관)
  • 수학적으로 정확함
  • 프레임 독립적 (드롭해도 정확)

단점:

  • 표준 spring만 가능
  • 중간에 목표치 변경 어려움 (매번 초기화 필요)

3.4 왜 RK4(룽게 쿠타)는 모바일에서 망할까?

// RK4: 4단계 계산
k1 = f(t, x)
k2 = f(t + dt/2, x + k1*dt/2)
k3 = f(t + dt/2, x + k2*dt/2)
k4 = f(t + dt, x + k3*dt)
x += (k1 + 2*k2 + 2*k3 + k4) * dt / 6

문제점:

  • dt가 일정할 때 설계됨 (Fixed timestep 가정)
  • 가변적이고 큰 dt에서 오차가 심함 (모바일의 현실)
  • 장시간 시뮬레이션에서 에너지 손실 (dissipative)
// dt = 16ms (일정):  ✅ RK4 정확함
// dt = 16~100ms (가변): ❌ RK4 오차 심함
// dt = 5000ms (큼):  💥 RK4 완전히 망가짐

결론: 모바일 가변 FPS 환경에선 Semi-Implicit이나 Analytical이 훨씬 현명한 선택

4. 필수 최적화 3종 세트

4.1 Delta Time Clamping + Lag Smoothing

문제 인식: 가변 FPS를 어떻게 처리할 것인가?

모바일에서 뻑뻑하다는 피드백을 받고, "국밥 애니메이션 라이브러리" GSAP의 코드를 뜯어봤다.

출처: GSAP Ticker 소스코드

Clamping: dt 최대값 제한 (라인 908-914)

dt = Math.min(dt, 0.1);  // 100ms로 제한

목적: Spiral of Death 방지

느려짐 → dt 커짐 → 계산량 증가 → 더 느려짐 → 💥

Smoothing: 비정상 시간 보정 (라인 955-958)

// 백그라운드 복귀 등 비정상적으로 긴 시간 경과 시
if (elapsed > 500) {  // lagThreshold
  startTime += elapsed - 33;  // adjustedLag
}

동작 원리:

// 백그라운드 5초 후 복귀
elapsed = 5000ms
// 보정 없으면: 애니메이션 5초치 점프 💥
// 보정 후: startTime 조정으로 33ms만 경과한 것처럼
startTime += (5000 - 33) = 4967ms 추가
// → 결과적으로 33ms만 진행된 것처럼 계산됨

내가 적용한 값:

const MAX_DT = 0.1;         // 100ms
const LAG_THRESHOLD = 0.5;   // 500ms
const ADJUSTED_LAG = 0.033;  // 33ms

4.2 Adaptive Frame Rate

출처: GSAP Ticker adaptive scheduling

프레임 스케줄:

정상 상황:
┌──────┬──────┬──────┐
│ 16ms │ 16ms │ 16ms │
└──────┴──────┴──────┘
   ✓      ✓      ✓

프레임 드롭:
┌──────┬────────────┬────┐
│ 16ms │   50ms     │ 4ms│ ← 빠르게 스킵
└──────┴────────────┴────┘
   ✓        ✗         ✓
const overlap = time - nextTime;  // 예정보다 얼마나 늦었는지
nextTime += overlap + (overlap >= gap ? 4 : gap - overlap);

동작 원리:

// gap = 16.67ms (60fps 목표)

// 조금 늦음 (overlap = 3ms)
nextTime += 3 + (16.67 - 3) = 16.67ms
// → 정상 간격 유지

// 많이 늦음 (overlap = 20ms)
nextTime += 20 + 4 = 24ms
// → 빠르게 스킵해서 따라잡기

효과: 프레임 드롭 시 자동으로 간격 조정

4.3 Ticker 싱글톤 패턴

문제: requestAnimationFrame은 비싼 함수

// ❌ 나쁜 예
animation1: requestAnimationFrame(update1);
animation2: requestAnimationFrame(update2);
animation3: requestAnimationFrame(update3);
// → RAF 호출 3번

// ✅ 좋은 예: Ticker 싱글톤
class Ticker {
  private listeners = new Set<(dt: number) => void>();
  
  private tick = (timestamp: number) => {
    const dt = (timestamp - this.lastTime) / 1000;
    
    // 모든 애니메이션을 한 번에
    this.listeners.forEach(callback => callback(dt));
    
    if (this.listeners.size > 0) {
      this.rafId = requestAnimationFrame(this.tick);
    }
  };
  
  subscribe(callback: (dt: number) => void) {
    this.listeners.add(callback);
    if (this.listeners.size === 1) {
      requestAnimationFrame(this.tick);
    }
  }
}

ticker.subscribe(animation1.update);
ticker.subscribe(animation2.update);
// → RAF 호출 1번만!

효과: RAF 호출 최소화, 배터리 절약

적용 사례: GSAP, Framer Motion, 내 라이브러리

5. 놓치기 쉬운 디테일: 수렴 조건

문제: === 비교는 영원히 안 맞음

// ❌ 이렇게 하면 영원히 안 멈춤
if (position === target && velocity === 0) {
  stop();
}
// → 부동소수점 오차로 딱 맞지 않음
// position = 99.999999...
// velocity = 0.0000001...

해결: 임계값(threshold) 사용

// ✅ 올바른 방법
const POSITION_THRESHOLD = 0.01;
const VELOCITY_THRESHOLD = 0.01;

const isSettled = 
  Math.abs(position - target) < POSITION_THRESHOLD &&
  Math.abs(velocity) < VELOCITY_THRESHOLD;

if (isSettled) {
  position = target;  // 딱 맞춰주고
  velocity = 0;
  stop();
}

6. 결론 & 체크리스트

내 경험 정리

Before:
- 기존 라이브러리 사용 (오래된 버전)
- 모바일에서 뻑뻑함 💀
- 백그라운드 복귀 시 깨짐 💥

After (GSAP 코드 분석 후 적용):
- Semi-Implicit Euler 직접 구현
- dt clamping (100ms) 적용
- lag smoothing (500ms → 33ms) 적용
- adaptive frame rate 추가
- Ticker 싱글톤 패턴

Result:
- 저사양 기기에서도 부드러움 ✅
- 백그라운드 복귀 안정적 ✅

구현 체크리스트

[ ] 수치해석 방법 선택
    → Semi-Implicit Euler (중간에 목표치 변경 가능)
    
[ ] dt clamping 적용
    → Math.min(dt, 0.1)
    
[ ] lag smoothing 구현
    → if (elapsed > 500) startTime += elapsed - 33
    
[ ] 수렴 조건 threshold 설정
    → position < 0.01, velocity < 0.01
    
[ ] Ticker 싱글톤 패턴
    → RAF 호출 최소화
    
[ ] 실기기 테스트
    → 저사양 안드로이드 포함

핵심 레퍼런스

구현:

이론:


마치며

"모바일은 데스크톱이 아니다. 16ms를 기대하지 말고, 500ms도 대응할 수 있게 만들어라."

핵심 3줄:
1. 안정성 > 정확도: Semi-Implicit > RK4
2. 방어 코드는 필수: clamping, smoothing
3. 디테일이 차이를 만든다: threshold, ticker

배운 점:

  • 가변 FPS는 필연이다 (대응 필수)
  • 좋은 라이브러리의 코드를 뜯어보자 (GSAP)
  • 작은 최적화가 모여 큰 차이를 만든다

이 모든 것을 적용해서 ssgoi 2.5.2를 릴리즈했다.
화면 전환 라이브러리로, 부드럽습니다 ㅎ

ssgoi.dev/en

profile
기부하면 코드 드려요

0개의 댓글