requestAnimationFrame 활용 부드러운 인터렉션 만들기

프론트엔드 개발을 하다보면 애니메이션이나 인터렉티브한 UI를 구현해야 할 때가 있습니다. 이때 단순히 이벤트 구독을 통해 상태를 업데이트하다보면 화면이 버벅거리거나 불필요하게 CPU 자원을 낭비할 수 있게 됩니다.
이러한 문제를 해결하고 부드러운 UX를 제공하기 위해 필수적으로 알아야 할 브라우저 API인 requestAnimationFrame(이하 rAF)에 대해 알아보고, 리액트 환경에서 어떻게 도입하는지 살펴보겠습니다.
1. 사전 지식: 모니터 주사율과 프레임
먼저 rAF를 이해하기 위해서는 하드웨어인 '모니터'가 화면을 그리는 방식을 이해해야 합니다.
모니터가 화면을 그리는 방식은 어릴 적 교과서 귀퉁이에 그림을 그려 빠르게 넘겨보던 '플립북'과 유사합니다. 플립북이 여러 장의 정지된 그림을 빠르게 넘겨 움직이는 것처럼 보이게 하듯, 모니터도 짧은 시간 동안 화면 전체를 새로운 그림으로 계속해서 '새로고침'합니다. 이때 화면에 보여지는 하나의 그림을 프레임(Frame)이라고 부릅니다.
즉, 어떤 모니터가 60Hz 주사율을 갖는다는 건, 모니터가 물리적으로 1초에 60개의 프레임을 보여줄 수 있다는 뜻입니다. 그렇다면 한 장의 프레임이 화면에 머무르는 시간은, 1000ms / 60 = 약 16.67ms 정도 되겠죠.
- 주사율(Refresh Rate, Hz): 모니터가 1초에 몇 번 화면을 새로고침하는지 나타내는 단위
- 프레임(Frame): 모니터가 한 번에 보여주는 정지된 화면
2. 문제 상황: 왜 rAF가 필요할까?
우리가 아무리 빠르게 화면을 바꾸고 싶어도, 일반적인 60Hz 모니터 환경에서는 최소 16.67ms마다 한 번씩만 새로운 화면을 사용자에게 보여줄 수 있다는 물리적인 한계가 있습니다.
만약 이 물리적 한계를 고려하지 않고 코드를 작성하면 어떤 문제가 발생할까요? 마우스로 박스를 드래그하여 이동시키는 기능을 리액트 환경에서 구현해야하는 상황을 가정해보겠습니다.
아래는 mousemove 이벤트가 발생할 때마다 즉시 상태(bbox)를 업데이트하여 박스의 위치를 옮기는 커스텀 훅 입니다.
import { useCallback, useRef, useState } from 'react';
const useBoxMouseHandler = (boxId: string) => {
// bbox: {top, left}
const [bbox, setBbox] = useState(boxBboxAtomFamily(boxId));
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);
}, [handleMouseMove]);
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, handleMouseMove, handleMouseUp],
);
return { handleMouseDown };
};
export default useBoxElementMouseHandler;
무엇이 문제일까요?
요즘 사용되는 고성능 마우스는 1초에 수백 번 이상의 위치 정보를 컴퓨터로 전송합니다. (높은 폴링 레이트) 만약 사용자가 빠르게 마우스를 움직여서 1초에 120번의 mousemove 이벤트가 발생했다고 가정해 봅시다.
-
handleMouseMove함수가 1초에 120번 실행됩니다. -
setBbox상태 업데이트 함수도 1초에 120번 호출됩니다. -
리액트는 1초에 120번의 리렌더링을 시도하고, 브라우저에게 화면을 120번 다시 그리라고 요청합니다.
하지만 앞서 살펴보았듯, 60Hz 모니터는 1초에 최대 60번만 화면을 그릴 수 있습니다.
- 1ms 후,
mousemove이벤트 발생 -> 새로운 bbox 값으로 리렌더링 요청 1 (반영 X) - 2ms 후,
mousemove이벤트 발생 -> 새로운 bbox 값으로 리렌더링 요청 2 (반영 X) - 2ms 후,
mousemove이벤트 발생 -> 새로운 bbox 값으로 리렌더링 요청 2 (반영 X) - ...
- 16.67ms 후, 모니터가 화면을 갱신하면서, 그 시점에 계산된 가장 최신 상태의 bbox만 화면에 반영 (ex. 리렌더링 요청 10)
즉, 실제로 모니터에 반영되지도 못할 중간 단계의 수많은 계산과 리렌더링이 발생하여 CPU 자원을 낭비하게 됩니다. 이는 메인 스레드를 바쁘게 만들어 다른 중요한 작업의 처리를 지연시키고, 결과적으로 애니메이션이 끊겨 보이는 '렉'의 원인이 됩니다.
- 리액트: "bbox 상태 값이 120번이나 바꼈네? 120번 컴포넌트 함수 다시 실행하고, 120번 가상 돔 비교할게."
- 브라우저: "리액트가 120번이나 돔 건들여서 나도 다 반영해주고 싶긴 한데, 모니터가 1초에 60번 밖에 못보여줘서 나머지 60번은 그냥 버려져.."
3. 해결책: requestAnimationFrame 도입
위 문제의 핵심은 결국 '이벤트 발생 주기'와 '브라주어 렌더링 주기'의 불일치 입니다.
그리고 이 주기를 맞춰주는 역할을 해주는 게 바로 requestAnimationFrame입니다.
requestAnimationFrame이란?
requestAnimationFrame(callback)은 브라우저에게 다음과 같이 요청하는 API입니다.
- rAF: "브라우저야, 네가 다음 프레임을 그리기 직전(약 16.67ms가 되기 직전)에, 내가 전달한 이
callback함수를 딱 한 번만 실행해 줘."
이를 통해 우리는 모니터가 화면을 그릴 준비가 되었을 때만 상태를 업데이트하고 그림을 그리도록 예약을 걸 수 있습니다.
- 사용 전: 마우스 이벤트가 120번 발생하면, 화면 갱신 요청도 무식하게 120번 보내기
- 사용 후: 마우스 이벤트가 아무리 많이 발생해도, 실제 화면 갱신 작업은 모니터 주사율에 맞춰 1초에 최대 60번만 수행하기
이제 rAF를 사용하여 위의 리액트 훅을 최적화해보겠습니다.
import { useCallback, useRef, useEffect, useState } from 'react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
const useBoxElementMouseHandler = (id: 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);
}, [handleMouseMove]);
// 오직 최신 마우스 이벤트를 기록하고, 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>, direction?: string) => {
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;
개선된 프로세스의 타임라인을 살펴보겠습니다.
-
1ms 후,
mousemove이벤트 발생 ->latestMouseEventRef.current에 마우스 이벤트 저장, 첫번째 rAF 예약 -
2ms 후,
mousemove이벤트 발생 ->latestMouseEventRef.current에 마우스 이벤트 덮어쓰기 -
...
-
약 16ms 후(프레임 그리기 직전), 브라우저가 예약해둔
rAF콜백 함수, 즉runAnimationLoop함수 실행-
latestMouseEventRef.current에 저장된 마우스 이벤트 정보 가지고setBBox실행하고 리렌더링 요청 -
다음
rAF를 또 예약
-
-
16.67ms 후, 모니터는 계산된 결과를 화면에 반영
결과적으로 16.67ms 동안 발생한 수많은 마우스 이벤트 중 가장 마지막 위치 정보만 사용하여 단 한 번의 연산과 리렌더링을 수행하게 됩니다. 불필요한 낭비가 사라졌네요!
4. requestAnimationFrame 동작 방식과 이벤트 루프
그렇다면 브라우저는 도대체 어떻게 '다음 프레임을 그리기 직전'을 알고 rAF 콜백을 실행하는 걸까요?
이는 브라우저의 이벤트 루프(Event Loop) 및 렌더링 파이프라인과 깊은 관련이 있습니다.
브라우저의 메인 스레드는 자바스크립트 실행, 이벤트 처리, 그리고 화면 렌더링을 모두 담당합니다. 이 작업들을 조율하는 것이 이벤트 루프입니다.

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