이벤트는 화면이 그려지는 것보다 훨씬 자주 발생합니다. 브라우저 렌더링 주기가 어떻게 도는지, 왜 setTimeout 스로틀이 아니라 requestAnimationFrame을 쓰는지, 그리고 rAF 스로틀 패턴의 핵심과 정리(cleanup)까지 정리합니다

발단 - 마우스를 움직일 때마다 전부 계산하고 있었다

Shift 드래그 다중 선택 기능을 만들면서, mousemove 좌표로 "지금 어느 행 위에 있는지"를 계산해야 했습니다. 그런데 mousemove는 마우스를 슥 긋는 동안에도 수십~수백 번 발생합니다. 이벤트마다 행 탐색과 상태 적용을 전부 돌리면, 한 프레임에 주어진 16.7ms를 계산으로 다 써버립니다. 그러면 화면을 제때 못 그려서 드래그가 뚝뚝 끊깁니다 (이 기능을 만든 전체 이야기는 Shift 드래그 다중 선택 만들기에 있습니다)

화면은 프레임당 한 번만 그려집니다. 한 프레임 안에 mousemove가 다섯 번 와도 화면에 남는 건 마지막 것 하나입니다. 앞의 네 번은 계산만 하고 버려집니다

화면은 프레임 단위로 그려진다

먼저 브라우저가 화면을 어떻게 갱신하는지부터 봐야 합니다. 디스플레이는 주사율(refresh rate) 에 맞춰 화면을 다시 그립니다. 보통 60Hz - 초당 60번, 약 16.7ms에 한 번입니다 (요즘 모니터는 120Hz, 144Hz도 흔합니다). 브라우저는 이 주기에 맞춰 한 "프레임"을 만듭니다

한 프레임 (~16.7ms @ 60Hz)

입력 이벤트 처리
  → rAF 콜백              ← 내 코드가 실행되는 자리
  → 브라우저가 화면을 그림   (스타일 계산 → 레이아웃 → 페인트 → 합성)
  → 화면에 표시

한 프레임 안에서 상태를 다섯 번 계산해도 화면에는 마지막 결과만 반영됩니다. 앞의 네 번은 낭비입니다

찾아보니 입력 장치는 프레임과 무관하게 자기 속도로 움직인다고 합니다. 마우스는 보통 125Hz, 게이밍 마우스는 1000Hz로 위치를 보고합니다. 최신 Chrome은 mousemove 같은 연속 이벤트를 프레임에 맞춰 병합(coalescing)해 주기도 하는데, 브라우저별 최적화라 표준이 보장하는 건 아니었습니다. 그래서 이벤트가 프레임보다 자주 올 수 있다고 보고 코드를 짰습니다

mousemove와 프레임 경계 - rAF 스로틀은 프레임당 마지막 좌표만 처리한다

requestAnimationFrame

requestAnimationFrame(이하 rAF)은 정확히 이 지점을 위한 API입니다. 콜백을 등록하면 브라우저가 다음 프레임을 그리기 직전에 호출해 줍니다

requestAnimationFrame((timestamp) => {
  // 다음 프레임의 스타일 계산·레이아웃·페인트 직전에 실행됨
});

특징 세 가지가 눈에 들어왔습니다

  • 일회성입니다. 한 번 등록하면 한 번만 불립니다. 매 프레임 실행하려면 콜백 안에서 재등록해야 합니다
  • 렌더링 주기와 정확히 동기화됩니다. 60Hz 모니터에서는 초당 60번, 120Hz에서는 120번 - 화면이 그려지는 바로 그 리듬으로 불립니다
  • 화면이 안 그려지면 안 불립니다. 탭이 백그라운드로 가거나 창이 최소화되면 브라우저는 그리기를 멈추고, rAF 콜백도 함께 멈춥니다

이 셋을 조합하니 "프레임당 한 번만 처리"가 정확히 만들어졌습니다

rAF 스로틀 패턴

패턴의 뼈대는 두 부분입니다. 이벤트 핸들러는 최신 값을 덮어쓰기만 하고(저렴), 실제 처리는 프레임당 한 번 rAF 콜백에서 합니다(비싼 일)

let frame = 0;
let pending = null;

const process = () => {
  frame = 0; // 처리 끝 - 다음 예약 허용
  const point = pending;
  pending = null;
  if (point) handle(point); // 비싼 일은 여기서, 프레임당 1회
};

const onMouseMove = (e) => {
  pending = { x: e.clientX, y: e.clientY }; // 덮어쓰기만 - 몇 번 와도 저렴
  if (!frame) frame = requestAnimationFrame(process); // 중복 예약 방지
};

if (!frame) 한 줄이 없으면 이 패턴이 성립하지 않습니다. 한 프레임에 이벤트가 5번 오면 rAF도 5개 예약되고, 다음 프레임에 process가 5번 실행돼 스로틀이 무너집니다. 이 조건 덕분에 "예약은 프레임당 최대 1건"이 보장되고, 그 사이 도착한 이벤트들은 pending에 마지막 값만 남습니다. 어차피 화면에 반영될 수 있는 것도 마지막 값뿐이니 정보 손실은 없습니다

왜 setTimeout 스로틀이 아닌가

처음엔 lodash throttle이나 setTimeout(fn, 16)으로도 되지 않나 싶었습니다. 찾아보니 세 가지가 달랐습니다

주기가 렌더링과 어긋납니다. setTimeout(fn, 16)의 16ms는 "60Hz면 대충 맞겠지"라는 추측이지, 실제 프레임 경계와 무관합니다. 타이머는 프레임 중간에 발화할 수 있고, 그러면 한 프레임에 두 번 처리되거나 한 프레임을 건너뛰는 드리프트가 생깁니다. 120Hz 모니터에서는 아예 절반 주기로 동작합니다. rAF는 추측이 아니라 브라우저가 알려주는 진짜 프레임 경계입니다

백그라운드 동작이 다릅니다. 탭이 백그라운드로 가면 rAF는 완전히 멈추지만, setTimeout은 (1초 이상으로 클램핑될 뿐) 계속 돕니다. 보이지도 않는 탭에서 좌표 계산을 계속 돌릴 이유가 없습니다

실행 시점의 질이 다릅니다. rAF 콜백은 스타일 계산·레이아웃 직전이라는 정해진 자리에서 실행됩니다. 여기서 DOM을 읽고 쓰면 그 결과가 바로 이어지는 렌더링에 반영됩니다. 타이머 콜백은 이벤트 루프의 임의 시점에 끼어들어 그런 정렬이 없습니다

참고로 debounce와는 목적이 다릅니다. debounce는 "이벤트가 멈춘 뒤 한 번"(검색 입력 등), throttle/rAF는 "진행되는 동안 주기적으로"입니다. 드래그처럼 진행 중에 계속 반응해야 하는 인터랙션이라 debounce는 맞지 않았습니다

함정 - cleanup을 잊으면 끝난 뒤에 한 번 더 실행된다

rAF는 등록 시 ID를 돌려주고, cancelAnimationFrame(id)으로 취소합니다. 이걸 잊기 쉬운 자리가 두 곳입니다

// 1. 인터랙션 종료 시 - 드래그가 끝났는데 마지막 rAF가 남아 있으면
//    종료 후에 한 번 더 처리가 실행됨
const endDrag = () => {
  if (frame) cancelAnimationFrame(frame);
  frame = 0;
  pending = null;
};

// 2. React라면 unmount cleanup - 컴포넌트가 사라져도 예약된 rAF는 살아 있음
useEffect(() => {
  return () => {
    if (frameRef.current) cancelAnimationFrame(frameRef.current);
  };
}, []);

예약된 rAF 콜백은 컴포넌트 언마운트를 모릅니다. 취소하지 않으면 사라진 컴포넌트의 상태를 만지려다 에러가 나거나, 클로저가 붙잡은 참조 때문에 메모리가 해제되지 않습니다. window에 건 이벤트 리스너와 정확히 같은 이유로, rAF도 등록했으면 해제까지 같이 넣어둬야 했습니다

마치며

  • 화면은 프레임당 한 장만 그려집니다. 한 프레임 안의 중간 계산은 사용자에게 보이지 않는 낭비입니다
  • rAF는 "다음 프레임 직전"이라는 정확한 시점을 주는 API입니다 - 렌더링 주기 동기화와 백그라운드 자동 중지도 같이 해결됩니다
  • rAF 스로틀 패턴의 핵심은 둘입니다. 핸들러는 최신 값 덮어쓰기만, if (!frame) 조건으로 예약은 프레임당 1건
  • setTimeout(16)은 프레임 경계에 대한 추측이고, rAF는 프레임 경계 그 자체입니다
  • 등록했으면 해제합니다 - 인터랙션 종료와 unmount, 두 자리 모두에서 cancelAnimationFrame

고빈도 이벤트를 만나면 "얼마나 자주 오는가"보다 "결과가 얼마나 자주 보이는가"를 먼저 보게 됐습니다. 보이는 주기보다 자주 계산하고 있다면 그 차이가 전부 낭비입니다