웹 애니메이션 성능을 끌어올리는 requestAnimationFrame(rAF) 활용법
웹페이지를 구현할 때 가장 난감한 순간 중 하나는 애니메이션을 구현할 때인 것 같습니다.
CSS만으로 구현할 수 있는 애니메이션이라면 다행이지만, JS로 스타일을 변화시켜야 되는 경우에는 생각보다 성능이 안 나오는 문제가 있습니다.(특히 모바일에서)
이런 상황에서 최적화할 수 있는 API인 requestAnimationFrame(rAF)에 대해 알아보겠습니다.
requestAnimationFrame은 무엇인가?
requestAnimationFrame()은 쉽게 말해 브라우저에게 "다음 repaint 이전에 이 함수를 실행해 줘"라고 요청하는 API입니다.
콜백으로 전달된 함수는 브라우저 다음 프레임의 repaint 직전에 호출되며, 일반적으로 초당 60회(60 FPS) 호출됩니다.
다만 실제 호출 빈도는 디스플레이의 주사율(refresh rate)에 맞춰 조정되는데, 대부분 브라우저에서 60Hz를 기준으로 하지만 75Hz, 120Hz, 144Hz와 같이 모니터에 따라 더 잦을 수도 있습니다.
이런 조율 덕분에 rAF는 CSS의 애니메이션 프레임과 동기화되어 효율적으로 동작합니다.
rAF가 호출될 때 콜백 함수에는 고해상도 타임스탬프(DOMHighResTimeStamp)가 인자로 전달되는데, 이 값은 애니메이션이 시작된 시점으로부터의 시간(ms)을 나타내며, 애니메이션 진행률 계산에 사용됩니다.
특히 고주사율 모니터에서는 프레임 간 시간이 더 짧아지므로, 타임스탬프 기반으로 진행률을 계산해야 애니메이션 속도가 일관됩니다.
(예: 144Hz 화면에서는 1 프레임이 대략 6.9ms로 60Hz의 16.7ms보다 짧으므로, 매 프레임마다 시간차를 측정해 이동 거리를 계산해야 동일한 속도를 유지할 수 있습니다.)
요약하면, requestAnimationFrame(callback)을 호출하면 브라우저 렌더링 엔진이 적절한 시점에 callback을 실행하고, 화면을 repaint 하기 전에 애니메이션 관련 연산을 완료할 수 있게 해주는 API입니다.
추가로 rAF는 1회성으로 동작하므로 연속적인 애니메이션이 필요할 때는 콜백 내부에서 다시 requestAnimationFrame()을 호출하는 재귀적 루프를 활용해야 됩니다.
또 rAF는 호출 시 ID를 반환하고, cancelAnimationFrame(id)를 통해 언제든 필요 없어진 프레임 예약을 취소해 메모리 누수를 막을 수 있습니다.
왜 requestAnimationFrame인가?
과거에는 setTimeout이나 setInterval로 1초에 60번씩 함수 호출(대략 16ms 간격)을 시도하며 애니메이션을 구현했습니다.
하지만 이런 고정 주기 타이머 방식은 한계가 있습니다.
예를 들어, 16ms 안에 프레임 처리가 끝나지 못하면 프레임 드롭이 발생하여 실제 FPS가 떨어지고 애니메이션 속도가 불균일해집니다.
그리고 setInterval은 화면 repaint와 무관하게 호출되기 때문에, 브라우저나 디바이스 상태에 따라 일정한 간격을 보장하지 못하는 문제도 있었습니다.
requestAnimationFrame의 도입(2011년, Paul Irish)으로 이러한 문제가 개선되었습니다.
rAF의 주요 장점은 다음과 같습니다.
- 브라우저 주도 최적화: 애니메이션 업데이트를 브라우저 렌더링 사이클과 정확히 동기화하여, 프레임 간 일관된 상태 업데이트가 이뤄집니다. 이를 통해 고품질의 부드러운 애니메이션을 구현할 수 있습니다. 브라우저가 적절한 시점을 결정하므로 불필요한 연산을 줄이고 최적의 프레임을 유지합니다.
- 효율적인 자원 활용: rAF는 백그라운드 탭이나 숨겨진 탭에서 자동으로 호출을 중단하거나 빈도를 낮춰 CPU/GPU 사용과 배터리 소모를 줄입니다. 예를 들어 사용자가 탭을 다른 곳으로 전환하면 rAF 루프가 일시정지되어, 보이지 않는 화면을 그리느라 자원을 낭비하지 않습니다. (최신 브라우저의 경우 이러한 최적화를 다른 timer api들에도 적용하였습니다.)
- GPU 가속 등 성능 향상: rAF를 통해 애니메이션을 구현하면 브라우저의 하드웨어 가속 최적화를 활용할 수 있습니다. 특히 CSS 트랜스폼/트랜지션과 조합하면 GPU 레벨에서 처리가 이루어져 메인 스레드 부하를 감소시키며, 저사양 기기에서도 비교적 매끄러운 애니메이션을 기대할 수 있습니다. (CSS 기반 애니메이션과 rAF 기반 JS 애니메이션의 성능은 대부분 유사하며, 일부 JS 라이브러리(GSAP 등)는 내부적으로 rAF를 활용하여 CSS보다 나은 성능을 내기도 합니다.)
그리고 rAF 콜백은 브라우저 이벤트 루프 상에서 특별한 우선순위 큐에서 처리됩니다.
브라우저는 매 프레임마다 Animation Frame 큐의 모든 콜백들을 실행한 후에 일반 Task 큐(setTimeout, setInterval 등)의 작업을 처리하므로, 애니메이션 관련 작업이 다른 태스크에 밀려 지연되지 않게 보장합니다.
이런 메커니즘 덕분에 rAF로 구현한 애니메이션은 매 프레임 빠짐없이 실행되어 더욱 부드럽게 보이는 효과가 있는 것입니다.
그래서 rAF는 더 나은 타이밍 제어, 자원 효율성, 브라우저 최적화 혜택을 제공하여 고성능 애니메이션 구현할 때 프론트엔드 개발자가 꼭 알아야 될 표준으로 자리 잡았습니다.
requestAnimationFrame의 주요 활용 사례
rAF는 "화면 업데이트에 맞춰 무언가를 반복 수행"해야 하는 모든 시나리오에 응용될 수 있습니다.
대표적인 활용 영역과 예시는 다음과 같습니다.
1. DOM 요소 애니메이션 (수동 애니메이션 제어)
CSS 트랜지션/애니메이션으로 할 수 없는 세밀한 DOM 애니메이션은 rAF의 대표적 활용처입니다.
예를 들어, 스크립트로 엘리먼트의 위치나 속성을 프레임마다 업데이트하여 게임이나 커스텀 UI 효과를 줄 때 rAF를 사용합니다.
다음은 rAF로 DOM 요소를 움직이는 간단한 예시입니다.
2초 동안 엘리먼트를 오른쪽으로 최대 200px 이동시키는 애니메이션을 구현해 보겠습니다.
timestamp를 이용해 경과 시간을 계산하고, 이를 기반으로 이동 거리를 결정합니다(여기서는 매 ms마다 0.1px씩 이동).
2초가 지나면 애니메이션을 중단합니다.
const element = document.getElementById("box");
let startTime;
function animate(timestamp) {
if (startTime === undefined) {
startTime = timestamp; // 애니메이션 시작 시각 기록
}
const elapsed = timestamp - startTime;
// 시간 경과에 따라 박스를 오른쪽으로 이동 (최대 200px)
const moveX = Math.min(0.1 * elapsed, 200);
element.style.transform = `translateX(${moveX}px)`;
if (elapsed < 2000) { // 2초(2000ms) 이하면 다음 프레임 예약
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
위 코드에서 requestAnimationFrame 콜백은 브라우저가 다음 프레임을 그리기 직전 호출되므로, 화면 업데이트 타이밍과 정확히 일치합니다.
timestamp를 활용해 프레임 간 시간차에 비례하여 이동량을 계산함으로써, 장치 성능이나 프레임 드롭에 관계없이 일정한 속도로 움직이는 효과를 얻습니다.
예를 들어, 한 프레임이 33ms 걸리면 한 번에 3.3px 이동하여 FPS 저하 시에도 애니메이션 속도는 유지됩니다.
DOM 애니메이션에 rAF를 활용하면 JS 계산 → 스타일 변경 → 리페인트 순서가 프레임에 맞게 이뤄집니다.
하지만 매 프레임 DOM을 조작하면 브라우저 렌더링 작업(리플로우/리페인트)이 많이 발생하므로, 가능하면 transform, opacity 등 GPU 가속이 되는 CSS 속성을 변경하여 성능을 최적화하는 것이 좋습니다.
또한 한 프레임 내 rAF 콜백의 작업이 16ms를 넘지 않도록 연산량을 조절해야 60 FPS를 유지할 수 있습니다.
만약 한 프레임에 처리해야 할 작업이 많다면, 아래에서 소개할 방법으로 작업을 분산시키거나, Web Worker 등으로 오프로드를 고려해야 합니다.
2. Canvas 및 게임 루프
게임 개발이나 Canvas 그래픽 렌더링에서도 rAF는 필수적입니다.
애니메이션 루프를 돌면서 Canvas에 그림을 그리거나 물리 연산을 수행할 때, rAF를 사용하면 정확한 프레임 타이밍과 최적 성능을 얻을 수 있습니다.
예를 들어 Canvas에 공을 그려 좌우로 계속 튕기는 간단한 게임 루프를 rAF로 구현할 수 있습니다.
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
let x = 0;
let vx = 2; // 공의 속도 (px/frame)
function gameLoop() {
// 논리 업데이트: 경계에 부딪히면 방향 반전
if (x + vx > canvas.width || x + vx < 0) {
vx = -vx;
}
x += vx;
// 화면 그리기: 이전 프레임 지우고 새로운 공 그리기
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.beginPath();
ctx.arc(x, 50, 20, 0, 2 * Math.PI);
ctx.fill();
requestAnimationFrame(gameLoop);
}
gameLoop();
이처럼 무한 루프 형식으로 rAF를 재귀 호출하면, 브라우저가 표시할 준비가 될 때마다 gameLoop이 실행되어 애니메이션이 이어집니다.
rAF는 화면이 활성화되어 있을 때만 루프가 돈다는 점에서, setInterval로 루프를 돌리는 것보다 효율적입니다.
사용자가 게임 탭을 떠나면 rAF 호출이 자동 멈춰 배터리와 CPU를 아끼며, 다시 돌아오면 적절한 시점부터 재개합니다.
Canvas 애니메이션에서도 반드시 시간 기반으로 움직임을 계산해야 합니다.
위 예시에선 vx를 프레임당 픽셀 이동량으로 정했지만, 실제로는 timestamp를 활용해 초당 픽셀(speed * timeDelta)로 계산하는 것이 좋습니다.
그래야 저사양 기기에서 FPS가 떨어져도 게임 속도가 일정하게 유지되고, 고사양에서 FPS가 높아져도 너무 빨라지지 않습니다.
3. 스크롤/이벤트 처리 최적화 (프레임 단위 스로틀링)
rAF는 사용자 입력 이벤트를 최적화하는 데도 많이 활용됩니다.
예를 들어 스크롤, 마우스 이동, 창 리사이즈 등의 이벤트는 아주 짧은 시간 간격으로 빈번히 발생하여, 이벤트 핸들러를 그대로 두면 초당 수백 번 함수가 호출될 수 있습니다.
이런 경우 rAF를 사용하여 이벤트 핸들러를 프레임당 한 번으로 throttling 하면 성능과 효율이 크게 향상됩니다.
다음은 mousemove 이벤트를 rAF로 최적화하는 패턴입니다.
이벤트 리스너에서는 rAF를 이용해 업데이트 로직을 예약만 하고, 이미 예약된 경우 중복 예약을 피합니다.
useEffect(() => {
let scheduled = false;
function onMouseMove(event: MouseEvent) {
if (!scheduled) {
scheduled = true;
requestAnimationFrame(() => {
// 예: 마우스 좌표에 따라 요소 이동, 상태 업데이트 등
console.log("Mouse at", event.clientX, event.clientY);
scheduled = false; // 다음 이벤트 처리 준비
});
}
}
window.addEventListener('mousemove', onMouseMove);
return () => window.removeEventListener('mousemove', onMouseMove);
}, []);
위 코드에서는 scheduled 플래그로 현재 프레임에 처리 예약 여부를 추적합니다.
마우스가 움직일 때마다 이벤트가 발생해도, 한 프레임에 하나의 rAF 콜백만 실행되도록 조절하여 FPS 이상으로 이벤트 핸들러가 과도하게 호출되지 않게 합니다.
결과적으로 마우스를 얼마나 빨리 움직이든, 매 프레임 최대 한 번만 로직이 실행되므로 부하가 제한되고, 불필요한 중복 연산을 피할 수 있습니다.
이 패턴은 스크롤 이벤트에도 동일하게 적용할 수 있습니다.
예를 들어 스크롤 위치에 따라 UI를 업데이트하는 경우, scroll 이벤트 리스너 안에서 위와 같은 방법으로 rAF 콜백을 사용하면 스크롤 속도가 매우 빨라져도 UI 업데이트가 모니터 재생률 이상 발생하지 않도록 막아줍니다.
즉, 자원 낭비를 줄이고 필요한 시각적 업데이트만 수행하게 됩니다.
추가 Tip
rAF를 이용한 스로틀은 _.throttle 같은 라이브러리 함수로 간격 조절하는 것과는 다르게, 정확히 화면 리프레시 타이밍에 맞춰 실행된다는 장점이 있습니다.
또한 이벤트 빈도에 따라 동적으로 대응하며, 브라우저가 알아서 최대 빈도를 조절하므로 매우 합리적입니다.
다만, rAF 콜백 내에서 최신 이벤트 정보(예: 마지막 마우스 위치)를 기반으로 동작해야 함을 잊지 마세요.
여러 이벤트 중 마지막 것을 사용하거나, 혹은 이벤트 핸들러 내부에서 필요한 데이터를 외부 변수에 저장해 두고 rAF 콜백에서 참조하는 방법을 활용합니다.
4. 대량 DOM 업데이트 작업 분산 처리
때로는 수천 개 이상의 DOM 요소를 한꺼번에 생성/업데이트해야 하는 등 매우 무거운 작업을 해야 할 때가 있습니다.
이런 작업을 한 번에 실행하면 메인 스레드가 수십 ms 이상 블로킹되어 사용자 인터랙션이 끊기고 프레임 드롭이 발생합니다.
rAF를 이용하면 이런 대용량 작업을 여러 프레임에 걸쳐 분산시킬 수 있습니다.
예를 들어 10,000개의 리스트 아이템을 한꺼번에 렌더링 하는 대신, 한 프레임에 100개씩 처리하도록 쪼개는 방법입니다.
const items = Array.from({ length: 10000 }, (_, i) => `Item ${i}`);
const container = document.getElementById("list");
function appendChunk() {
// 한 프레임에 100개씩 추가
for (let i = 0; i < 100 && items.length > 0; i++) {
const item = document.createElement("div");
item.textContent = items.shift();
container.appendChild(item);
}
// 아직 남은 아이템이 있다면 다음 프레임에 이어서 처리
if (items.length > 0) {
requestAnimationFrame(appendChunk);
}
}
// 첫 프레임 시작
requestAnimationFrame(appendChunk);
이 코드에서는 10,000개의 엘리먼트를 한 번에 생성하지 않고, rAF 루프를 돌면서 매 프레임 일정 개수씩 생성합니다.
이렇게 하면 UI가 점진적으로 업데이트되어 브라우저가 프레임을 놓치지 않고 작업을 처리할 수 있습니다.
사용자 입장에서도 화면이 한꺼번에 멈췄다가 나타나는 것보다, 점진적으로 콘텐츠가 채워지는 편이 더 매끄럽게 느껴질 수 있습니다.
(물론 이렇게 많은 아이템은 가상화나 백그라운드 워커 활용도 고려할 수 있겠지만, rAF 분할도 간단한 해결책으로 사용할 수 있습니다.)
중요한 점은, rAF 기반으로 작업을 나눌 때에도 전체 처리 시간 동안은 UI 스레드가 계속 바쁘기 때문에 UX 개선은 있어도 총 소요 시간 단축은 아닐 수 있다는 것입니다.
따라서 사용자 경험상 문제가 없다면 가급적 작업량 자체를 줄이는 것이 근본 해결이지만, 부득이하게 많은 연산을 해야 할 경우 rAF로 프레임 단위 배분하여 최소한 메인 스레드가 완전히 장악되지 않도록 조치하는 것이라고 이해하면 됩니다.
5. React/Next.js에서의 requestAnimationFrame 활용
React에서도 rAF는 유용하게 쓰일 수 있습니다.
다만 React는 가상 DOM과 상태 업데이트를 통한 UI 관리가 기본이므로, rAF처럼 직접 DOM을 조작하거나 매 프레임 상태를 변경하는 패턴은 신중하게 사용해야 합니다.
- Canvas 또는 직접 DOM 조작이 필요한 부분: 복잡한 Canvas 애니메이션, WebGL, 또는 성능 상 이유로 DOM을 직접 만져야 하는 부분은 React 컴포넌트 내부에서 rAF를 사용해 제어합니다. 이때 React의 리렌더링 주기를 우회하여 useRef로 DOM 노드나 값을 직접 업데이트하기도 합니다.
- 부드러운 상태 애니메이션: 상태값이 변함에 따라 UI가 전환될 때, setTimeout 대신 rAF로 작은 변화들을 연속적으로 발생시켜 부드러운 전환 효과를 줄 수 있습니다. 숫자 카운터가 0에서 100으로 변화할 때 한 번에 바뀌는 대신 rAF를 이용해 1씩 증감하며 애니메이션 시키는 식입니다.
- 이벤트 스로틀링: 앞서 언급한 scroll/resize 등의 패턴을 React 컴포넌트의 useEffect 훅 등을 통해 적용해, 비싼 연산이 동반되는 이벤트 핸들러를 최적화합니다.
Next.js에서 사용한다면 SSR(서버 사이드 렌더링)을 사용하는 경우 유의하여야 합니다.
requestAnimationFrame은 브라우저 API이므로 서버에서는 존재하지 않습니다.
따라서 window나 rAF를 직접 사용하는 코드는 반드시 브라우저에서만 실행되도록 해야 합니다.
일반적으로 React의 useEffect 훅 안에서 사용함으로써 처리합니다.
useEffect는 컴포넌트가 클라이언트에 마운트 된 후 실행되므로 SSR 단계에서는 실행되지 않기 때문입니다.
아래 React 컴포넌트는 마운트 되면 rAF를 이용해 1초 동안 0부터 100까지 숫자를 증가시키며 화면에 렌더링 합니다.
import { useEffect, useRef, useState } from 'react';
function CounterAnimation() {
const [count, setCount] = useState(0);
const rafId = useRef<number>(0);
useEffect(() => {
const start = performance.now();
function update(timestamp: DOMHighResTimeStamp) {
const progress = timestamp - start;
// 1초(1000ms)에 걸쳐 0→100 카운트
const newCount = Math.min(Math.floor(progress / 10), 100);
setCount(newCount);
if (progress < 1000) {
rafId.current = requestAnimationFrame(update);
}
}
// 애니메이션 시작
rafId.current = requestAnimationFrame(update);
// 컴포넌트 언마운트 시 정리
return () => cancelAnimationFrame(rafId.current);
}, []);
return <div>{count}</div>;
}
위 컴포넌트에서는 useEffect를 사용하여 초기 마운트 시 rAF 루프를 시작하고, 언마운트 시 cancelAnimationFrame으로 루프를 정리합니다.
useRef를 통해 rAF ID와 관련 변수를 저장하여 리렌더링 간에도 값이 유지되도록 처리하였습니다.
매 프레임마다 setCount로 상태를 업데이트하면 React가 그에 맞춰 리렌더링을 수행합니다.
이처럼 React 상태를 직접 변경하는 경우, 매 프레임 리렌더링이 성능에 부담이 되진 않는지 고려해야 합니다.
단순한 UI에서는 60 FPS의 상태 업데이트도 문제없지만, 변화가 일어나는 컴포넌트 트리가 크다면 매 프레임 가상 DOM 비교와 렌더링이 병목 될 수 있습니다.
필요하다면 requestAnimationFrame 내부에서는 상태가 아닌 ref 등을 이용해 DOM을 직접 변경하고, 최종 완료 시 한 번만 상태 업데이트를 트리거하는 방법도 있습니다.
React 18+의 Concurrent Mode(동시성 모드)에서는 여러 상태 업데이트가 배치(batch) 처리되어 렌더링 될 수 있지만, rAF와는 별개의 메커니즘입니다.
rAF는 브라우저 프레임과 동기화되어 실행되므로, React 내부 스케줄링보다는 브라우저 페인팅 주기에 맞춘 정교한 제어가 필요할 때 유용합니다.
React에서 rAF를 활용할 때는 클린업에 특히 신경 써야 됩니다.
컴포넌트가 사라졌는데도 rAF 루프가 돌아가면 불필요한 연산이나 메모리 누수가 발생할 수 있습니다.
위 예시처럼 return () => cancelAnimationFrame(id) 패턴을 습관화하여 컴포넌트 언마운트 시 애니메이션을 정지시키는 것은 필수입니다.
requestAnimationFrame은 프론트엔드 퍼포먼스 최적화의 핵심 도구로, 저수준에서 브라우저 렌더링 사이클을 활용하게 해 줍니다.
마지막으로 내용을 요약하며 글을 마치겠습니다.
rAF를 잘 활용한다면 UX을 향상하면서도 성능을 놓치지 않는 서비스를 만들 수 있을 것입니다.
- rAF는 브라우저에 의해 조율되는 60 FPS 애니메이션 루프를 가능하게 하며, 기존 타이머 기반 방법보다 부드럽고 신뢰성 있는 프레임 관리를 제공합니다.
- 애니메이션뿐만 아니라 스크롤, 입력 이벤트 최적화, 대량 작업 분산 처리 등 UI 반응성 개선에 다양하게 쓸 수 있습니다.
- React환경에서도 rAF를 활용해 부드러운 UI 효과를 줄 수 있지만, 리액트의 렌더링 비용과 클린업을 염두에 두고 사용해야 합니다.