본문으로 건너뛰기
김신건의 로그

[JavaScript] requestAnimationFrame: 브라우저 애니메이션 루프

· 수정 · 📖 약 2분 · 945자/단어 #javascript #browser #animation #rendering #performance
requestAnimationFrame, rAF, 애니메이션 프레임, cancelAnimationFrame, request animation frame

정의

requestAnimationFrame (rAF) 은 브라우저에게 다음 리페인트 직전에 콜백을 실행해달라고 요청합니다. 60Hz 모니터 기준 약 16.67ms 주기.

브라우저 렌더 파이프라인

브라우저는 한 프레임을 아래 순서로 처리합니다. rAF 콜백은 Style/Layout 계산 직전 JavaScript 실행 단계에 위치합니다.

flowchart LR
    A["Input 이벤트"] --> B["JS + rAF 콜백"]
    B --> C["Style 계산"]
    C --> D["Layout 리플로우"]
    D --> E["Paint 레스터화"]
    E --> F["Composite 합성"]
    F --> G["VSync 화면 출력"]

이 위치 덕분에 rAF 콜백에서 DOM 을 조작하면 같은 프레임 안에서 렌더링 결과에 반영됩니다.

IMPORTANT

rAF 콜백 안에서 DOM 레이아웃을 읽은 뒤 쓰기 를 반복하면 강제 리플로우 (Forced Reflow) 가 발생합니다. 읽기를 먼저 모두 완료한 뒤 쓰기를 일괄 처리하세요.

VSync 와 주사율

VSync (수직 동기화) 신호에 맞춰 콜백이 호출됩니다. setTimeout(fn, 16) 은 고주사율 이점을 살리지 못하지만 rAF 는 모니터 주사율에 자동으로 맞춰집니다.

주사율프레임 주기용도
60Hz~16.67ms일반 모니터
90Hz~11.11msVR 헤드셋
120Hz~8.33ms고급 폰, 모니터
144Hz~6.94ms게이밍 모니터

경과 시간 기반으로 계산해야 주사율이 달라져도 같은 속도를 유지합니다.

let prevTime = 0;

function animate(timestamp) {
    const delta = timestamp - prevTime; // ms 단위 경과
    prevTime = timestamp;

    position += speed * (delta / 1000); // px/s 단위로 계산
    el.style.transform = `translateX(${position}px)`;

    requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

이벤트 루프 내 위치

setTimeout 콜백은 매크로태스크 큐로 들어가지만 rAF 콜백은 렌더링 단계 직전 별도 큐에서 처리됩니다.

flowchart TD
    A["이벤트 루프 틱 시작"] --> B["매크로태스크 하나 처리"]
    B --> C["마이크로태스크 큐 소진"]
    C --> D["렌더링 필요 여부 확인"]
    D --> E["rAF 큐 콜백 실행"]
    E --> F["Style, Layout, Paint, Composite"]
    F --> G["VSync 신호 대기"]
    G --> A

왜 setTimeout 이 아닌가

  • 주사율에 자동 맞춤: 120Hz 화면에서는 8ms 주기, 절전 모드에서는 늦춰짐
  • 탭 비활성 시 정지: 백그라운드 탭에서 안 돎, CPU 절약
  • 동기화: 브라우저 렌더링 파이프라인과 정렬됨

setTimeout(fn, 16) 을 쓰면 렌더 사이클과 정렬이 안 되어 프레임 드랍이 생기고, 백그라운드 탭에서도 계속 실행됩니다.

기본 사용

let start;

function animate(timestamp) {
    if (!start) start = timestamp;
    const elapsed = timestamp - start;

    ball.style.left = elapsed * 0.1 + 'px';

    if (elapsed < 3000) requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

콜백에 전달되는 timestampperformance.now() 와 동일한 고해상도 타임스탬프 (ms, 소수점 포함).

취소

const handle = requestAnimationFrame(animate);
cancelAnimationFrame(handle);

컴포넌트 언마운트 시 또는 애니메이션 조건이 해제될 때 반드시 취소해야 합니다. 누락 시 메모리 누수와 불필요한 계산이 지속됩니다.

// React 예시
useEffect(() => {
    let rafId;
    const loop = (ts) => {
        update(ts);
        rafId = requestAnimationFrame(loop);
    };
    rafId = requestAnimationFrame(loop);

    return () => cancelAnimationFrame(rafId); // cleanup 필수
}, []);

실전 예시

Spring 애니메이션

물리 기반 스프링 효과. delta 기반으로 계산해 주사율 독립.

function createSpring({ stiffness = 0.1, damping = 0.8 } = {}) {
    let pos = 0, vel = 0, target = 0;
    let rafId, prevTime = performance.now();

    const tick = (now) => {
        const dt = Math.min((now - prevTime) / 1000, 0.05);
        prevTime = now;

        vel += (target - pos) * stiffness;
        vel *= damping;
        pos += vel;
        return pos;
    };

    return {
        setTarget(t) { target = t; },
        start(onUpdate) {
            const loop = (ts) => {
                onUpdate(tick(ts));
                rafId = requestAnimationFrame(loop);
            };
            rafId = requestAnimationFrame(loop);
        },
        stop() { cancelAnimationFrame(rafId); },
    };
}

게임 루프 (fixed timestep)

물리 업데이트는 고정 시간 단위, 렌더링은 매 프레임.

const FIXED_DT = 1000 / 60;
let accumulator = 0, lastTime = performance.now();

function gameLoop(timestamp) {
    const elapsed = Math.min(timestamp - lastTime, 100); // 스파이크 방지
    lastTime = timestamp;
    accumulator += elapsed;

    while (accumulator >= FIXED_DT) {
        updatePhysics(FIXED_DT / 1000); // 초 단위로 변환
        accumulator -= FIXED_DT;
    }

    const alpha = accumulator / FIXED_DT; // 보간 비율
    render(alpha);
    requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);

부드러운 스크롤

function smoothScrollTo(target) {
    const start = window.scrollY;
    const dist = target - start;
    let startTime = null;
    const duration = 600;

    function step(timestamp) {
        if (!startTime) startTime = timestamp;
        const progress = Math.min((timestamp - startTime) / duration, 1);
        const ease = 1 - Math.pow(1 - progress, 3); // ease-out cubic

        window.scrollTo(0, start + dist * ease);
        if (progress < 1) requestAnimationFrame(step);
    }

    requestAnimationFrame(step);
}

requestIdleCallback 비교

API실행 시점용도
requestAnimationFrame다음 리페인트 직전시각적 업데이트, 애니메이션
requestIdleCallback브라우저 유휴 시간낮은 우선순위 백그라운드 작업
// rAF: 매 프레임 시각적 업데이트
requestAnimationFrame(() => {
    el.style.opacity = '1';
});

// rIC: 여유 시간에 분석, 캐싱 등
requestIdleCallback((deadline) => {
    while (deadline.timeRemaining() > 0 && tasks.length) {
        tasks.shift()();
    }
}, { timeout: 2000 });

성능 / 함정

강제 리플로우 (Layout Thrashing)

// ❌ 읽기/쓰기 교대 → 매번 Layout 재계산
elements.forEach(el => {
    const h = el.offsetHeight;        // Layout 유발
    el.style.height = h * 2 + 'px';  // 무효화, 다음 읽기에 재계산
});

// ✅ 읽기 먼저, 쓰기 나중에 일괄 처리
const heights = elements.map(el => el.offsetHeight); // 한 번만 Layout
elements.forEach((el, i) => {
    el.style.height = heights[i] * 2 + 'px';
});

CSS 애니메이션 vs rAF 선택 기준

transform, opacity 만 변경하는 경우: CSS 트랜지션이 Compositor Thread 에서 실행되어 메인 스레드 없이 60fps 보장.

.card {
    transition: transform 0.3s ease, opacity 0.3s ease;
    will-change: transform; /* GPU 레이어 미리 생성 */
}

rAF 는 물리 시뮬레이션, 값 보간처럼 프레임마다 동적으로 계산이 필요한 경우에 적합합니다.

함정 목록

  • 콜백 내 무거운 연산 금지: 16ms 초과 시 프레임 드랍
  • 첫 프레임 지연: 첫 rAF 콜백은 다음 프레임까지 대기 (수 ms)
  • 탭 재활성 시 큰 delta: 탭이 돌아올 때 시간이 크게 튀므로 Math.min 으로 cap 필요
  • 취소 누락: React/Vue 환경에서 cleanup 에서 cancelAnimationFrame 반드시 호출
  • CSS 애니메이션이 더 효율적: 순수 transform/opacity 는 CSS 로
  • setTimeout 이 필요한 경우: 정확한 인터벌이 필요할 때 (게임 서버 sync 등)

관련 위키

이 글의 용어 (3개)
[Javascript] 이벤트 루프javascript
정의 이벤트 루프 (Event Loop) 는 싱글 스레드 JavaScript 엔진이 비동기 작업을 처리하기 위해 돌리는 무한 루프다. 콜 스택이 비어있을 때마다 마이크로태스크 큐…
[Javascript] Microtask Queuejavascript
정의 Microtask Queue (마이크로태스크 큐) 는 이벤트 루프 가 매 틱마다 완전히 비울 때까지 처리하는 우선순위 큐다. 매크로태스크 큐보다 항상 먼저 처리된다. ECM…
[Javascript] setIntervaljavascript
정의 은 호스트 환경이 제공하는 반복 타이머 API. 매 밀리초마다 을 매크로태스크 큐에 등록한다. 전체 메커니즘은 와 참고. 사용 상황 | 상황 | setInterval | 대…

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기