프론트엔드 개발을 하다 보면 애니메이션이나 인터랙티브한 UI를 구현해야 할 때가 있다. 이때 단순히 이벤트 구독을 통해 상태를 업데이트하다 보면 화면이 버벅거리거나 불필요하게 CPU 자원을 낭비할 수 있다.
이런 문제를 해결하고 부드러운 UX를 제공하기 위해 꼭 알아야 할 브라우저 API인 requestAnimationFrame(이하 rAF)에 대해 알아보고, 리액트 환경에서 어떻게 도입할 수 있는지 살펴보았다.
먼저 rAF를 이해하려면 하드웨어인 '모니터'가 화면을 그리는 방식을 이해해야 한다.
모니터가 화면을 그리는 방식은 어릴 적 교과서 귀퉁이에 그림을 그려 빠르게 넘겨보던 '플립북'과 유사하다. 플립북이 여러 장의 정지된 그림을 빠르게 넘겨 움직이는 것처럼 보이게 하듯, 모니터도 짧은 시간 동안 화면 전체를 새로운 그림으로 계속 '새로고침'한다. 이때 화면에 보여지는 하나의 그림을 프레임(Frame)이라고 부른다.
즉, 어떤 모니터가 60Hz 주사율을 갖는다는 건, 모니터가 물리적으로 1초에 60개의 프레임을 보여줄 수 있다는 뜻이다. 그렇다면 한 장의 프레임이 화면에 머무르는 시간은, 1000ms / 60 = 약 16.67ms 정도 된다.
- 주사율(Refresh Rate, Hz): 모니터가 1초에 몇 번 화면을 새로고침하는지 나타내는 단위
- 프레임(Frame): 모니터가 한 번에 보여주는 정지된 화면
우리가 아무리 빠르게 화면을 바꾸고 싶어도, 일반적인 60Hz 모니터 환경에서는 최소 16.67ms마다 한 번씩만 새로운 화면을 사용자에게 보여줄 수 있다는 물리적인 한계가 있다.
만약 이 물리적 한계를 고려하지 않고 코드를 작성하면 어떤 문제가 발생할까? 마우스로 박스를 드래그하여 이동시키는 기능을 리액트 환경에서 구현해야 하는 상황을 가정해보자.
아래는 mousemove 이벤트가 발생할 때마다 즉시 상태(bbox)를 업데이트해 박스의 위치를 옮기는 커스텀 훅이다.
import { useCallback, useRef, useState } from 'react';
const useBoxElementMouseHandler = (boxId: string) => {
// bbox: {top, left}
const [bbox, setBbox] = useState({ top: 0, left: 0 });
const startXRef = useRef(0);
const startYRef = useRef(0);
const boxTopRef = useRef(0);
const boxLeftRef = useRef(0);
// 마우스 위치에 따라 박스 bbox 상태값 업데이트
const updateBbox = useCallback(
(e: MouseEvent) => {
const deltaX = e.pageX - startXRef.current;
const deltaY = e.pageY - startYRef.current;
setBbox({
left: deltaX + boxLeftRef.current,
top: deltaY + boxTopRef.current,
});
},
[setBbox],
);
const handleMouseUp = useCallback(() => {
document.removeEventListener('mousemove', updateBbox);
document.removeEventListener('mouseup', handleMouseUp);
}, [updateBbox]);
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
// 시작 좌표 및 현재 박스 크기/위치 기록
startXRef.current = e.pageX;
startYRef.current = e.pageY;
boxTopRef.current = bbox.top;
boxLeftRef.current = bbox.left;
document.addEventListener('mousemove', updateBbox);
document.addEventListener('mouseup', handleMouseUp);
},
[bbox.left, bbox.top, updateBbox, handleMouseUp],
);
return { handleMouseDown };
};
export default useBoxElementMouseHandler;
요즘 사용되는 고성능 마우스는 1초에 수백 번 이상의 위치 정보를 컴퓨터로 전송하는데, 이를 폴링 레이트(Polling Rate)라고 부른다. 만약 사용자가 빠르게 마우스를 움직여서 1초에 120번의 mousemove 이벤트가 발생했다고 해보자.
handleMouseMove 함수가 1초에 120번 실행된다.setBbox 상태 업데이트 함수도 1초에 120번 호출된다.하지만 앞서 살펴봤듯, 60Hz 모니터는 1초에 최대 60번만 화면을 그릴 수 있다.
mousemove 이벤트 발생 -> 새로운 bbox 값으로 리렌더링 요청 1 (반영 X)mousemove 이벤트 발생 -> 새로운 bbox 값으로 리렌더링 요청 2 (반영 X)mousemove 이벤트 발생 -> 새로운 bbox 값으로 리렌더링 요청 3 (반영 X)즉, 실제로 모니터에 반영되지도 못할 중간 단계의 수많은 계산과 리렌더링이 발생해 CPU 자원을 낭비하게 된다. 이는 메인 스레드를 바쁘게 만들어 다른 중요한 작업의 처리를 지연시키고, 결과적으로 애니메이션이 끊겨 보이는 '렉'의 원인이 된다.
- 리액트: "bbox 상태 값이 120번이나 바꼈네? 120번 컴포넌트 함수 다시 실행하고, 120번 가상 돔 비교할게."
- 브라우저: "리액트가 120번이나 DOM 건들여서 나도 다 반영해주고 싶긴 한데, 모니터가 1초에 60번밖에 못 보여줘서 나머지 60번은 그냥 버려져.."
위 문제의 핵심은 결국 '이벤트 발생 주기'와 '브라우저 렌더링 주기'의 불일치다.
그리고 이 주기를 맞춰주는 역할을 하는 게 바로 requestAnimationFrame이다.
requestAnimationFrame(callback)은 브라우저에게 다음과 같이 요청하는 API다.
rAF: "브라우저야, 네가 다음 프레임을 그리기 직전(약 16.67ms가 되기 직전)에, 내가 전달한 이
callback함수를 딱 한 번만 실행해 줘."
이를 통해 우리는 모니터가 화면을 그릴 준비가 됐을 때만 상태를 업데이트하고 그림을 그리도록 예약을 걸 수 있다.
참고로 rAF의 콜백 실행 주기는 모니터 주사율이 아니라 탭 상태에도 영향을 받는다. 브라우저 탭이 백그라운드에 있으면 대부분의 브라우저가 rAF 호출 빈도를 초당 1회 수준으로 크게 낮추고, 가변 주사율(VRR) 모니터에서는 프레임 간격 자체가 고정되어 있지 않다는 점도 함께 알아두면 좋다.
이제 rAF를 사용해 위의 리액트 훅을 최적화해보자.
import { useCallback, useRef, useEffect, useState } from 'react';
const useBoxElementMouseHandler = (boxId: string) => {
const [bbox, setBbox] = useState({ top: 0, left: 0 });
const startXRef = useRef(0);
const startYRef = useRef(0);
const boxTopRef = useRef(0);
const boxLeftRef = useRef(0);
// --- rAF 및 이벤트 핸들러 관리를 위한 Ref ---
const animationFrameRef = useRef<number | null>(null); // rAF ID
const latestMouseEventRef = useRef<MouseEvent | null>(null); // 최신 마우스 이벤트
const updateBbox = useCallback(
(e: MouseEvent) => {
const deltaX = e.pageX - startXRef.current;
const deltaY = e.pageY - startYRef.current;
setBbox({
left: deltaX + boxLeftRef.current,
top: deltaY + boxTopRef.current,
});
},
[setBbox],
);
// rAF에 의해 모니터 주사율에 맞춰 실행될 함수
const runAnimationLoop = useCallback(() => {
if (!latestMouseEventRef.current) {
animationFrameRef.current = null;
return;
}
updateBbox(latestMouseEventRef.current);
// 다음 프레임에도 이 루프가 실행되도록 재귀적으로 예약
animationFrameRef.current = requestAnimationFrame(runAnimationLoop);
}, [updateBbox]);
// 오직 최신 마우스 이벤트를 기록하고, rAF 루프를 시작시키는 역할만 한다.
const handleDocumentMouseMove = useCallback(
(e: MouseEvent) => {
e.preventDefault();
latestMouseEventRef.current = e; // 1. 최신 이벤트만 기록
// 2. rAF 루프가 아직 실행되지 않았으면 실행
if (!animationFrameRef.current) {
animationFrameRef.current = requestAnimationFrame(runAnimationLoop);
}
},
[runAnimationLoop],
);
const handleDocumentMouseUp = useCallback(() => {
// 1. rAF 루프 중지
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
// 2. 이벤트 리스너 제거
document.removeEventListener('mousemove', handleDocumentMouseMove);
document.removeEventListener('mouseup', handleDocumentMouseUp);
// 3. Ref 초기화
latestMouseEventRef.current = null;
}, [handleDocumentMouseMove]);
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
startXRef.current = e.pageX;
startYRef.current = e.pageY;
boxTopRef.current = bbox.top;
boxLeftRef.current = bbox.left;
document.addEventListener('mousemove', handleDocumentMouseMove);
document.addEventListener('mouseup', handleDocumentMouseUp);
},
[bbox.left, bbox.top, handleDocumentMouseMove, handleDocumentMouseUp],
);
useEffect(() => {
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
document.removeEventListener('mousemove', handleDocumentMouseMove);
document.removeEventListener('mouseup', handleDocumentMouseUp);
};
}, [handleDocumentMouseMove, handleDocumentMouseUp]);
return { handleMouseDown };
};
export default useBoxElementMouseHandler;
개선된 프로세스의 타임라인을 살펴보자.
mousemove 이벤트 발생 -> latestMouseEventRef.current에 마우스 이벤트 저장, 첫 번째 rAF 예약mousemove 이벤트 발생 -> latestMouseEventRef.current에 마우스 이벤트 덮어쓰기rAF 콜백 함수, 즉 runAnimationLoop 함수 실행
latestMouseEventRef.current에 저장된 마우스 이벤트 정보를 가지고 setBBox 실행하고 리렌더링 요청rAF를 또 예약결과적으로 16.67ms 동안 발생한 수많은 마우스 이벤트 중 가장 마지막 위치 정보만 사용해 단 한 번의 연산과 리렌더링을 수행하게 된다. 불필요한 낭비가 사라졌다.
그렇다면 브라우저는 도대체 어떻게 '다음 프레임을 그리기 직전'을 알고 rAF 콜백을 실행하는 걸까?
이는 브라우저의 이벤트 루프(Event Loop) 및 렌더링 파이프라인과 깊은 관련이 있다.
브라우저의 메인 스레드는 자바스크립트 실행, 이벤트 처리, 화면 렌더링을 모두 담당한다. 이 작업들을 조율하는 것이 이벤트 루프다.

일반적인 setTimeout이나 setInterval 같은 타이머 함수들은 매크로태스크 큐(Macrotask Queue)에서 관리한다. 이벤트 루프는 콜 스택이 비었을 때, 이 큐에 쌓인 작업을 하나씩 꺼내서 실행한다. 그러나 이 시점이 화면의 렌더링 시점과 맞으리라는 보장은 전혀 없다. 그래서 setTimeout(callback, 10)으로 애니메이션을 구현하면 위에서 봤던 프레임 드랍이 발생하기 쉽다.
반면, requestAnimationFrame으로 등록된 콜백 함수들은 별도의 애니메이션 프레임 큐(Animation Frame Queue)에서 관리된다.
렌더링이 필요하다고 판단되면 (보통 60Hz 주기에 맞춰서) 아래 단계가 이어진다.
브라우저는 '스타일 계산 단계가 시작되기 바로 직전 단계(3번)에서 rAF 콜백들을 몰아서 실행'하도록 설계되어 있다. 이 덕분에 rAF 내부에서 수행한 DOM 변경이나 스타일 조작이 바로 이어지는 렌더링 파이프라인(4~7번)에 즉시 반영되어 가장 최신의 상태로 부드럽게 화면이 그려지는 것이 보장된다.
rAF는 브라우저가 화면을 그리는 메커니즘을 이해하고 그 주기에 맞춰 코드를 실행하게 해주는 강력한 도구다.
특히 마우스 이동, 스크롤, 캔버스 드로잉 등 짧은 시간에 수많은 이벤트가 발생하거나 복잡한 애니메이션을 구현해야 할 때, rAF는 불필요한 연산을 줄이고 사용자에게 60fps의 부드러운 경험을 선사하는 핵심 열쇠가 된다.
리액트 같은 프레임워크 환경에서도 useRef와 함께 적절히 활용해 성능을 최적화해보면 좋겠다.