예전에 JS런타임에 대한 글을 포스팅할 때, 렌더링 시점과 관련해서 더 알아보면 좋을 개념에 requestAnimationFrame 이 있었다.
해당 포스팅을 짧게 요약하자면, JavaScript는 싱글 스레드이지만 이벤트 루프 덕분에 비동기 작업을 처리하며 멀티 스레드처럼 동작한다.
JavaScript의 메인 스레드에는 콜 스택이 존재하고, 함수가 호출되면 콜 스택에 쌓여 순차적으로 실행된 뒤 실행이 끝나면 제거된다.
비동기 함수를 만나면 해당 작업은 Web API로 넘어가고, 작업이 완료되면 콜백 함수가 이벤트 큐에 들어간다.
이벤트 루프는 콜 스택이 비어 있을 때 이벤트 큐에 있는 콜백 함수를 콜 스택으로 이동시켜 실행하도록 관리한다.
이 글과 rAF(이제부터 줄임말로 쓰겠다)와 무슨 상관이 있을까?
이벤트 큐는 매크로태스크/마이크로태스크 큐로 구성이 되어 있는데 이들은 우선순위가 정해져 있다.
마이크로태스크의 우선순위가 더 높아서 먼저 콜스택으로 넘어간다.
Promise,mutationObserver 등이 마이크로태스크이고,
setTimeout,setInterval,fetch 함수들이 매크로태스크에 속한다.
그럼 rAF는 어느 큐에 들어갈까?
마이크로,매크로가 아닌 애니메이션 프레임이라는 별도의 큐에 들어간다.
rAF는 일반 비동기가 아니다, 렌더링 직전에 실행되는 비동기이다.
이렇게 한 프레임의 흐름이 흘러간다.
rAF의 역할은 브라우저가 다음 화면을 그리기 전에 콜백 함수를 실행하는 것이다.
rAF를 사용할 때 프레임 단위에 맞게 설계해야 한다.
현대 기기는 대부분 1초당 60프레임으로 구성되어 있다고 한다. 그래서 이를 60fps라고 한다.
초당 60개의 프레임을 렌더링한다는 것은 16ms마다 프레임을 새로 생성해야 한다는 것이다.(1000/60)
그럼 JS에선 16ms마다 코드를 호출하는 식으로 구현해야 한다.
흔히 타이머를 구현할 때 setTimeout과 setInterval을 많이 사용한다.
그런데 이 함수들은 주어진 시간 내에 동작하고 프레임은 신경 쓰지 않는다.

setTimeout을 보면
브라우저가 프레임3을 그리려고 준비 중인데 setTimeout의 콜백 함수가 뒤늦게 실행되면 화면 갱신이 뒤로 밀리게 되는데 이때 우리가 버벅거림을 시각적으로 확인할 수 있다.
반면에, rAF를 확인해보면
브라우저의 렌더 시점 이전에 실행되는 것을 볼 수 있다.
결과적으로, 렌더링 프로세스와 JS 실행이 물 흐르듯 자연스럽게 연결되어 자연스러운 애니메이션을 볼 수 있다.
rAF는 setTiemout과 동일하게 콜백함수 내부에서 재귀 호출하는 식으로 구성하면 된다. 또 setTimeout에서 타이머 클린업 함수인 clearTimeout을 사용하듯이, 특정한 조건에서 애니메이션을 종료하고 싶을 때 cleanAnimationFrame()을 사용한다.
let id;
function animate() {
id = requestAnimationFrame(animate);
}
id = requestAnimationFrame(animate);
// 멈추기
cancelAnimationFrame(id);
function Timer({ onTimeout }) {
const [progress, setProgress] = useState(100);
const totalTime = 60000; // 60초 타이머
const intervalTime = 10; //0.01초마다 업데이트
const step = 100 / (totalTime / intervalTime);
useEffect(() => {
const interval = setInterval(() => {
setProgress((prev) => {
if (prev <= 0) {
clearInterval(interval);
onTimeout();
return 0;
}
return prev - step;
});
}, intervalTime);
return () => clearInterval(interval);
}, []);
return (
<div id="question">
<progress value={progress} max="100"></progress>
</div>
);
}
export default Timer;
setInterval을 사용하면 이런 식으로 구현할 수 있다.
애니메이션은 css의 transition으로 관리하면 된다.
탭을 옮겨서 5초 동안 멈췄다면, 돌아왔을 때도 멈춘 시간부터 다시 타이머가 동작할 것이다. 실제론 5초가 흘렀지만 타이머는 5초를 번 셈이다.
그러면 실제 시간을 기반으로 타이머를 구현하고 싶다면?
아래와 같이 requestAnimationFrame을 사용해서 인자에 고정밀 타임스탬프(currentTime)를 전달하자.
import { useEffect, useRef, useState } from "react";
function Timer({ onTimeout }) {
const [progress, setProgress] = useState(100);
const totalTime = 10000; // 10초
const rafIdRef = useRef(null);
const startTimeRef = useRef(null);
useEffect(() => {
//애니메이션 프레임마다 실행될 함수
const tick = (currentTime) => {
// 1. 첫 실행 시 시작시간 기록
if (!startTimeRef.current) {
startTimeRef.current = currentTime;
}
// 2. 경과 시간 계산
const elapsed = currentTime - startTimeRef.current;
// 3. 진행률 계산(100% -> 0%)
// (경과시간 / 전체시간) 만큼 깎는다.
const remaining = Math.max(100 - (elapsed / totalTime) * 100, 0);
setProgress(remaining);
// 4. 남은 시간이 있으면 다음 프레임 요청, 아니면 타임아웃
if (remaining > 0) {
rafIdRef.current = requestAnimationFrame(tick); //재귀로 호출
} else {
onTimeout();
}
};
// 애니메이션 시작
rafIdRef.current = requestAnimationFrame(tick);
// 클린업 : 컴포넌트가 사라지면 애니메이션 중단(메모리 누수 방지)
return () => {
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current);
}
};
}, [totalTime]);
return (
<div id="question">
<progress value={progress} max="100" style={{ width: "100%" }}></progress>
<p>{Math.ceil((progress / 100) * (totalTime / 1000))}초</p>
</div>
);
}
주의할 점은 setInterval에선 css transition으로 브라우저가 자동으로 부드러운 애니메이션을 채워주지만, rAF는 직접적으로 프레임마다 개입해서 애니메이션을 보여주기 때문에 rAF 방식으로 수정했다면 progress태그에 준 스타일에서 transition 속성은 무조건 제거해야 한다.(그렇지 않으면 CSS vs rAF 충돌이 난다.)
rAF타이머에서 리렌더링이 일어나더라도 useRef 덕분에 타이머는 깨지지 않는다.
useRef(rafIdRef, startTimeRef) : 내가 언제 시작했지? 를 기억하고 원래 흐름대로 계산을 이어간다.
그렇기 때문에 다른 탭에 들렸다가 돌아왔을 때 타이머가 "정확한 위치"를 찾아간다는 것이다. 이 점이 setInterval()과의 큰 차이점이다.
60Hz나 144Hz 같은 모니터의 깜빡임 박자에 맞춰 브라우저가 직접 실행 타이밍을 결정한다.
덕분에 화면 갱신과 로직 실행이 어긋나서 생기는 끊김 현상이 사라지고, 시각적으로 가장 부드러운 애니메이션을 구현할 수 있다.
rAF의 콜백 함수는 인자로 DOMHighResTimeStamp를 전달받는다.
// 2. 경과 시간 계산
const elapsed = currentTime - startTimeRef.current;
이 계산으로 실제 경과 시간을 계산할 수 있었던 이유가 이 정밀한 값 덕분이다.
사용자가 다른 탭을 보거나 창을 최소화한다면 콜백 함수 호출을 멈춘다.
반면 setInterval은 비활성화 시에도 계속 돌아가며 자원을 낭비한다.
브라우저는 스타일 계산 -> 레이아웃 -> 페인팅 과정을 거쳐 화면을 그린다.
rAF는 여러 애니메이션 요소를 한 프레임 안에서 일괄적으로 처리하도록 돕는다.
화면을 그리기 전에 모든 변경 사항을 한번에 적용하므로 불필요한 리플로우를 줄여 성능 최적화에 기여한다!
왜 setInterval 대신 rAF를 사용할까?
1. 디스플레이가 초당 프레임을 그릴 때의 타이밍이 다르다. setInterval은 브라우저가 화면을 그리는 타이밍을 무시하고 콜백을 보낸다, 그래서 화면을 새로 그린 직후에 데이터가 변하면 버벅거림 현상이 발생한다. rAF는 브라우저가 프레임을 새로 그리기 전에 실행되기 때문에 애니메이션이 매우 부드럽다.
2. setInterval은 탭이 백그라운드로 가도 느려질 뿐이지 계속 실행되어서 자원을 소유하고 있지만, rAF는 사용자가 탭을 보지 않으면 실행을 잠깐 종료하기 때문에 불필요한 연산을 하지 않아 배터리를 절약한다.
3. rAF는 콜백 인자로 현재 시간을 보내준다. 그렇기 때문에 이전 프레임에서 얼마나 시간이 흘렀는지 정확하게 계산을 할 수 있다.