requestAnimationFrame은 무엇이고 언제 어떻게 써야 할까

Frontend

프론트엔드 개발을 하다 보면 언젠가 requestAnimationFrame이라는 이름을 만나게 됩니다.

  • 애니메이션을 부드럽게 만들 때
  • 스크롤 이벤트 성능을 다룰 때
  • DOM 업데이트 타이밍을 브라우저 렌더링 주기에 맞추고 싶을 때

그런데 이 API는 이름만 보면 단순히 "다음 프레임에 실행해주는 함수"처럼 보입니다. 물론 맞는 설명이지만, 실무에서는 그것만으로는 부족합니다. 중요한 건 브라우저가 화면을 그리는 리듬에 맞춰 코드를 실행하게 해주는 도구라는 점입니다.

이 글에서는 requestAnimationFrame을 단순 사용법보다 아래 흐름으로 보겠습니다.

  1. 무엇인지
  2. setTimeout과 다르게 느껴지는지
  3. 브라우저 렌더링과 어떤 관계가 있는지
  4. 애니메이션, 스크롤, React에서는 어떻게 써야 하는지
  5. 언제 쓰면 좋고 언제는 굳이 쓸 필요가 없는지

한눈에 보면

짧게 정리하면 이렇습니다.

  • 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브라우저가 다음 프레임을 그리기 직전에 콜백을 실행하게 해줍니다.

그래서 아래처럼 시각적인 업데이트를 할 때 잘 맞습니다.

  • translateX
  • scrollTop
  • 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을 "애니메이션 함수"가 아니라 브라우저 렌더링 타이밍에 맞춰 코드를 배치하는 도구로 이해하는 편이 좋습니다.

실무 체크리스트

실제로 적용할 때는 아래 질문으로 빠르게 점검하면 도움이 됩니다.

  1. 이 작업은 정말 시각적 업데이트인가? requestAnimationFrame은 네트워크 요청이나 단순 지연 실행보다, 브라우저가 그리는 순간과 맞춰야 하는 작업에 적합합니다.

  2. 프레임 수가 아니라 시간 기준으로 계산하고 있는가? 애니메이션 속도는 timestamp 기반으로 계산하는 편이 훨씬 안정적입니다.

  3. DOM 읽기와 쓰기를 한 프레임 안에서 과하게 섞고 있지 않은가? requestAnimationFrame을 써도 layout thrashing은 여전히 생길 수 있습니다.

  4. React라면 cleanup이 정확한가? effect cleanup에서 cancelAnimationFrame을 빼먹으면 unmount 이후에도 예약이 남을 수 있습니다.

  5. CSS로 충분한 문제를 굳이 JavaScript로 풀고 있지는 않은가? 단순 transition이나 keyframes로 해결된다면 그쪽이 더 단순하고 유지보수하기 좋을 수 있습니다.

정리하면

requestAnimationFrame을 한 줄로 줄이면, 브라우저가 다음 화면을 그리기 직전에 실행되는 콜백을 예약하는 API입니다.

실무 기준으로 기억할 핵심은 이렇습니다.

  • setTimeout보다 렌더링 타이밍에 더 잘 맞고
  • 애니메이션, 스크롤, 드래그 같은 시각적 업데이트에 특히 잘 맞으며
  • timestamp를 활용한 시간 기반 계산이 중요하고
  • React에서는 cleanup과 리렌더 비용까지 같이 봐야 합니다

결국 중요한 것은 "requestAnimationFrame을 쓸 줄 아는가"가 아니라, 이 작업이 브라우저의 그리기 리듬에 맞춰야 하는 종류의 작업인가를 구분하는 것입니다. 그 판단이 서면 언제 requestAnimationFrame이 필요한지도 훨씬 분명해집니다.

같이 보면 좋은 글