requestAnimationFrame은 무엇이고 언제 어떻게 써야 할까
프론트엔드 개발을 하다 보면 언젠가 requestAnimationFrame이라는 이름을 만나게 됩니다.
- 애니메이션을 부드럽게 만들 때
- 스크롤 이벤트 성능을 다룰 때
- DOM 업데이트 타이밍을 브라우저 렌더링 주기에 맞추고 싶을 때
그런데 이 API는 이름만 보면 단순히 "다음 프레임에 실행해주는 함수"처럼 보입니다. 물론 맞는 설명이지만, 실무에서는 그것만으로는 부족합니다. 중요한 건 브라우저가 화면을 그리는 리듬에 맞춰 코드를 실행하게 해주는 도구라는 점입니다.
이 글에서는 requestAnimationFrame을 단순 사용법보다 아래 흐름으로 보겠습니다.
- 무엇인지
- 왜
setTimeout과 다르게 느껴지는지 - 브라우저 렌더링과 어떤 관계가 있는지
- 애니메이션, 스크롤, React에서는 어떻게 써야 하는지
- 언제 쓰면 좋고 언제는 굳이 쓸 필요가 없는지
한눈에 보면
짧게 정리하면 이렇습니다.
requestAnimationFrame은 브라우저가 다음 화면을 그리기 직전에 콜백을 실행하게 해줍니다.- 그래서 시각적인 업데이트와 잘 맞습니다.
setTimeout보다 무조건 빠른 것이 아니라, 렌더링 타이밍에 더 자연스럽게 맞는다는 점이 중요합니다.- 애니메이션, 스크롤 위치 반영, 드래그 UI, progress 갱신 같은 작업에 특히 잘 맞습니다.
즉, 이 API의 핵심은 "언제 실행하느냐"입니다. 브라우저가 그릴 준비를 할 때 같이 움직이게 해주기 때문에 시각 업데이트가 덜 어색하게 느껴집니다.
먼저 개념부터
가장 단순한 형태는 이렇게 생겼습니다.
const id = requestAnimationFrame((timestamp) => {
console.log(timestamp);
});콜백에는 브라우저가 주는 시간값이 들어오고, 반환값으로는 취소에 쓸 id를 받습니다.
const id = requestAnimationFrame(() => {
console.log('next frame');
});
cancelAnimationFrame(id);즉:
- 등록:
requestAnimationFrame - 취소:
cancelAnimationFrame
구조 자체는 단순합니다. 어려운 부분은 이게 정확히 어떤 타이밍에 실행되느냐입니다.
브라우저는 왜 이런 API를 제공할까?
브라우저는 화면을 한 번 그리고 끝나는 것이 아니라, 사용자 입력과 DOM 변화에 따라 계속 다음 프레임을 만들어냅니다.
예를 들어:
- 스타일이 바뀌고
- 레이아웃이 다시 계산되고
- 페인트가 일어나고
- 합성이 일어나는 식으로
렌더링 파이프라인이 반복됩니다.
이때 requestAnimationFrame은 브라우저가 다음 프레임을 그리기 직전에 콜백을 실행하게 해줍니다.
그래서 아래처럼 시각적인 업데이트를 할 때 잘 맞습니다.
translateXscrollTop- canvas 그리기
- progress bar 너비 변경
즉, 이 API는 "아무 때나 비동기로 실행"하는 것이 아니라, 브라우저의 그리기 타이밍에 맞춘 예약이라고 보는 편이 더 정확합니다.
setTimeout과는 무엇이 다를까?
많이 비교되는 API가 setTimeout입니다.
예를 들어 16ms마다 움직이면 60fps 비슷하게 나올 것 같아서 아래처럼 쓰기 쉽습니다.
setTimeout(() => {
move();
}, 16);겉보기엔 비슷해 보여도 차이가 큽니다. 그리고 여기서 먼저 짚어야 할 오해가 하나 있습니다. 브라우저가 항상 정확히 16.6ms마다 프레임을 그리는 것은 아닙니다.
- 디스플레이가 60Hz일 수도 있고
- 120Hz, 144Hz처럼 더 높은 주사율일 수도 있으며
- 탭 상태나 브라우저 부하에 따라 실제 프레임 간격도 달라질 수 있습니다
즉, 실무에서는 "16ms마다 돌리면 된다"보다 브라우저가 실제로 프레임을 만들 타이밍에 맞추자가 더 정확한 관점입니다.
setTimeout
- 일정 시간 뒤에 실행
- 브라우저 렌더링 타이밍과 직접 연결되지 않음
- 탭이 백그라운드로 가도 기대와 다른 타이밍으로 실행될 수 있음
requestAnimationFrame
- 다음 페인트 직전에 실행
- 시각적인 업데이트와 타이밍이 맞음
- 브라우저가 프레임을 만들지 않는 상황에서는 불필요한 실행을 줄일 수 있음
즉, 둘의 차이는 "몇 ms 뒤냐"보다 브라우저 렌더링 주기에 맞춰져 있느냐입니다.
애니메이션 예제로 보면 가장 잘 보인다
예를 들어 박스를 오른쪽으로 움직이는 애니메이션을 만든다고 해보겠습니다.
const box = document.getElementById('box');
let position = 0;
function animate() {
position += 2;
if (box) {
box.style.transform = `translateX(${position}px)`;
}
if (position < 300) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);이 코드는 재귀처럼 보이지만 실제로는 "다음 프레임에 다시 실행 예약"하는 구조입니다.
핵심은:
- 위치를 계산하고
- DOM에 반영하고
- 다음 프레임을 예약하는 흐름입니다
이 패턴이 requestAnimationFrame의 가장 전형적인 사용법입니다.
timestamp를 써야 하는 이유
처음엔 단순히 position += 2로도 충분해 보입니다. 하지만 이 방식은 프레임레이트가 달라지면 속도 체감도 달라집니다.
그래서 실제로는 timestamp를 기반으로 delta time을 계산하는 편이 좋습니다.
let startTime: number | null = null;
function animate(timestamp: number) {
if (startTime === null) {
startTime = timestamp;
}
const elapsed = timestamp - startTime;
const progress = Math.min(elapsed / 1000, 1);
const x = 300 * progress;
box.style.transform = `translateX(${x}px)`;
if (progress < 1) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);이 방식의 장점은:
- 프레임이 조금 떨어져도
- "시간 기준"으로 이동량을 계산하기 때문에
- 애니메이션 속도가 더 일관적으로 느껴진다는 점입니다
즉, requestAnimationFrame 자체보다 timestamp를 활용한 시간 기반 계산이 실무 품질에 더 중요할 때가 많습니다.
특히 이 포인트는 고주사율 디스플레이에서 더 중요합니다. 프레임 수를 기준으로 이동량을 계산하면:
- 어떤 환경에서는 너무 빠르게 보이고
- 어떤 환경에서는 너무 느리게 보일 수 있습니다
반면 시간 기반 계산은 디바이스 환경이 달라도 비교적 일관된 속도를 유지하기 쉽습니다.
스크롤 이벤트에서는 왜 자주 같이 나오나?
스크롤 이벤트는 아주 자주 발생할 수 있습니다. 그런데 스크롤마다 DOM 측정과 스타일 변경을 바로 해버리면 메인 스레드가 쉽게 바빠집니다.
이럴 때 자주 쓰는 패턴이 requestAnimationFrame batching입니다.
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
const scrollY = window.scrollY;
updateStickyHeader(scrollY);
ticking = false;
});
ticking = true;
}
});이 패턴의 의미는:
- 스크롤 이벤트가 여러 번 들어와도
- 실제 DOM 업데이트는 다음 프레임에 한 번만 하게 만들겠다는 것입니다
즉, requestAnimationFrame은 애니메이션 전용 API라기보다 렌더링 직전으로 작업을 정렬하는 도구로도 많이 쓰입니다.
read와 write를 섞을 때는 더 조심해야 한다
성능 문제가 생기는 지점은 보통 여기입니다.
- 레이아웃 읽기:
getBoundingClientRect(),offsetHeight - 스타일 쓰기:
style.transform,style.width
읽기와 쓰기를 한 프레임 안에서 뒤섞으면 레이아웃 스래싱이 생길 수 있습니다.
예를 들어 이런 코드는 조심해야 합니다.
requestAnimationFrame(() => {
const height = element.offsetHeight;
element.style.height = `${height + 10}px`;
});한 번은 괜찮아 보일 수 있지만, 많은 요소에 반복되면 비용이 커질 수 있습니다. 실무에서는:
- 먼저 읽고
- 계산하고
- 그다음 쓰는 흐름을 분리하는 편이 좋습니다
즉, requestAnimationFrame을 쓴다고 자동으로 성능이 좋아지는 것은 아닙니다. 프레임 타이밍에 맞추는 것과 비싼 작업 자체를 줄이는 것은 별도 문제입니다.
React에서는 어떻게 볼까?
React에서도 requestAnimationFrame은 꽤 자주 등장합니다.
- 직접 DOM을 움직이는 애니메이션
- scroll position 기반 UI
- drag 중간 프레임 업데이트
- resize/scroll의 시각적 반영 batching
예를 들어 진행률 바를 직접 갱신한다고 하면:
import { useEffect, useRef } from 'react';
export function ProgressBar() {
const barRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let frameId = 0;
let startTime: number | null = null;
const animate = (timestamp: number) => {
if (startTime === null) {
startTime = timestamp;
}
const progress = Math.min((timestamp - startTime) / 2000, 1);
if (barRef.current) {
barRef.current.style.transform = `scaleX(${progress})`;
}
if (progress < 1) {
frameId = requestAnimationFrame(animate);
}
};
frameId = requestAnimationFrame(animate);
return () => {
cancelAnimationFrame(frameId);
};
}, []);
return (
<div className="overflow-hidden rounded bg-neutral-200">
<div ref={barRef} className="h-2 origin-left bg-blue-500" />
</div>
);
}여기서 중요한 포인트는 cleanup입니다.
- 컴포넌트가 unmount됐는데도 프레임을 계속 예약하면 안 되고
- effect cleanup에서
cancelAnimationFrame을 호출해야 합니다
즉, React에서는 단순 사용법보다 생명주기와 정리(cleanup) 가 훨씬 중요합니다. 특히 개발 환경의 StrictMode에서는 effect가 기대보다 더 자주 실행되는 것처럼 보일 수 있어서, cleanup이 정확하지 않으면 중복 예약 버그가 더 빨리 드러납니다.
state를 매 프레임마다 바꾸는 것은 어떨까?
이 부분은 많이들 궁금해합니다.
가능은 하지만, 매 프레임 setState를 호출하면 컴포넌트 리렌더가 계속 발생할 수 있습니다. 그래서 아주 잦은 프레임 기반 업데이트는 아래처럼 생각하는 편이 좋습니다.
- 화면에 바로 반영해야 하는 값이면 DOM/ref 기반 업데이트 검토
- React 상태로 꼭 관리해야 하는 값이면 리렌더 비용을 감수할 이유가 있는지 확인
즉, requestAnimationFrame은 브라우저 프레임에 맞춰준다 해도, React 렌더 비용까지 없애주는 것은 아닙니다.
언제 쓰면 좋고 언제 굳이 안 써도 될까?
잘 맞는 경우
- 직접 시각적 애니메이션을 만들 때
- scroll/resize 반응을 프레임 단위로 조율할 때
- canvas나 chart를 그릴 때
- drag, progress, parallax처럼 프레임 기반 UI일 때
굳이 안 써도 되는 경우
- 단순 지연 실행이면
setTimeout - 단순 interval 작업이면
setInterval - CSS transition / animation으로 충분한 경우
- React state 변경 한두 번이면 일반 이벤트 처리로 충분한 경우
즉, requestAnimationFrame은 모든 비동기 작업의 대체제가 아니라 시각적 업데이트 타이밍에 맞춰야 할 때 쓰는 도구입니다.
자주 하는 실수
정리하면 아래 실수가 정말 자주 나옵니다.
setTimeout(16)과 거의 같다고 생각한다- timestamp 없이 프레임 수 기준으로만 애니메이션을 계산한다
- cleanup 없이 계속 프레임을 예약한다
- 비싼 DOM 읽기/쓰기를 프레임 안에 과하게 넣는다
- CSS만으로 충분한 애니메이션까지 JavaScript로 만든다
이 실수들을 줄이려면, requestAnimationFrame을 "애니메이션 함수"가 아니라 브라우저 렌더링 타이밍에 맞춰 코드를 배치하는 도구로 이해하는 편이 좋습니다.
실무 체크리스트
실제로 적용할 때는 아래 질문으로 빠르게 점검하면 도움이 됩니다.
-
이 작업은 정말 시각적 업데이트인가?
requestAnimationFrame은 네트워크 요청이나 단순 지연 실행보다, 브라우저가 그리는 순간과 맞춰야 하는 작업에 적합합니다. -
프레임 수가 아니라 시간 기준으로 계산하고 있는가? 애니메이션 속도는
timestamp기반으로 계산하는 편이 훨씬 안정적입니다. -
DOM 읽기와 쓰기를 한 프레임 안에서 과하게 섞고 있지 않은가?
requestAnimationFrame을 써도 layout thrashing은 여전히 생길 수 있습니다. -
React라면 cleanup이 정확한가? effect cleanup에서
cancelAnimationFrame을 빼먹으면 unmount 이후에도 예약이 남을 수 있습니다. -
CSS로 충분한 문제를 굳이 JavaScript로 풀고 있지는 않은가? 단순 transition이나 keyframes로 해결된다면 그쪽이 더 단순하고 유지보수하기 좋을 수 있습니다.
정리하면
requestAnimationFrame을 한 줄로 줄이면, 브라우저가 다음 화면을 그리기 직전에 실행되는 콜백을 예약하는 API입니다.
실무 기준으로 기억할 핵심은 이렇습니다.
setTimeout보다 렌더링 타이밍에 더 잘 맞고- 애니메이션, 스크롤, 드래그 같은 시각적 업데이트에 특히 잘 맞으며
- timestamp를 활용한 시간 기반 계산이 중요하고
- React에서는 cleanup과 리렌더 비용까지 같이 봐야 합니다
결국 중요한 것은 "requestAnimationFrame을 쓸 줄 아는가"가 아니라, 이 작업이 브라우저의 그리기 리듬에 맞춰야 하는 종류의 작업인가를 구분하는 것입니다. 그 판단이 서면 언제 requestAnimationFrame이 필요한지도 훨씬 분명해집니다.
