프로젝트 개선

requestAnimationFrame으로 드로잉 성능 최적화하기

권끼리마끼리 2025. 5. 29. 16:43

안녕하세요 :) 오늘은 requestAnimationFrame() 함수를 활용해 부드러운 애니메이션을 구현하고 성능을 최적화한 경험을 공유하고자 합니다.

 

회사에서 Canvas를 이용해 Figma처럼 드로잉툴을 구현해야 하는 과제가 있었습니다.

figma 예시

 

제가 택한 구현 방법은 mousemove 이벤트가 발생할 때마다 점을 수집해 state에 저장하고, 이를 기반으로 선을 그리는 것이었습니다. 아주 간단한 예시 코드는 다음과 같습니다.

  const handleMouseMove = () => {
    const point = stageRef.current.getPointerPosition();
    if (point) {
      setDrawingPoints((prev) => [...prev, point]);
    }
  }

 

문제는 setter 함수를 통해 상태를 업데이트할 때마다 컴포넌트가 리렌더링된다는 점이었습니다. 마우스가 움직일 때마다 setter가 호출되는 것이 성능 면에서 찝찝하게 느껴졌습니다.

 

이럴 때 자주 언급되는 방법이 바로 throttledebounce입니다. 아래는 두 방법에 대한 간단한 설명입니다.

  • debounce는 이벤트가 연달아 발생하더라도 마지막 이벤트 이후 일정 시간 동안 추가 이벤트가 없을 때 콜백이 실행됩니다.
  • 반면 throttle은 일정 주기로 콜백이 실행되도록 제한합니다.

저는 연속적인 마우스 움직임이 발생하는 드로잉 상황에 적합한 throttle을 선택했습니다. 그리고 사용자에게 가장 자연스럽고 편안한 속도인 초당 60프레임(1프레임당 약 16ms)을 기준으로, throttle을 사용해 setter 함수가 16ms마다 실행되도록 조정했습니다.

 

이를 통해 지나치게 많은 리렌더링을 방지하면서도, 부드럽고 매끄러운 드로잉 경험을 제공할 수 있었습니다.

throttle이 최선일까?

하지만 throttle을 16ms로 설정하면서도 계속 마음에 걸리는 부분이 있었습니다. 60fps라는 기준 자체은 60Hz 모니터에 맞춘 값입니다.

그런데 요즘은 120Hz 아이패드나 144Hz 이상의 게이밍 모니터도 흔히 사용됩니다.

이런 상황에서 제가 16ms 간격으로 setter를 호출한다는 건, 결국 의도적으로 성능을 60fps에 맞춰 제한하고 있는 셈이죠.

 

정말 이게 최적일까?라는 의문이 들기 시작했습니다.

더 나은 방법이 있을지 고민하며 mousemove 이벤트 최적화와 관련된 자료를 찾아보다가, requestAnimationFrame을 활용한 방식에 대해 다룬 글을 보게 되었습니다.

 

requestAnimationFrame은 브라우저가 다음 화면을 그리기 직전에 지정한 콜백 함수를 실행해주는 API입니다.

즉, 디스플레이의 주사율(예: 60Hz, 120Hz 등)에 맞춰 애니메이션을 부드럽게 실행할 수 있도록 도와주는 함수죠.

 

가장 큰 장점은 브라우저가 렌더링 타이밍을 제어한다는 점입니다.

덕분에 개발자가 임의로 시간을 조절할 필요 없이 각 기기의 환경에 맞춰 최적의 퍼포먼스를 낼 수 있습니다.

예를 들어, 120Hz 디스플레이에선 초당 최대 120번까지 콜백이 실행될 수 있어 더 부드러운 반응을 만들 수 있습니다.

 

또한 setTimeout이나 throttle과 달리, 탭이 비활성화되었을 때는 자동으로 실행을 멈추는 등 불필요한 연산을 줄여주기 때문에 성능 측면에서도 유리합니다.

 

성능 최적화 테스트해보기

requestAnimationFrame을 적용 후 성능 차이를 직접 확인해보기 위해 개발자도구의 performance 탭을 통해 record를 해보았습니다.

노란색은 partially dropped frame의 개수입니다. 최적화후에 초록색 막대로 가득찬 것이 보이시나요?

 

최적화 전

 

최적화 후

 

드로잉 과정에서는 mousemove 이벤트가 매우 자주 발생합니다.

이러한 이벤트가 브라우저가 처리할 여유도 없이 과도하게 쏟아지면, 결국 프레임 드랍(끊김)으로 이어질 수 있습니다.

 

반면, requestAnimationFrame을 사용하면 브라우저의 렌더링 타이밍에 맞춰 움직임을 반영하게 되므로, 콜백이 과도하게 실행되는 일을 방지할 수 있습니다.

 

그 결과, 더 예측 가능하고 일관된 간격으로 그리기가 가능해져, 사용자 입장에서는 훨씬 부드러운 드로잉 경험을 느낄 수 있습니다.

 

추가적인 최적화

더 나은 성능을 위해 점의 수집과 표시를 분리하는 방식으로 한 단계 더 최적화를 시도했습니다. 

 

useRef를 활용해 pointsRef라는 변수를 만들어 점 수집만 담당하게 하고, mousemove 이벤트가 발생할 때마다 해당 배열에 좌표를 직접 저장하도록 했습니다. 그리고 requestAnimationFrame의 콜백이 호출될 때, 이 수집된 점들을 한꺼번에 상태에 반영하는 구조로 변경했죠.

 

간단한 코드로 비교하자면 다음과 같습니다:

//이전
setDrawingPoints((prev) => [...prev, point]);

//이후
pointsRef.current.push(point);
setDrawingPoints([...pointsRef.current]);

 

이 방식의 장점은, 매 이벤트마다 새로운 배열을 생성하지 않아도 되기 때문에 불필요한 연산을 줄일 수 있다는 점입니다.

또한 점을 일괄적으로 업데이트함으로써 보다 많은 좌표를 안정적으로 수집할 수 있었고, 결과적으로 더 부드럽고 자연스러운 선 그리기가 가능해졌습니다.

 

코드 개선은 언제나 많은 공부를 필요로 하는 것 같습니다 ㅎㅎ 다음에도 제가 프로젝트를 하며 접했던 방법들을 전하러 돌아오겠습니다. 감사합니다.