requestAnimationFrame
화면에서 무언가를 부드럽게 움직이고 싶을 때, 가장 먼저 떠오르는 건 setInterval이나 setTimeout이다.
setInterval(() => {
element.style.left = position++ + "px";
}, 16); // 대략 60fps를 노려서 16ms
이 코드는 동작은 하지만 근본적인 문제가 있다. 브라우저가 실제로 화면을 다시 그리는 타이밍과 전혀 상관없이 콜백이 실행된다는 점이다. 브라우저는 모니터의 주사율(보통 60Hz)에 맞춰 약 16.67ms마다 한 번씩 화면을 갱신하는데, setInterval의 16ms 타이머는 이 주기와 어긋날 수밖에 없다. 타이머가 화면 갱신 사이의 어중간한 시점에 실행되면 그 프레임에서는 변경이 반영되지 않고, 다음 프레임에서 두 번치 변경이 한꺼번에 적용되면서 끊김(jank)이 발생한다.
더 심각한 문제는 탭이 백그라운드에 있을 때도 타이머가 계속 돌아간다는 것이다. 보이지 않는 탭에서 16ms마다 콜백을 실행하면 CPU와 배터리만 소모한다. 브라우저가 쓰로틀링을 걸긴 하지만, 완전히 멈추지는 않는다.
requestAnimationFrame(이하 rAF)은 이 문제를 구조적으로 해결한다. 브라우저의 렌더링 파이프라인에 직접 콜백을 등록하는 방식이기 때문이다.
기본 사용법
function animate(timestamp) {
// timestamp: 콜백이 호출된 시점의 DOMHighResTimeStamp (ms)
// 여기서 DOM 업데이트 수행
requestAnimationFrame(animate); // 다음 프레임 예약
}
const id = requestAnimationFrame(animate); // 최초 호출
// 취소
cancelAnimationFrame(id);
requestAnimationFrame은 콜백을 다음 화면 갱신 직전에 한 번 실행하도록 예약한다. 한 번 호출하면 한 번만 실행되므로, 연속 애니메이션을 만들려면 콜백 안에서 다시 requestAnimationFrame을 호출해야 한다. 반환값은 정수 ID로, cancelAnimationFrame에 전달하면 예약을 취소할 수 있다.
콜백에 전달되는 timestamp는 performance.now()와 동일한 기준의 고해상도 타임스탬프다. 이전 프레임의 렌더링이 끝난 시점을 나타내며, 같은 프레임에서 등록된 모든 rAF 콜백은 동일한 timestamp를 받는다.
브라우저 렌더링 파이프라인에서의 위치
rAF가 왜 특별한지 이해하려면 브라우저가 한 프레임을 어떻게 처리하는지 알아야 한다. 60Hz 모니터 기준으로 브라우저는 약 16.67ms 안에 다음 과정을 완료해야 한다.
Input Events → JavaScript → rAF callbacks → Style → Layout → Paint → Composite → (VSync) → Display
- Input Events: 클릭, 스크롤, 키보드 등 입력 이벤트 처리
- JavaScript: 일반 JS 실행 (타이머 콜백, Promise microtask 등)
- rAF Callbacks:
requestAnimationFrame으로 등록된 콜백들 실행 - Style Calculation: CSS 규칙 매칭, computed style 계산
- Layout: 요소의 크기와 위치 계산 (reflow)
- Paint: 레이어별 픽셀 그리기
- Composite: 레이어 합성 후 GPU로 전송
- VSync: 모니터 수직 동기화 신호에 맞춰 화면 업데이트
핵심은 rAF 콜백이 Style/Layout/Paint 직전에 실행된다는 것이다. rAF에서 DOM을 변경하면 그 변경이 바로 다음 단계에서 스타일 계산과 레이아웃에 반영되고, 같은 프레임 안에서 화면에 그려진다. setTimeout으로 DOM을 변경하면 이 파이프라인과 동기화가 안 되기 때문에 한 프레임 늦게 반영되거나, 아예 다음 프레임까지 밀리는 일이 생긴다.
setInterval/setTimeout과의 핵심 차이
1. VSync 동기화
setInterval(fn, 16)은 "16ms 후에 실행해줘"라는 요청이다. 브라우저의 이벤트 루프가 바쁘면 20ms, 25ms 후에 실행될 수도 있다. 반면 rAF는 "다음 화면 갱신 때 실행해줘"라는 요청이다. 모니터가 60Hz면 ~16.67ms, 144Hz면 ~6.94ms 간격으로 자동 조절된다. 모니터 주사율이 바뀌어도 코드를 수정할 필요가 없다.
// setInterval: 모니터 주사율과 무관하게 고정 간격
setInterval(render, 16); // 144Hz 모니터에서는 프레임마다 2번씩 호출될 수도
// rAF: 모니터 주사율에 자동 맞춤
function loop() {
render();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
2. 배치 처리
한 프레임에 여러 rAF 콜백이 등록되어 있으면, 브라우저는 이들을 모아서 한꺼번에 실행한다. 각 콜백의 DOM 변경은 축적되다가 한 번의 Style→Layout→Paint 사이클로 처리된다. setTimeout으로 여러 곳에서 DOM을 변경하면 각각 별도의 렌더링을 트리거할 수 있다.
3. 백그라운드 탭 동작
탭이 보이지 않으면 브라우저는 rAF 콜백 호출을 완전히 중단한다. 보이지 않는 화면의 애니메이션에 CPU를 낭비하지 않는 것이다. 탭이 다시 포그라운드로 돌아오면 콜백이 재개된다. setTimeout/setInterval은 백그라운드에서도 쓰로틀링만 될 뿐 완전히 멈추지는 않는다.
timestamp 활용: 시간 기반 애니메이션
rAF 콜백의 호출 간격은 모니터 주사율에 따라 달라지기 때문에, "프레임당 얼마나 이동"이 아니라 "초당 얼마나 이동"으로 생각해야 한다. 그래야 60Hz에서든 144Hz에서든 동일한 속도로 애니메이션이 재생된다.
let previousTime = null;
const SPEED = 200; // 초당 200px 이동
function animate(timestamp) {
if (previousTime === null) {
previousTime = timestamp;
requestAnimationFrame(animate);
return;
}
const deltaTime = (timestamp - previousTime) / 1000; // 초 단위
previousTime = timestamp;
position += SPEED * deltaTime;
element.style.transform = `translateX(${position}px)`;
if (position < targetPosition) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
deltaTime은 이전 프레임과 현재 프레임 사이의 시간 간격이다. 60Hz에서는 약 0.0167초, 144Hz에서는 약 0.0069초가 된다. 속도에 deltaTime을 곱하면 어떤 주사율에서든 "초당 200px"이라는 동일한 이동 속도가 보장된다.
첫 프레임에서는 previousTime이 없으므로 건너뛰어야 한다. 그렇지 않으면 페이지 로드 시점부터의 거대한 deltaTime이 계산되어 애니메이션이 한 프레임 만에 끝까지 점프할 수 있다.
FPS 제한하기
rAF는 모니터 주사율에 맞춰 실행되지만, 때로는 의도적으로 FPS를 낮추고 싶을 때가 있다. 예를 들어 게임 로직을 30fps로 고정하거나, 무거운 연산의 호출 빈도를 줄이고 싶은 경우다.
const TARGET_FPS = 30;
const FRAME_DURATION = 1000 / TARGET_FPS; // 약 33.33ms
let lastFrameTime = 0;
function gameLoop(timestamp) {
requestAnimationFrame(gameLoop);
const elapsed = timestamp - lastFrameTime;
if (elapsed < FRAME_DURATION) return;
// 정확한 타이밍을 위해 나머지를 보정
lastFrameTime = timestamp - (elapsed % FRAME_DURATION);
// 실제 업데이트 로직
update();
render();
}
requestAnimationFrame(gameLoop);
여기서 주의할 점은 lastFrameTime = timestamp가 아니라 timestamp - (elapsed % FRAME_DURATION)으로 설정하는 것이다. 단순히 timestamp로 설정하면 매 프레임에서 조금씩 밀리는 오차가 누적되어 실제 FPS가 목표보다 낮아진다. 나머지를 빼서 보정하면 장기적으로 정확한 간격을 유지할 수 있다.
실전 패턴: 스크롤 연동 애니메이션
스크롤 이벤트에서 직접 DOM을 조작하면 스크롤마다 reflow가 발생한다. rAF로 실제 DOM 업데이트를 다음 프레임으로 미루면 성능이 크게 개선된다.
let ticking = false;
function onScroll() {
if (!ticking) {
requestAnimationFrame(() => {
// DOM 업데이트는 여기서
header.style.transform = `translateY(${-window.scrollY * 0.5}px)`;
ticking = false;
});
ticking = true;
}
}
window.addEventListener("scroll", onScroll, { passive: true });
ticking 플래그는 같은 프레임 내에서 rAF가 중복 등록되는 것을 방지한다. 스크롤 이벤트는 한 프레임에 여러 번 발생할 수 있는데, 어차피 화면 갱신은 프레임당 한 번이므로 마지막 스크롤 위치만 반영하면 된다. passive: true는 브라우저에게 이 리스너가 preventDefault()를 호출하지 않을 것임을 알려서 스크롤 성능을 추가로 최적화한다.
실전 패턴: Canvas 렌더링 루프
Canvas 애니메이션에서 rAF는 사실상 필수다. Canvas는 즉시 모드(immediate mode) 렌더링이라 매 프레임마다 전체를 다시 그려야 하기 때문이다.
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const particles = [];
let animationId = null;
function update(deltaTime) {
for (const p of particles) {
p.x += p.vx * deltaTime;
p.y += p.vy * deltaTime;
p.vy += 980 * deltaTime; // 중력 가속도 (px/s²)
// 바닥 충돌
if (p.y > canvas.height) {
p.y = canvas.height;
p.vy *= -0.7; // 반발 계수
}
}
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const p of particles) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.fill();
}
}
let prevTime = null;
function loop(timestamp) {
if (prevTime === null) prevTime = timestamp;
const dt = Math.min((timestamp - prevTime) / 1000, 0.1); // 최대 100ms 클램프
prevTime = timestamp;
update(dt);
draw();
animationId = requestAnimationFrame(loop);
}
// 시작
animationId = requestAnimationFrame(loop);
// 정지
function stop() {
cancelAnimationFrame(animationId);
animationId = null;
prevTime = null;
}
몇 가지 중요한 포인트:
- deltaTime 클램핑:
Math.min(dt, 0.1)로 최대값을 제한한다. 탭이 백그라운드에 있다가 돌아오면 수 초의 deltaTime이 들어올 수 있는데, 이러면 물리 시뮬레이션이 폭발한다. 0.1초(100ms)로 클램프하면 최대 10fps 수준의 업데이트만 허용된다. - update와 draw 분리: 로직(물리 연산)과 렌더링을 분리하면 나중에 update만 더 높은 빈도로 실행하거나, draw를 건너뛰는 등 유연하게 조절할 수 있다.
- 정지 시 prevTime 초기화: 애니메이션을 멈췄다 다시 시작할 때 prevTime을 null로 리셋해야 한다. 안 그러면 멈춰있던 시간이 거대한 deltaTime으로 들어온다.
React에서의 rAF
React 컴포넌트에서 rAF를 사용할 때는 cleanup이 핵심이다. 컴포넌트가 언마운트될 때 cancelAnimationFrame을 호출하지 않으면 이미 없어진 DOM을 참조하는 콜백이 계속 실행된다.
import { useRef, useEffect, useCallback } from "react";
function useAnimationFrame(callback) {
const requestRef = useRef(null);
const previousTimeRef = useRef(null);
const callbackRef = useRef(callback);
// 콜백을 ref로 관리하면 의존성 배열에서 자유로워짐
callbackRef.current = callback;
useEffect(() => {
function animate(timestamp) {
if (previousTimeRef.current !== null) {
const deltaTime = timestamp - previousTimeRef.current;
callbackRef.current(deltaTime);
}
previousTimeRef.current = timestamp;
requestRef.current = requestAnimationFrame(animate);
}
requestRef.current = requestAnimationFrame(animate);
return () => {
cancelAnimationFrame(requestRef.current);
};
}, []);
}
// 사용
function AnimatedCounter() {
const [count, setCount] = useState(0);
useAnimationFrame((deltaTime) => {
setCount((prev) => prev + deltaTime * 0.01);
});
return <div>{Math.round(count)}</div>;
}
callbackRef로 콜백을 감싸는 이유는 중요하다. 만약 callback을 직접 useEffect 의존성에 넣으면, 콜백이 바뀔 때마다 effect가 재실행되면서 애니메이션이 끊긴다. ref를 통해 항상 최신 콜백을 참조하면서도 effect는 마운트 시 한 번만 실행되도록 할 수 있다. 이건 "latest ref" 패턴이라 불리며, rAF뿐 아니라 이벤트 리스너, 인터벌 등에서도 자주 사용된다.
requestAnimationFrame vs requestIdleCallback
둘 다 브라우저가 적절한 시점에 콜백을 실행해주는 API지만, 목적이 완전히 다르다.
| requestAnimationFrame | requestIdleCallback | |
|---|---|---|
| 실행 시점 | 다음 화면 갱신 직전 (매 프레임) | 브라우저가 할 일이 없는 유휴 시간 |
| 보장 | 매 프레임 실행 보장 | 유휴 시간이 없으면 실행 안 될 수도 |
| 용도 | 시각적 업데이트 (애니메이션, Canvas) | 비긴급 작업 (분석 데이터 전송, 캐시 정리) |
| 타임 버짓 | ~16.67ms 내에 완료해야 | deadline.timeRemaining()로 남은 시간 확인 |
// rAF: 반드시 다음 프레임에 실행
requestAnimationFrame(() => {
updateAnimation(); // 시각적 업데이트, 매 프레임 필수
});
// rIC: 브라우저가 한가할 때
requestIdleCallback(
(deadline) => {
while (deadline.timeRemaining() > 0 && tasks.length > 0) {
processTask(tasks.pop()); // 비긴급 작업
}
},
{ timeout: 2000 } // 최대 2초 안에는 실행해줘
);
requestIdleCallback은 Safari에서 지원이 늦었고(2024년부터 지원), 실행이 보장되지 않기 때문에 timeout 옵션을 함께 쓰는 것이 좋다.
주의사항과 함정
1. 콜백 안에서 무거운 작업 금지
rAF 콜백이 16ms를 넘기면 프레임 드롭이 발생한다. 무거운 계산은 Web Worker로 옮기거나, 여러 프레임에 나눠서 처리해야 한다.
// ❌ 한 프레임에 10만 개 파티클 계산 + 렌더링
function animate(timestamp) {
for (let i = 0; i < 100000; i++) {
updateParticle(particles[i]); // 프레임 드롭 확정
}
requestAnimationFrame(animate);
}
// ✅ 청크로 나눠서 처리
const CHUNK_SIZE = 5000;
let currentIndex = 0;
function animate(timestamp) {
const end = Math.min(currentIndex + CHUNK_SIZE, particles.length);
for (let i = currentIndex; i < end; i++) {
updateParticle(particles[i]);
}
currentIndex = end >= particles.length ? 0 : end;
draw();
requestAnimationFrame(animate);
}
2. rAF 안에서 레이아웃 읽기/쓰기 혼합 금지
// ❌ 강제 동기 레이아웃 (layout thrashing)
requestAnimationFrame(() => {
const height = element.offsetHeight; // 읽기 → 레이아웃 강제 계산
element.style.height = height * 2 + "px"; // 쓰기
const width = element.offsetWidth; // 읽기 → 또 레이아웃 강제 계산
element.style.width = width * 2 + "px"; // 쓰기
});
// ✅ 읽기 먼저, 쓰기 나중에
requestAnimationFrame(() => {
// 읽기 단계
const height = element.offsetHeight;
const width = element.offsetWidth;
// 쓰기 단계
element.style.height = height * 2 + "px";
element.style.width = width * 2 + "px";
});
레이아웃 속성(offsetHeight, offsetWidth, getBoundingClientRect 등)을 읽으면 브라우저가 현재까지의 스타일 변경을 즉시 계산해야 한다. 읽기와 쓰기가 번갈아 나오면 매번 레이아웃을 다시 계산하는 "layout thrashing"이 발생한다. 항상 읽기를 먼저, 쓰기를 나중에 하자.
3. 재귀 호출 위치
// ✅ 콜백 시작 부분에서 다음 프레임 예약
function animate(timestamp) {
requestAnimationFrame(animate); // 먼저 예약
update(timestamp); // 에러가 나도 다음 프레임은 예약됨
}
// ⚠️ 콜백 끝에서 예약하면 에러 시 루프 중단
function animate(timestamp) {
update(timestamp); // 여기서 에러 나면
requestAnimationFrame(animate); // 이 줄 실행 안 됨 → 루프 멈춤
}
콜백 시작 부분에서 다음 프레임을 예약하면, 현재 프레임에서 에러가 발생해도 애니메이션 루프가 죽지 않는다. 물론 에러가 발생하면 안 되겠지만, 방어적으로 코딩하는 습관은 좋다.
정리
requestAnimationFrame은 "브라우저가 화면을 다시 그리기 직전에 이 코드를 실행해줘"라는 요청이다. setTimeout이나 setInterval과 달리 브라우저의 렌더링 사이클에 직접 통합되어 있기 때문에, 모니터 주사율에 자동으로 맞춰지고 백그라운드 탭에서 자동으로 멈춘다. 시각적 업데이트가 필요한 모든 곳에서 rAF는 기본이다. Canvas 애니메이션, 스크롤 연동 효과, 드래그 앤 드롭, 프로그레스 바 같은 것들은 모두 rAF 위에서 돌아간다.