본문 바로가기

TIL

requestAnimationFrame — 브라우저 렌더 루프의 작동 방식

브라우저에서 무언가를 계속 업데이트해야 할 때 가장 단순한 방법은 setInterval이다. 예를 들어 16ms마다 함수를 부르면 약 60fps가 된다. 그러나 이 방식은 실제로 잘 맞지 않는다. 브라우저가 화면을 그리는 타이밍과 setInterval이 함수를 부르는 타이밍이 어긋날 수 있기 때문이다.

requestAnimationFrame(이하 rAF)은 이 문제를 해결하기 위해 만들어진 API다.

브라우저가 화면을 그리는 방식

모니터는 초당 정해진 횟수만큼 화면을 새로 그린다. 60Hz 모니터는 초당 60번, 120Hz 모니터는 초당 120번이다. 이 주기를 수직 동기화 신호(vertical sync, vsync)라 부른다.

브라우저도 이 주기에 맞춰 렌더링 파이프라인을 돌린다. JavaScript 실행, 레이아웃 계산, 페인트, 합성이 한 프레임 안에 일어난다. 이 파이프라인이 시작되기 직전, 브라우저는 rAF로 등록해둔 콜백을 호출한다. 이 콜백 안에서 DOM을 바꾸거나 캔버스에 그리면, 그 변경사항이 같은 프레임 안에서 화면에 반영된다.

setInterval(fn, 16)은 운영체제 타이머가 16ms마다 fn을 부르도록 예약한다. 이 타이머는 브라우저의 렌더 파이프라인을 모른다. 타이머가 렌더 파이프라인 중간에 끼어들면 이미 페인트가 끝난 뒤에 DOM을 바꾸게 되어 변경이 다음 프레임에야 반영된다. 사실상 1프레임이 낭비된다.

rAF는 렌더 파이프라인 바로 앞에서 실행이 보장되므로, 변경이 항상 현재 프레임에 반영된다. 화면 갱신 주기와 정확히 동기화된다.

콜백에 넘어오는 타임스탬프

rAF 콜백은 호출 시 DOMHighResTimeStamp를 인수로 받는다. 페이지가 로드된 이후의 밀리초 단위 시간이다. performance.now()와 같은 기준점을 쓰지만 rAF가 직접 넘겨주므로 콜백 안에서 다시 performance.now()를 부를 필요가 없다.

이 타임스탬프는 단조 증가한다. 콜백이 실행된 정확한 시각을 나타내며, 연속 두 프레임의 타임스탬프 차이가 프레임 간격이다. 60Hz에서는 약 16.67ms, 120Hz에서는 약 8.33ms다. 실제로는 이 값이 정확히 일정하지 않다. 시스템 부하에 따라 한 프레임이 늦어지거나 빠를 수 있다.

One Euro Filter나 ML 추론 결과를 다룰 때 이 타임스탬프를 그대로 쓴다. 고정 간격을 가정하지 않고 실제 시간 차이를 계산에 넣기 때문에 프레임 간격이 불규칙해도 일관된 동작을 보장한다.

백그라운드 탭에서의 동작

탭이 백그라운드로 내려가면 브라우저는 rAF 콜백 실행을 멈추거나 크게 줄인다. 일반적으로 초당 1회 미만으로 떨어지거나 완전히 중단된다. 배터리와 CPU 사용을 줄이기 위한 브라우저 정책이다.

이 동작은 실시간 카메라 처리 앱에서 중요한 함의를 가진다. 사용자가 다른 탭으로 이동하면 웹캠 추론 루프가 사실상 멈춘다. 앱이 "집중 중인지" 추적하는 기능이 있다면, 탭이 백그라운드일 때는 신뢰할 수 있는 데이터를 받지 못한다는 사실을 명시적으로 다뤄야 한다. 대개는 탭이 보이는 동안(document.visibilityState === 'visible')에만 루프가 의미 있다고 보고 처리한다.

반대로 setInterval은 백그라운드 탭에서도 계속 실행된다. 불필요한 연산을 배경에서 계속 돌리는 셈이다. rAF의 자동 일시정지는 이 면에서도 유리하다.

rAF 루프와 FPS 제어

rAF는 화면 주사율에 맞춰 최대한 빠르게 실행하려 한다. 60Hz 모니터에서는 초당 60번 호출된다. ML 추론을 매 rAF마다 실행하면 프레임마다 추론이 일어난다. 무거운 모델이라면 CPU·GPU에 과부하가 걸릴 수 있다.

이럴 때는 마지막 처리 시각을 기억해두고 최소 간격이 지났을 때만 추론을 실행하는 방식으로 FPS를 제한한다.

const minInterval = 1000 / maxFps;  // maxFps = 30이면 33.3ms
let lastProcess = 0;

function loop(now: number) {
  requestAnimationFrame(loop);
  if (now - lastProcess < minInterval) return;
  lastProcess = now;
  // 추론 실행
}
requestAnimationFrame(loop);

rAF는 화면 갱신마다 loop를 부르지만, 실제 추론은 minInterval마다 한 번만 일어난다. 120Hz 모니터에서도 추론은 30fps로 유지된다. 화면 렌더링(커서 위치 업데이트 등)은 여전히 화면 주사율로 일어나므로 시각적 끊김은 없다.

다음 프레임 예약 방식

rAF는 "다음 프레임에 한 번만 호출해달라"는 예약이다. 콜백 안에서 다시 rAF를 호출해야 계속 실행된다. 콜백이 끝나고 다시 예약하지 않으면 루프가 멈춘다.

이 구조는 루프 중단을 깔끔하게 만든다. cancelAnimationFrame(id)로 예약을 취소하거나, 콜백 안의 조건 분기에서 재예약을 생략하면 루프가 정지한다. 외부에서 플래그 하나로 루프를 끄고 켜기가 쉽다.

let rafId = 0;
let running = false;

function loop() {
  if (!running) return;
  rafId = requestAnimationFrame(loop);
  // 처리
}

function start() { running = true; loop(); }
function stop()  { running = false; cancelAnimationFrame(rafId); }

어디에 쓰이나

rAF가 실제로 쓰이는 영역은 "매 화면 갱신마다 무언가를 다시 계산하거나 그려야 하는" 모든 곳이다.

캔버스 렌더링. <canvas> 요소에 그림을 그리는 모든 것이 해당된다. 게임, 데이터 시각화, 파티클 시스템, 지도 위의 움직이는 레이어가 rAF 루프 안에서 clearRectdrawImage를 반복한다. Three.js, PixiJS 같은 그래픽 라이브러리는 내부적으로 rAF 루프를 돌린다.

손 랜드마크 오버레이. 웹캠 영상 위에 손뼈대를 그리는 것도 rAF 루프다. 21개 랜드마크 좌표를 매 프레임 읽어 <canvas>에 선과 점으로 그린다. 이때 React 상태로 좌표를 관리하면 매 프레임마다 컴포넌트 전체가 리렌더된다. 대신 최신 추론 결과를 ref에 보관하고 rAF 콜백에서 직접 캔버스를 업데이트하면, React 렌더 트리를 건드리지 않고 빠르게 그릴 수 있다.

실시간 ML 추론 루프. 웹캠 프레임을 ML 모델에 넣는 것 자체가 rAF 루프 안에서 일어난다. detectForVideo(video, now)는 현재 비디오 프레임을 꺼내 추론한다. rAF는 비디오가 실제로 새 프레임을 갖고 있을 때 호출되도록 타이밍을 맞추므로, 같은 프레임을 두 번 처리하는 낭비가 없다. 위에서 설명한 FPS 제한을 조합하면 추론 횟수를 모델 무게에 맞게 조절할 수 있다.

커스텀 커서. 브라우저 기본 커서 대신 직접 그린 커서(예: 손끝 위치를 따라다니는 점)를 구현할 때 rAF로 위치를 업데이트한다. mousemove 이벤트로 위치를 기억해두고, rAF에서 그 위치로 DOM 요소를 이동시키는 패턴이 흔하다. 이벤트마다 style.transform을 바꾸는 것보다 rAF에서 한 번에 처리하면 레이아웃 스래싱(layout thrashing, 읽고 쓰고 읽고 쓰는 반복으로 강제 레이아웃이 여러 번 일어나는 현상)을 피할 수 있다.

Web Audio 시각화. 오디오 파형이나 주파수 스펙트럼을 실시간으로 그리는 것도 rAF다. AnalyserNode.getByteTimeDomainData()로 매 프레임 오디오 데이터를 꺼내 캔버스에 파형을 그린다. 오디오 처리는 별도 스레드지만, 시각화는 rAF와 캔버스가 담당한다.

JS 주도 애니메이션. CSS 트랜지션이나 CSS 애니메이션으로 표현하기 어려운 물리 기반 움직임(스프링, 관성, 충돌)이나 다른 요소의 상태에 반응하는 애니메이션은 rAF로 직접 구현한다. 매 프레임 속도·가속도를 계산하고 위치를 갱신한다. GSAP, Framer Motion 같은 애니메이션 라이브러리도 내부에서 rAF를 쓴다.

이점과 트레이드오프

이점은 세 가지다. 화면 갱신 타이밍과 동기화되어 불필요한 렌더링이 없다. 백그라운드 탭에서 자동으로 멈춰 자원 낭비가 줄어든다. 실제 경과 시간이 타임스탬프로 제공되어 프레임 간격 계산이 정확하다.

감수하는 것은 화면 주사율에 의존한다는 점이다. 120Hz 모니터에서는 초당 120번 콜백이 호출된다. 매번 무거운 작업을 하면 프레임 드롭이 생긴다. 위에서 본 FPS 제한 패턴이 필요해진다. 또 Web Worker에서는 rAF를 쓸 수 없다. 백그라운드 스레드에서 루프를 돌리려면 setTimeout이나 postMessage를 써야 한다.